commit aed40de4bd6793ec92a7474d858e2af0063b4ef8 Author: Sucukdeluxe <259325684+Sucukdeluxe@users.noreply.github.com> Date: Wed Aug 5 21:38:06 2026 +0200 release: veröffentliche Twitch VOD Manager 1.0.1 Startet die öffentliche Versionslinie mit einer bereinigten Ein-Commit-Historie, stellt den Updater auf GitHub Releases um, entfernt interne Release-Ziele und beschränkt den gepackten Anwendungssatz auf notwendige Laufzeitdateien. Enthält aktualisierte produktive Abhängigkeiten ohne bekannte npm-Audit-Funde sowie die geprüfte öffentliche Quell-Positivliste. diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..882a58a --- /dev/null +++ b/.gitignore @@ -0,0 +1,11 @@ +node_modules/ +dist/ +release/ +coverage/ +tmp_*/ +*.log +*.local +.env +.env.* +!.env.example +.DS_Store diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..f87bfb2 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,9 @@ +# Changelog + +## 1.0.1 - 2026-08-05 + +- New clean public release line based on the complete desktop application. +- Twitch VOD, clip, trim, split, merge, queue, history and automation workflows. +- Streamer profiles, VOD previews, themes, localization and command palette. +- Resumable downloads, integrity checks, secure local storage and SQLite migration. +- Automatic update checks and downloads through GitHub Releases. diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..a370e13 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Twitch VOD Manager contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..d690f60 --- /dev/null +++ b/README.md @@ -0,0 +1,38 @@ +# Twitch VOD Manager + +Twitch VOD Manager is a Windows desktop application for finding, downloading, trimming, splitting, merging and organizing Twitch VODs and clips. + +## Features + +- Search streamers and browse available VODs +- Download complete VODs or precise time ranges +- Split long recordings into configurable parts +- Merge related downloads and track group progress +- Resume interrupted downloads and verify completed files +- Manage queues, history, profiles and per-streamer automation +- Capture live streams and Twitch chat +- Use light and dark themes with German and English localization +- Receive application updates through GitHub Releases + +## Installation + +Download the current Windows installer from [GitHub Releases](https://github.com/Sucukdeluxe/Twitch-VOD-Manager/releases/latest). + +The application stores its settings and local database on the computer where it is installed. No Twitch credentials, user settings, download history or personal data are included in this repository or its release files. + +## Development + +Requirements: + +- Node.js 20 or newer +- Windows for building the NSIS installer + +```powershell +npm ci +npm run test:e2e:release +npm run dist:win +``` + +## License + +Twitch VOD Manager is available under the [MIT License](LICENSE). diff --git a/build/installer.nsh b/build/installer.nsh new file mode 100644 index 0000000..4ee8fa1 --- /dev/null +++ b/build/installer.nsh @@ -0,0 +1,4 @@ +!macro customInit + ; Kill running Twitch VOD Manager process before installation + nsExec::ExecToLog 'taskkill /F /IM "Twitch VOD Manager.exe"' +!macroend diff --git a/eslint.config.mjs b/eslint.config.mjs new file mode 100644 index 0000000..4082710 --- /dev/null +++ b/eslint.config.mjs @@ -0,0 +1,25 @@ +import js from '@eslint/js'; +import tseslint from 'typescript-eslint'; +import security from 'eslint-plugin-security'; + +export default [ + js.configs.recommended, + ...tseslint.configs.recommended, + security.configs.recommended, + { + files: ['src/**/*.ts'], + rules: { + // Tune down noisy rules for existing codebase + '@typescript-eslint/no-explicit-any': 'off', + '@typescript-eslint/no-unused-vars': ['warn', { argsIgnorePattern: '^_' }], + 'no-console': 'off', + 'security/detect-object-injection': 'off', // Too many false positives with Record types + 'security/detect-non-literal-fs-filename': 'off', // All paths come from controlled sources + 'no-async-promise-executor': 'warn', + 'no-empty': ['warn', { allowEmptyCatch: true }], + } + }, + { + ignores: ['dist/**', 'release/**', 'node_modules/**', 'scripts/**', 'tmp_*/**'] + } +]; diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..c0e2d1a --- /dev/null +++ b/package-lock.json @@ -0,0 +1,6752 @@ +{ + "name": "twitch-vod-manager", + "version": "1.0.1", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "twitch-vod-manager", + "version": "1.0.1", + "license": "MIT", + "dependencies": { + "axios": "^1.16.1", + "better-sqlite3": "^12.10.0", + "electron-updater": "^6.8.3" + }, + "devDependencies": { + "@eslint/js": "^10.0.1", + "@types/better-sqlite3": "^7.6.13", + "@types/node": "^20.10.0", + "electron": "^28.0.0", + "electron-builder": "^24.9.0", + "eslint": "^10.4.0", + "eslint-plugin-security": "^4.0.0", + "playwright": "^1.60.0", + "typescript": "^5.3.0", + "typescript-eslint": "^8.59.4", + "vitest": "^4.1.6" + } + }, + "node_modules/@develar/schema-utils": { + "version": "2.6.5", + "resolved": "https://registry.npmjs.org/@develar/schema-utils/-/schema-utils-2.6.5.tgz", + "integrity": "sha512-0cp4PsWQ/9avqTVMCtZ+GirikIA36ikvjtHweU4/j8yLtgObI0+JUPhYFScgwlteveGB1rt3Cm8UhN04XayDig==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^6.12.0", + "ajv-keywords": "^3.4.1" + }, + "engines": { + "node": ">= 8.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/@electron/asar": { + "version": "3.4.1", + "resolved": "https://registry.npmjs.org/@electron/asar/-/asar-3.4.1.tgz", + "integrity": "sha512-i4/rNPRS84t0vSRa2HorerGRXWyF4vThfHesw0dmcWHp+cspK743UanA0suA5Q5y8kzY2y6YKrvbIUn69BCAiA==", + "dev": true, + "license": "MIT", + "dependencies": { + "commander": "^5.0.0", + "glob": "^7.1.6", + "minimatch": "^3.0.4" + }, + "bin": { + "asar": "bin/asar.js" + }, + "engines": { + "node": ">=10.12.0" + } + }, + "node_modules/@electron/asar/node_modules/brace-expansion": { + "version": "1.1.18", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz", + "integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/@electron/asar/node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/@electron/get": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@electron/get/-/get-2.0.3.tgz", + "integrity": "sha512-Qkzpg2s9GnVV2I2BjRksUi43U5e6+zaQMcjoJy0C+C5oxaKl+fmckGDQFtRpZpZV0NQekuZZ+tGz7EA9TVnQtQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.1.1", + "env-paths": "^2.2.0", + "fs-extra": "^8.1.0", + "got": "^11.8.5", + "progress": "^2.0.3", + "semver": "^6.2.0", + "sumchecker": "^3.0.1" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "global-agent": "^3.0.0" + } + }, + "node_modules/@electron/notarize": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/@electron/notarize/-/notarize-2.2.1.tgz", + "integrity": "sha512-aL+bFMIkpR0cmmj5Zgy0LMKEpgy43/hw5zadEArgmAMWWlKc5buwFvFT9G/o/YJkvXAJm5q3iuTuLaiaXW39sg==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.1.1", + "fs-extra": "^9.0.1", + "promise-retry": "^2.0.1" + }, + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/@electron/notarize/node_modules/fs-extra": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-9.1.0.tgz", + "integrity": "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "at-least-node": "^1.0.0", + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@electron/notarize/node_modules/jsonfile": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.0.tgz", + "integrity": "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/@electron/notarize/node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/@electron/osx-sign": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@electron/osx-sign/-/osx-sign-1.0.5.tgz", + "integrity": "sha512-k9ZzUQtamSoweGQDV2jILiRIHUu7lYlJ3c6IEmjv1hC17rclE+eb9U+f6UFlOOETo0JzY1HNlXy4YOlCvl+Lww==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "compare-version": "^0.1.2", + "debug": "^4.3.4", + "fs-extra": "^10.0.0", + "isbinaryfile": "^4.0.8", + "minimist": "^1.2.6", + "plist": "^3.0.5" + }, + "bin": { + "electron-osx-flat": "bin/electron-osx-flat.js", + "electron-osx-sign": "bin/electron-osx-sign.js" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/@electron/osx-sign/node_modules/fs-extra": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", + "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@electron/osx-sign/node_modules/isbinaryfile": { + "version": "4.0.10", + "resolved": "https://registry.npmjs.org/isbinaryfile/-/isbinaryfile-4.0.10.tgz", + "integrity": "sha512-iHrqe5shvBUcFbmZq9zOQHBoeOhZJu6RQGrDpBgenUm/Am+F3JM2MgQj+rK3Z601fzrL5gLZWtAPH2OBaSVcyw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/gjtorikian/" + } + }, + "node_modules/@electron/osx-sign/node_modules/jsonfile": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.0.tgz", + "integrity": "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/@electron/osx-sign/node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/@electron/universal": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@electron/universal/-/universal-1.5.1.tgz", + "integrity": "sha512-kbgXxyEauPJiQQUNG2VgUeyfQNFk6hBF11ISN2PNI6agUgPl55pv4eQmaqHzTAzchBvqZ2tQuRVaPStGf0mxGw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@electron/asar": "^3.2.1", + "@malept/cross-spawn-promise": "^1.1.0", + "debug": "^4.3.1", + "dir-compare": "^3.0.0", + "fs-extra": "^9.0.1", + "minimatch": "^3.0.4", + "plist": "^3.0.4" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/@electron/universal/node_modules/brace-expansion": { + "version": "1.1.18", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz", + "integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/@electron/universal/node_modules/fs-extra": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-9.1.0.tgz", + "integrity": "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "at-least-node": "^1.0.0", + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@electron/universal/node_modules/jsonfile": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.0.tgz", + "integrity": "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/@electron/universal/node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/@electron/universal/node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.9.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", + "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/config-array": { + "version": "0.23.5", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.23.5.tgz", + "integrity": "sha512-Y3kKLvC1dvTOT+oGlqNQ1XLqK6D1HU2YXPc52NmAlJZbMMWDzGYXMiPRJ8TYD39muD/OTjlZmNJ4ib7dvSrMBA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^3.0.5", + "debug": "^4.3.1", + "minimatch": "^10.2.4" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/config-array/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/@eslint/config-array/node_modules/brace-expansion": { + "version": "5.0.9", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", + "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/@eslint/config-array/node_modules/minimatch": { + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.5" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@eslint/config-helpers": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.6.0.tgz", + "integrity": "sha512-ii6Bw9jJ2zi2cWA2Z+9/QZ/+3DX6kwaV5Q986D/CdP3Lap3w/pgQZ373FV7byY/i7L4IRH/G43I5dz1ClsCbpA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^1.2.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/core": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-1.2.1.tgz", + "integrity": "sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/js": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-10.0.1.tgz", + "integrity": "sha512-zeR9k5pd4gxjZ0abRoIaxdc7I3nDktoXZk2qOv9gCNWx3mVwEn32VRhyLaRsDiJjTs0xq/T8mfPtyuXu7GWBcA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "eslint": "^10.0.0" + }, + "peerDependenciesMeta": { + "eslint": { + "optional": true + } + } + }, + "node_modules/@eslint/object-schema": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-3.0.5.tgz", + "integrity": "sha512-vqTaUEgxzm+YDSdElad6PiRoX4t8VGDjCtt05zn4nU810UIx/uNEV7/lZJ6KwFThKZOzOxzXy48da+No7HZaMw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.7.1.tgz", + "integrity": "sha512-rZAP3aVgB9ds9KOeUSL+zZ21hPmo8dh6fnIFwRQj5EAZl9gzR7wxYbYXYysAM8CTqGmUGyp2S4kUdV17MnGuWQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^1.2.1", + "levn": "^0.4.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@humanfs/core": { + "version": "0.19.1", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", + "integrity": "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.7", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.7.tgz", + "integrity": "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.1", + "@humanwhocodes/retry": "^0.4.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@isaacs/cliui": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", + "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^5.1.2", + "string-width-cjs": "npm:string-width@^4.2.0", + "strip-ansi": "^7.0.1", + "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", + "wrap-ansi": "^8.1.0", + "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@isaacs/cliui/node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/@isaacs/cliui/node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/@isaacs/cliui/node_modules/emoji-regex": { + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", + "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@isaacs/cliui/node_modules/string-width": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", + "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "eastasianwidth": "^0.2.0", + "emoji-regex": "^9.2.2", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@isaacs/cliui/node_modules/strip-ansi": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz", + "integrity": "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/@isaacs/cliui/node_modules/wrap-ansi": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", + "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.1.0", + "string-width": "^5.0.1", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@malept/cross-spawn-promise": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@malept/cross-spawn-promise/-/cross-spawn-promise-1.1.1.tgz", + "integrity": "sha512-RTBGWL5FWQcg9orDOCcp4LvItNzUPcyEU9bwaeJX0rJ1IQxzucC48Y0/sQLp/g6t99IQgAlGIaesJS+gTn7tVQ==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/malept" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/subscription/pkg/npm-.malept-cross-spawn-promise?utm_medium=referral&utm_source=npm_fund" + } + ], + "license": "Apache-2.0", + "dependencies": { + "cross-spawn": "^7.0.1" + }, + "engines": { + "node": ">= 10" + } + }, + "node_modules/@malept/flatpak-bundler": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@malept/flatpak-bundler/-/flatpak-bundler-0.4.0.tgz", + "integrity": "sha512-9QOtNffcOF/c1seMCDnjckb3R9WHcG34tky+FHpNKKCW0wc/scYLwMtO+ptyGUfMW0/b/n4qRiALlaFHc9Oj7Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.1.1", + "fs-extra": "^9.0.0", + "lodash": "^4.17.15", + "tmp-promise": "^3.0.2" + }, + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/@malept/flatpak-bundler/node_modules/fs-extra": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-9.1.0.tgz", + "integrity": "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "at-least-node": "^1.0.0", + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@malept/flatpak-bundler/node_modules/jsonfile": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.0.tgz", + "integrity": "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/@malept/flatpak-bundler/node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.143.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.143.0.tgz", + "integrity": "sha512-u6JZdLBTLotrNC9Vd6vPssINdzcCzleKAH6EJKImQb7GtYvX5keN2dxkoK44stCc4tffE6QQRtZTXVSzsLUlWA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, + "node_modules/@pkgjs/parseargs": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", + "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=14" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.3.tgz", + "integrity": "sha512-zrJtHDcaZJ1Fp7xf4hNl+7seH9Cn/N5TwLYkhgXREtBwAd/jaqW3uqeHxpDugJLVICWg4eW44kOQEGJ1r6jCGw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.3.tgz", + "integrity": "sha512-ieIiibVCp0tX7TLu2cafoNPv8wJyYi01ekXpbf8q2j7F4rGAhhXb/eQh7ge9DRBY78GwmRQtvjZDux7EDbA8kA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.3.tgz", + "integrity": "sha512-Zh9tCon19eDXJoihx0rqKhMUlMYqzwj3aPsSuHmI4RWZh62dWUL+DJN4C5YQya5TcQBJU/Fe8+rY0jhXTQITqA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.3.tgz", + "integrity": "sha512-nGbJWewA1wrXXZiQhjAT5rhibGfns5ZNkDVqxsO6zJ3f3YvpoDNNmGMSbbhLuXKjNScaBJVOAboztAWVespQMg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.3.tgz", + "integrity": "sha512-QNniJr5Kml0kDEB98jiDOJjXNroxIIi0IXIbdYzY26Xt1pVbeP62+KnoIZLwirOymX/0jDk/2gI/bNUv7A7OIw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.3.tgz", + "integrity": "sha512-TkqEAcmmvH3I/q4114NB4RVt6241Dao48pF45uLcFGrwAaIn0iITgTAKP/dLjbN0R4buJjGb91+UHSoFmpgIWw==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.3.tgz", + "integrity": "sha512-NHqjnxpsndf4MPymxteFAWHHfkTL8HjWh1KB7z23ofZ6QO2euONuxDXjat69dKZRALnGypg8k8SsK8vZJoXv1Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.3.tgz", + "integrity": "sha512-6tbrbwfz5GB9DQ4Jwo6hy9v+vR31xZlvzZ6n5Xut6Hhx5PvrA9q/HsK8KMaYQp063iqZGXwNvZtYNLD7EM/x0w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.3.tgz", + "integrity": "sha512-oyuXxXmoZHjXC917IAPFAAv4wWAa0cM9afk8nx1+9/jNNOX1uPf8yDA6p7G0RypOfw/X0PQt5IfoquY1um+zSg==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.3.tgz", + "integrity": "sha512-TytMwF2KVGqP2tgd0I1OY0PAv78dZRAYcF5ssDzjM34SUXCED3uXvSd5+lHoC0bTD6eEdFz7LdQNCO1y0oVk9w==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.3.tgz", + "integrity": "sha512-/E9m3qstrJFVPoULV25mVQblSNExY2+kBsYe4sy0Tn0yOOgJ8wZbZt3KnRbF/XeU2Gl1STKUQnDNTqhIE5MD4A==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.3.tgz", + "integrity": "sha512-Kr0OcsoQI816i6HOl3vFHpd1K0eZyh76zgfj4c1nTyaTsd5r2Mj1lwM4R90y/qaCfmTn9eHy0SKwi98eitRxug==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.3.tgz", + "integrity": "sha512-hOtMwTqnME+/gJcH/PCZ0wn0zPUjiWOgkHpxbSJpfGKMezHltx1S7/k1SitzVa7Ww2cqrDDaFbZEhcJZO8o+Jw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.3.tgz", + "integrity": "sha512-ekcqMMkI2PlhYnfzQnB/cEdYUVVJViWvoUyLrbzgDoi3Snfc1mVBwdnc306ufA5ejy8JSPjT2RlW1nQSjW7efg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@sindresorhus/is": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-4.6.0.tgz", + "integrity": "sha512-t09vSN3MdfsyCHoFcTRCH/iUtG7OJ0CsjzB8cjAmKc/va/kIgeDI/TxsigdncE/4be734m0cvIYwNaV4i2XqAw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "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/@szmarczak/http-timer": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/@szmarczak/http-timer/-/http-timer-4.0.6.tgz", + "integrity": "sha512-4BAffykYOgO+5nzBWYwE3W90sBgLJoUPRWWcL8wlyiM8IB8ipJz3UMJ9KXQd1RKQXpKp8Tutn80HZtWsu2u76w==", + "dev": true, + "license": "MIT", + "dependencies": { + "defer-to-connect": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@tootallnate/once": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-2.0.1.tgz", + "integrity": "sha512-HqmEUIGRJ5fSXchkVgR5F7qn48bDBzv0kWj/Kfu5e6uci4UlEeng4331LnBkWffb++Ei3FOVLxo8JJWMFBDMeQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10" + } + }, + "node_modules/@types/better-sqlite3": { + "version": "7.6.13", + "resolved": "https://registry.npmjs.org/@types/better-sqlite3/-/better-sqlite3-7.6.13.tgz", + "integrity": "sha512-NMv9ASNARoKksWtsq/SHakpYAYnhBrQgGD8zkLYk/jaK8jUGn08CfEdTRgYhMypUQAfzSP8W6gNLe0q19/t4VA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/cacheable-request": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/@types/cacheable-request/-/cacheable-request-6.0.3.tgz", + "integrity": "sha512-IQ3EbTzGxIigb1I3qPZc1rWJnH0BmSKv5QYTalEwweFvyBDLSAe24zP0le/hyi7ecGfZVlIVAg4BZqb8WBwKqw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/http-cache-semantics": "*", + "@types/keyv": "^3.1.4", + "@types/node": "*", + "@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.12", + "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.12.tgz", + "integrity": "sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@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/esrecurse": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz", + "integrity": "sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/fs-extra": { + "version": "9.0.13", + "resolved": "https://registry.npmjs.org/@types/fs-extra/-/fs-extra-9.0.13.tgz", + "integrity": "sha512-nEnwB++1u5lVDM2UI4c1+5R+FYaKfaAzS4OococimjVm3nQw3TuzH5UNsocrcTBbhnerblyHj4A49qXbIiZdpA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/http-cache-semantics": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@types/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz", + "integrity": "sha512-L3LgimLHXtGkWikKnsPg0/VFx9OGZaC+eN1u4r+OB1XRqH3meBIAVC2zr1WdMH+RHmnRkqliQAOHNJ/E0j/e0Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/keyv": { + "version": "3.1.4", + "resolved": "https://registry.npmjs.org/@types/keyv/-/keyv-3.1.4.tgz", + "integrity": "sha512-BQ5aZNSCpj7D6K2ksrRCTmKRLEpnPvWDiLPfoGyhZ++8YtiK9d/3DBKPJgry359X/P1PfruyYwvnvwFjuEiEIg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/ms": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", + "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "20.19.31", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.31.tgz", + "integrity": "sha512-5jsi0wpncvTD33Sh1UCgacK37FFwDn+EG7wCmEvs62fCvBL+n8/76cAYDok21NF6+jaVWIqKwCZyX7Vbu8eB3A==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@types/plist": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@types/plist/-/plist-3.0.5.tgz", + "integrity": "sha512-E6OCaRmAe4WDmWNsL/9RMqdkkzDCY1etutkflWk4c+AcjDU07Pcz1fQwTX0TQz+Pxqn9i4L1TU3UFpjnrcDgxA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@types/node": "*", + "xmlbuilder": ">=11.0.1" + } + }, + "node_modules/@types/responselike": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@types/responselike/-/responselike-1.0.3.tgz", + "integrity": "sha512-H/+L+UkTV33uf49PH5pCAUBVPNj2nDBXTN+qS1dOwyyg24l3CcicicCA7ca+HMvJBZcFgl5r8e+RR6elsb4Lyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/verror": { + "version": "1.10.11", + "resolved": "https://registry.npmjs.org/@types/verror/-/verror-1.10.11.tgz", + "integrity": "sha512-RlDm9K7+o5stv0Co8i8ZRGxDbrTxhJtgjqjFyVh/tXQyl/rYtTKlnTvZ88oSTeYREWurwx20Js4kTuKCsFkUtg==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/@types/yauzl": { + "version": "2.10.3", + "resolved": "https://registry.npmjs.org/@types/yauzl/-/yauzl-2.10.3.tgz", + "integrity": "sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.59.4", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.59.4.tgz", + "integrity": "sha512-PegsU+XfyJJNjd4+u/k6f9yTyp0lEXXiPopUNobZcIAUJFGICFLN+sP0Rb3JehVmiij1Ph0dFGYqODoRo/2+6A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.12.2", + "@typescript-eslint/scope-manager": "8.59.4", + "@typescript-eslint/type-utils": "8.59.4", + "@typescript-eslint/utils": "8.59.4", + "@typescript-eslint/visitor-keys": "8.59.4", + "ignore": "^7.0.5", + "natural-compare": "^1.4.0", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^8.59.4", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", + "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "8.59.4", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.59.4.tgz", + "integrity": "sha512-zORHqO/tuhxY1zWuTvMUqddRxpiFJ72xVfcNoWpqdLjs6lfPbuQBJuW4pk+49/uBMy7Ssr4bzgjiKmmDB1UbZQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/scope-manager": "8.59.4", + "@typescript-eslint/types": "8.59.4", + "@typescript-eslint/typescript-estree": "8.59.4", + "@typescript-eslint/visitor-keys": "8.59.4", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/project-service": { + "version": "8.59.4", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.59.4.tgz", + "integrity": "sha512-Ly00Vu4oAacfDeHp2Zg85ioNG6l8HG+tN1D7J+xTHSxu9y0awYKJ2zH1rFBn8ZSfuGK+7FxK3Cgl3uAz0aZZLg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.59.4", + "@typescript-eslint/types": "^8.59.4", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "8.59.4", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.59.4.tgz", + "integrity": "sha512-mUeR/3H1WrTAddJrwut8OoPjfauaztMQmRwV5fQTUyNVJCLiUXXe4lGEyYIL2oFDpP7UtgbGJXCt72wT0z2S3Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.59.4", + "@typescript-eslint/visitor-keys": "8.59.4" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.59.4", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.59.4.tgz", + "integrity": "sha512-DLCpnKgD4alVxTBSKulK+gU1KCqOgUXfDRDXh2mZgzokQKa/70ax93I2uVO3m/LLvIAtWZIFoiifudmIqAxpMA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "8.59.4", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.59.4.tgz", + "integrity": "sha512-uonTuPAAKr9XaBGqJ3LjYTh72zy5DyGesljO9gtmk/eFW0W1fRHjnwVYKB35Lm8d5Q5CluEW3gPHjTvZTmgrfA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.59.4", + "@typescript-eslint/typescript-estree": "8.59.4", + "@typescript-eslint/utils": "8.59.4", + "debug": "^4.4.3", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/types": { + "version": "8.59.4", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.59.4.tgz", + "integrity": "sha512-F1o7WJcCq+bc8dwcO/YsSEOudAH8RDtaOhM6wcAQhcUsFhnWQl81JKy48q1hoxAU0qrzM89+31GYh1515Zde3Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "8.59.4", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.59.4.tgz", + "integrity": "sha512-F+RuOmcDXo4+TPdfd/TCLS3m2nw8gE9XXyZLrA3JBfaA5tz9TtdkyD3YJFmPxulyc2cKbEok/CvFE3MgSLWnag==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/project-service": "8.59.4", + "@typescript-eslint/tsconfig-utils": "8.59.4", + "@typescript-eslint/types": "8.59.4", + "@typescript-eslint/visitor-keys": "8.59.4", + "debug": "^4.4.3", + "minimatch": "^10.2.2", + "semver": "^7.7.3", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { + "version": "5.0.9", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", + "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.5" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/semver": { + "version": "7.8.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.0.tgz", + "integrity": "sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@typescript-eslint/utils": { + "version": "8.59.4", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.59.4.tgz", + "integrity": "sha512-cYXeNAUsG4lJo5dbc1FcKm+JwIWrj1/UpTORsC6tGMjEZ81DYcvIr9/ueikhMa/Y/gDQYGp+YX9/xQrXje5BJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.9.1", + "@typescript-eslint/scope-manager": "8.59.4", + "@typescript-eslint/types": "8.59.4", + "@typescript-eslint/typescript-estree": "8.59.4" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "8.59.4", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.59.4.tgz", + "integrity": "sha512-U3gxVaDVnuZKhSspW/MzMxE1kq7zOdc072FcSNoqA1I9p8HyKbBFfEHoWckBAMgNMph4MamwS5iTVzFmrnt8TQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.59.4", + "eslint-visitor-keys": "^5.0.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@vitest/expect": { + "version": "4.1.6", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.6.tgz", + "integrity": "sha512-7EHDquPthALSV0jhhjgEW8FXaviMx7rSqu8W6oqCoAuOhKov814P99QDV1pxMA3QPv21YudvJngIhjrNI4opLg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.6", + "@vitest/utils": "4.1.6", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "4.1.6", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.6.tgz", + "integrity": "sha512-MCFc63czMjEInOlcY2cpQCvCN+KgbAn+60xu9cMgP4sKaLC5JNAKw7JH8QdAnoAC88hW1IiSNZ+GgVXlN1UcMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.1.6", + "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.6", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.6.tgz", + "integrity": "sha512-h5SxD/IzNhZYnrSZRsUZQIC+vD0GY8cUvq0iwsmkFKixRCKLLWqCXa/FIQ4S1R+sI+PGoojkHsdNrbZiM9Qpgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.1.6", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.6.tgz", + "integrity": "sha512-nOPCmn2+yD0ZNmKdsXGv/UxMMWbMuKeD6GyYncNwdkYDxpQvrPSKYj2rWuDjC2Y4b6w6hjip5dBKFzEUuZe3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.1.6", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.1.6", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.6.tgz", + "integrity": "sha512-YhsdE6xAVfTDmzjxL2ZDUvjj+ZsgyOKe+TdQzqkD72wIOmHka8NuGQ6NpTNZv9D2Z63fbwWKJPeVpEw4EQgYxw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.6", + "@vitest/utils": "4.1.6", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.1.6", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.6.tgz", + "integrity": "sha512-JFKxMx6udhwKh/Ldo270e17QX710vgunMkuPAvXjHSvC6oqLWAHhVhjg/I71q0u0CBSErIODV1Kjv0FQNSWjdg==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.1.6", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.6.tgz", + "integrity": "sha512-FxIY+U81R3LGKCxaHHFRQ5+g6/iRgGLmeHWdp2Amj4ljQRrEIWHmZyDfDYBRZlpyqA7qKxtS9DD1dhk8RnRIVQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.6", + "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", + "integrity": "sha512-KRYzxepc14G/CEpEGc3Yn+JKaAeT63smlDr+vjB8jRfgTBBI9wRj/nkQEO+ucV8p8I9bfKLWp37uHgFrbntPvw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/7zip-bin": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/7zip-bin/-/7zip-bin-5.2.0.tgz", + "integrity": "sha512-ukTPVhqG4jNzMro2qA9HSCSSVJN3aN7tlb+hfqYCt3ER0yWroeA2VR38MNrOHLQ/cVj+DaIMad0kFCtWWowh/A==", + "dev": true, + "license": "MIT" + }, + "node_modules/acorn": { + "version": "8.16.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", + "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/agent-base": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "license": "MIT", + "dependencies": { + "debug": "4" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/ajv": { + "version": "6.14.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz", + "integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-keywords": { + "version": "3.5.2", + "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-3.5.2.tgz", + "integrity": "sha512-5p6WTN0DdTGVQk6VjcEju19IgaHudalcfabD7yhDGeA6bcQnmL+CpveLJq/3hvfwd1aof6L386Ougkx6RfyMIQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "ajv": "^6.9.1" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/app-builder-bin": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/app-builder-bin/-/app-builder-bin-4.0.0.tgz", + "integrity": "sha512-xwdG0FJPQMe0M0UA4Tz0zEB8rBJTRA5a476ZawAqiBkMv16GRK5xpXThOjMaEOFnZ6zabejjG4J3da0SXG63KA==", + "dev": true, + "license": "MIT" + }, + "node_modules/app-builder-lib": { + "version": "24.13.3", + "resolved": "https://registry.npmjs.org/app-builder-lib/-/app-builder-lib-24.13.3.tgz", + "integrity": "sha512-FAzX6IBit2POXYGnTCT8YHFO/lr5AapAII6zzhQO3Rw4cEDOgK+t1xhLc5tNcKlicTHlo9zxIwnYCX9X2DLkig==", + "dev": true, + "license": "MIT", + "dependencies": { + "@develar/schema-utils": "~2.6.5", + "@electron/notarize": "2.2.1", + "@electron/osx-sign": "1.0.5", + "@electron/universal": "1.5.1", + "@malept/flatpak-bundler": "^0.4.0", + "@types/fs-extra": "9.0.13", + "async-exit-hook": "^2.0.1", + "bluebird-lst": "^1.0.9", + "builder-util": "24.13.1", + "builder-util-runtime": "9.2.4", + "chromium-pickle-js": "^0.2.0", + "debug": "^4.3.4", + "ejs": "^3.1.8", + "electron-publish": "24.13.1", + "form-data": "^4.0.0", + "fs-extra": "^10.1.0", + "hosted-git-info": "^4.1.0", + "is-ci": "^3.0.0", + "isbinaryfile": "^5.0.0", + "js-yaml": "^4.1.0", + "lazy-val": "^1.0.5", + "minimatch": "^5.1.1", + "read-config-file": "6.3.2", + "sanitize-filename": "^1.6.3", + "semver": "^7.3.8", + "tar": "^6.1.12", + "temp-file": "^3.4.0" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "dmg-builder": "24.13.3", + "electron-builder-squirrel-windows": "24.13.3" + } + }, + "node_modules/app-builder-lib/node_modules/fs-extra": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", + "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/app-builder-lib/node_modules/jsonfile": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.0.tgz", + "integrity": "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/app-builder-lib/node_modules/semver": { + "version": "7.7.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", + "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/app-builder-lib/node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/archiver": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/archiver/-/archiver-5.3.2.tgz", + "integrity": "sha512-+25nxyyznAXF7Nef3y0EbBeqmGZgeN/BxHX29Rs39djAfaFalmQ89SE6CWyDCHzGL0yt/ycBtNOmGTW0FyGWNw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "archiver-utils": "^2.1.0", + "async": "^3.2.4", + "buffer-crc32": "^0.2.1", + "readable-stream": "^3.6.0", + "readdir-glob": "^1.1.2", + "tar-stream": "^2.2.0", + "zip-stream": "^4.1.0" + }, + "engines": { + "node": ">= 10" + } + }, + "node_modules/archiver-utils": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/archiver-utils/-/archiver-utils-2.1.0.tgz", + "integrity": "sha512-bEL/yUb/fNNiNTuUz979Z0Yg5L+LzLxGJz8x79lYmR54fmTIb6ob/hNQgkQnIUDWIFjZVQwl9Xs356I6BAMHfw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "glob": "^7.1.4", + "graceful-fs": "^4.2.0", + "lazystream": "^1.0.0", + "lodash.defaults": "^4.2.0", + "lodash.difference": "^4.5.0", + "lodash.flatten": "^4.4.0", + "lodash.isplainobject": "^4.0.6", + "lodash.union": "^4.6.0", + "normalize-path": "^3.0.0", + "readable-stream": "^2.0.0" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/archiver-utils/node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/archiver-utils/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/archiver-utils/node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "license": "Python-2.0" + }, + "node_modules/assert-plus": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/assert-plus/-/assert-plus-1.0.0.tgz", + "integrity": "sha512-NfJ4UzBCcQGLDlQq7nHxH+tv3kyZ0hHQqF5BO6J7tNJeP5do1llPr8dZ8zHonfhAu0PHAdMkSo+8o0wxg9lZWw==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=0.8" + } + }, + "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/astral-regex": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/astral-regex/-/astral-regex-2.0.0.tgz", + "integrity": "sha512-Z7tMw1ytTXt5jqMcOP+OQteU1VuNK9Y02uuJtKQ1Sv69jXQKKg5cibLwGJow8yzZP+eAc18EmLGPal0bp36rvQ==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/async": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz", + "integrity": "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==", + "dev": true, + "license": "MIT" + }, + "node_modules/async-exit-hook": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/async-exit-hook/-/async-exit-hook-2.0.1.tgz", + "integrity": "sha512-NW2cX8m1Q7KPA7a5M2ULQeZ2wR5qI5PAbw5L0UOMxdioVk9PMZ0h1TmyZEkPYrCvYjDlFICusOu1dlEKAAeXBw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" + }, + "node_modules/at-least-node": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/at-least-node/-/at-least-node-1.0.0.tgz", + "integrity": "sha512-+q/t7Ekv1EDY2l6Gda6LLiX14rU9TV20Wa3ofeQmwPFZbOMo9DXrLbOjFaaclkXKWidIaopwAObQDqwWtGUjqg==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">= 4.0.0" + } + }, + "node_modules/axios": { + "version": "1.19.0", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.19.0.tgz", + "integrity": "sha512-ht/iuYZXEjFxLH/Hkezgd7m6JKlHHXEUSneaDz8uZe1Gj5QZtCnpyDsckvAiEnT89OEbCLmnte4R4sn7P0EKFw==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.16.0", + "form-data": "^4.0.6", + "https-proxy-agent": "^5.0.1", + "proxy-from-env": "^2.1.0" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/better-sqlite3": { + "version": "12.10.0", + "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-12.10.0.tgz", + "integrity": "sha512-CyzaZRQKyHkB2ZInfTTl2nvT33EbDpjkLEbE8/Zck3Ll6O0qqvuGdrJ45HgtH+HykRg88ITY3AdreBGN70aBSQ==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "bindings": "^1.5.0", + "prebuild-install": "^7.1.1" + }, + "engines": { + "node": "20.x || 22.x || 23.x || 24.x || 25.x || 26.x" + } + }, + "node_modules/bindings": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz", + "integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==", + "license": "MIT", + "dependencies": { + "file-uri-to-path": "1.0.0" + } + }, + "node_modules/bl": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", + "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", + "license": "MIT", + "dependencies": { + "buffer": "^5.5.0", + "inherits": "^2.0.4", + "readable-stream": "^3.4.0" + } + }, + "node_modules/bluebird": { + "version": "3.7.2", + "resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.7.2.tgz", + "integrity": "sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg==", + "dev": true, + "license": "MIT" + }, + "node_modules/bluebird-lst": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/bluebird-lst/-/bluebird-lst-1.0.9.tgz", + "integrity": "sha512-7B1Rtx82hjnSD4PGLAjVWeYH3tHAcVUmChh85a3lltKQm6FresXh9ErQo6oAv6CqxttczC3/kEg8SY5NluPuUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "bluebird": "^3.5.5" + } + }, + "node_modules/boolean": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/boolean/-/boolean-3.2.0.tgz", + "integrity": "sha512-d0II/GO9uf9lfUHH2BQsjxzRJZBdsjgsBiW4BvhWk/3qoKwQFjIDVN19PfX8F2D/r9PCMTtLWjYVCFrpeYUzsw==", + "deprecated": "Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/brace-expansion": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.4.tgz", + "integrity": "sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/buffer": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", + "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.1.13" + } + }, + "node_modules/buffer-crc32": { + "version": "0.2.13", + "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz", + "integrity": "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/buffer-equal": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal/-/buffer-equal-1.0.1.tgz", + "integrity": "sha512-QoV3ptgEaQpvVwbXdSO39iqPQTCxSF7A5U99AxbHYqUdCizL/lH2Z0A2y6nbZucxMEOtNyZfG2s6gsVugGpKkg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/buffer-from": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/builder-util": { + "version": "24.13.1", + "resolved": "https://registry.npmjs.org/builder-util/-/builder-util-24.13.1.tgz", + "integrity": "sha512-NhbCSIntruNDTOVI9fdXz0dihaqX2YuE1D6zZMrwiErzH4ELZHE6mdiB40wEgZNprDia+FghRFgKoAqMZRRjSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/debug": "^4.1.6", + "7zip-bin": "~5.2.0", + "app-builder-bin": "4.0.0", + "bluebird-lst": "^1.0.9", + "builder-util-runtime": "9.2.4", + "chalk": "^4.1.2", + "cross-spawn": "^7.0.3", + "debug": "^4.3.4", + "fs-extra": "^10.1.0", + "http-proxy-agent": "^5.0.0", + "https-proxy-agent": "^5.0.1", + "is-ci": "^3.0.0", + "js-yaml": "^4.1.0", + "source-map-support": "^0.5.19", + "stat-mode": "^1.0.0", + "temp-file": "^3.4.0" + } + }, + "node_modules/builder-util-runtime": { + "version": "9.2.4", + "resolved": "https://registry.npmjs.org/builder-util-runtime/-/builder-util-runtime-9.2.4.tgz", + "integrity": "sha512-upp+biKpN/XZMLim7aguUyW8s0FUpDvOtK6sbanMFDAMBzpHDqdhgVYm6zc9HJ6nWo7u2Lxk60i2M6Jd3aiNrA==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.3.4", + "sax": "^1.2.4" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/builder-util/node_modules/fs-extra": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", + "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/builder-util/node_modules/jsonfile": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.0.tgz", + "integrity": "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/builder-util/node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/cacheable-lookup": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/cacheable-lookup/-/cacheable-lookup-5.0.4.tgz", + "integrity": "sha512-2/kNscPhpcxrOigMZzbiWF7dz8ilhb/nIHU3EyZiXWXpeq/au8qJ8VhdftMkty3n7Gj6HIGalQG8oiBNB3AJgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.6.0" + } + }, + "node_modules/cacheable-request": { + "version": "7.0.4", + "resolved": "https://registry.npmjs.org/cacheable-request/-/cacheable-request-7.0.4.tgz", + "integrity": "sha512-v+p6ongsrp0yTGbJXjgxPow2+DL93DASP4kXCDKb8/bwRtt9OEF3whggkkDkGNzgcWy2XaF4a8nZglC7uElscg==", + "dev": true, + "license": "MIT", + "dependencies": { + "clone-response": "^1.0.2", + "get-stream": "^5.1.0", + "http-cache-semantics": "^4.0.0", + "keyv": "^4.0.0", + "lowercase-keys": "^2.0.0", + "normalize-url": "^6.0.1", + "responselike": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "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", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/chownr": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-2.0.0.tgz", + "integrity": "sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/chromium-pickle-js": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/chromium-pickle-js/-/chromium-pickle-js-0.2.0.tgz", + "integrity": "sha512-1R5Fho+jBq0DDydt+/vHWj5KJNJCKdARKOCwZUen84I5BreWoLqRLANH1U87eJy1tiASPtMnGqJJq0ZsLoRPOw==", + "dev": true, + "license": "MIT" + }, + "node_modules/ci-info": { + "version": "3.9.0", + "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-3.9.0.tgz", + "integrity": "sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/sibiraj-s" + } + ], + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/cli-truncate": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/cli-truncate/-/cli-truncate-2.1.0.tgz", + "integrity": "sha512-n8fOixwDD6b/ObinzTrp1ZKFzbgvKZvuz/TvejnLn1aQfC6r52XEx85FmuC+3HI+JM7coBRXUvNqEU2PHVrHpg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "slice-ansi": "^3.0.0", + "string-width": "^4.2.0" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cliui": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/clone-response": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/clone-response/-/clone-response-1.0.3.tgz", + "integrity": "sha512-ROoL94jJH2dUVML2Y/5PEDNaSHgeOdSDicUyS7izcF63G6sTc/FTjLub4b8Il9S8S0beOfYt0TaA5qvFK+w0wA==", + "dev": true, + "license": "MIT", + "dependencies": { + "mimic-response": "^1.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/commander": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-5.1.0.tgz", + "integrity": "sha512-P0CysNDQ7rtVw4QIQtm+MRxV66vKFSvlsQvGYXZWR3qFU0jlMKHZZZgw8e+8DSah4UDKMqnknRDQz+xuQXQ/Zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/compare-version": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/compare-version/-/compare-version-0.1.2.tgz", + "integrity": "sha512-pJDh5/4wrEnXX/VWRZvruAGHkzKdr46z11OlTPN+VrATlWWhSKewNCJ1futCO5C7eJB3nPMFZA1LeYtcFboZ2A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/compress-commons": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/compress-commons/-/compress-commons-4.1.2.tgz", + "integrity": "sha512-D3uMHtGc/fcO1Gt1/L7i1e33VOvD4A9hfQLP+6ewd+BvG/gQ84Yh4oftEhAdjSMgBgwGL+jsppT7JYNpo6MHHg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "buffer-crc32": "^0.2.13", + "crc32-stream": "^4.0.2", + "normalize-path": "^3.0.0", + "readable-stream": "^3.6.0" + }, + "engines": { + "node": ">= 10" + } + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true, + "license": "MIT" + }, + "node_modules/config-file-ts": { + "version": "0.2.6", + "resolved": "https://registry.npmjs.org/config-file-ts/-/config-file-ts-0.2.6.tgz", + "integrity": "sha512-6boGVaglwblBgJqGyxm4+xCmEGcWgnWHSWHY5jad58awQhB6gftq0G8HbzU39YqCIYHMLAiL1yjwiZ36m/CL8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "glob": "^10.3.10", + "typescript": "^5.3.3" + } + }, + "node_modules/config-file-ts/node_modules/glob": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", + "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "dev": true, + "license": "ISC", + "dependencies": { + "foreground-child": "^3.1.0", + "jackspeak": "^3.1.2", + "minimatch": "^9.0.4", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^1.11.1" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/config-file-ts/node_modules/minimatch": { + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.2" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/config-file-ts/node_modules/minipass": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz", + "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/core-util-is": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.2.tgz", + "integrity": "sha512-3lqz5YjWTYnW6dlDa5TLaTCcShfar1e40rmcJVwCBJC6mWlFuj0eCHIElmG1g5kyuJ/GD+8Wn4FFCcz4gJPfaQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/crc": { + "version": "3.8.0", + "resolved": "https://registry.npmjs.org/crc/-/crc-3.8.0.tgz", + "integrity": "sha512-iX3mfgcTMIq3ZKLIsVFAbv7+Mc10kxabAGQb8HvjA1o3T1PIYprbakQ65d3I+2HGHt6nSKkM9PYjgoJO2KcFBQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "buffer": "^5.1.0" + } + }, + "node_modules/crc-32": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/crc-32/-/crc-32-1.2.2.tgz", + "integrity": "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==", + "dev": true, + "license": "Apache-2.0", + "peer": true, + "bin": { + "crc32": "bin/crc32.njs" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/crc32-stream": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/crc32-stream/-/crc32-stream-4.0.3.tgz", + "integrity": "sha512-NT7w2JVU7DFroFdYkeq8cywxrgjPHWkdX1wjpRQXPX5Asews3tA+Ght6lddQO5Mkumffp3X7GEqku3epj2toIw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "crc-32": "^1.2.0", + "readable-stream": "^3.4.0" + }, + "engines": { + "node": ">= 10" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decompress-response": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", + "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==", + "license": "MIT", + "dependencies": { + "mimic-response": "^3.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/decompress-response/node_modules/mimic-response": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", + "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/deep-extend": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz", + "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==", + "license": "MIT", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/defer-to-connect": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/defer-to-connect/-/defer-to-connect-2.0.1.tgz", + "integrity": "sha512-4tvttepXG1VaYGrRibk5EwJd1t4udunSOVMdLSAL6mId1ix438oPwPZMALY41FCijukO1L0twNcGsdzS7dHgDg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/define-data-property": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", + "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "es-define-property": "^1.0.0", + "es-errors": "^1.3.0", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/define-properties": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz", + "integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "define-data-property": "^1.0.1", + "has-property-descriptors": "^1.0.0", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/detect-node": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/detect-node/-/detect-node-2.1.0.tgz", + "integrity": "sha512-T0NIuQpnTvFDATNuHN5roPwSBG83rFsuO+MXXH9/3N1eFbn4wcPjttvjMLEPWJ0RGUYgQE7cGgS3tNxbqCGM7g==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/dir-compare": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/dir-compare/-/dir-compare-3.3.0.tgz", + "integrity": "sha512-J7/et3WlGUCxjdnD3HAAzQ6nsnc0WL6DD7WcwJb7c39iH1+AWfg+9OqzJNaI6PkBwBvm1mhZNL9iY/nRiZXlPg==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer-equal": "^1.0.0", + "minimatch": "^3.0.4" + } + }, + "node_modules/dir-compare/node_modules/brace-expansion": { + "version": "1.1.18", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz", + "integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/dir-compare/node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/dmg-builder": { + "version": "24.13.3", + "resolved": "https://registry.npmjs.org/dmg-builder/-/dmg-builder-24.13.3.tgz", + "integrity": "sha512-rcJUkMfnJpfCboZoOOPf4L29TRtEieHNOeAbYPWPxlaBw/Z1RKrRA86dOI9rwaI4tQSc/RD82zTNHprfUHXsoQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "app-builder-lib": "24.13.3", + "builder-util": "24.13.1", + "builder-util-runtime": "9.2.4", + "fs-extra": "^10.1.0", + "iconv-lite": "^0.6.2", + "js-yaml": "^4.1.0" + }, + "optionalDependencies": { + "dmg-license": "^1.0.11" + } + }, + "node_modules/dmg-builder/node_modules/fs-extra": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", + "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/dmg-builder/node_modules/jsonfile": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.0.tgz", + "integrity": "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/dmg-builder/node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/dmg-license": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/dmg-license/-/dmg-license-1.0.11.tgz", + "integrity": "sha512-ZdzmqwKmECOWJpqefloC5OJy1+WZBBse5+MR88z9g9Zn4VY+WYUkAyojmhzJckH5YbbZGcYIuGAkY5/Ys5OM2Q==", + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "dependencies": { + "@types/plist": "^3.0.1", + "@types/verror": "^1.10.3", + "ajv": "^6.10.0", + "crc": "^3.8.0", + "iconv-corefoundation": "^1.1.7", + "plist": "^3.0.4", + "smart-buffer": "^4.0.2", + "verror": "^1.10.0" + }, + "bin": { + "dmg-license": "bin/dmg-license.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/dotenv": { + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-9.0.2.tgz", + "integrity": "sha512-I9OvvrHp4pIARv4+x9iuewrWycX6CcZtoAu1XrzPxc5UygMJXJZYmBsynku8IkrJwgypE5DGNjDPmPRhDCptUg==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=10" + } + }, + "node_modules/dotenv-expand": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/dotenv-expand/-/dotenv-expand-5.1.0.tgz", + "integrity": "sha512-YXQl1DSa4/PQyRfgrv6aoNjhasp/p4qs9FjJ4q4cQk+8m4r6k4ZSiEyytKG8f8W9gi8WsQtIObNmKd+tMzNTmA==", + "dev": true, + "license": "BSD-2-Clause" + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/eastasianwidth": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", + "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", + "dev": true, + "license": "MIT" + }, + "node_modules/ejs": { + "version": "3.1.10", + "resolved": "https://registry.npmjs.org/ejs/-/ejs-3.1.10.tgz", + "integrity": "sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "jake": "^10.8.5" + }, + "bin": { + "ejs": "bin/cli.js" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/electron": { + "version": "28.3.3", + "resolved": "https://registry.npmjs.org/electron/-/electron-28.3.3.tgz", + "integrity": "sha512-ObKMLSPNhomtCOBAxFS8P2DW/4umkh72ouZUlUKzXGtYuPzgr1SYhskhFWgzAsPtUzhL2CzyV2sfbHcEW4CXqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "@electron/get": "^2.0.0", + "@types/node": "^18.11.18", + "extract-zip": "^2.0.1" + }, + "bin": { + "electron": "cli.js" + }, + "engines": { + "node": ">= 12.20.55" + } + }, + "node_modules/electron-builder": { + "version": "24.13.3", + "resolved": "https://registry.npmjs.org/electron-builder/-/electron-builder-24.13.3.tgz", + "integrity": "sha512-yZSgVHft5dNVlo31qmJAe4BVKQfFdwpRw7sFp1iQglDRCDD6r22zfRJuZlhtB5gp9FHUxCMEoWGq10SkCnMAIg==", + "dev": true, + "license": "MIT", + "dependencies": { + "app-builder-lib": "24.13.3", + "builder-util": "24.13.1", + "builder-util-runtime": "9.2.4", + "chalk": "^4.1.2", + "dmg-builder": "24.13.3", + "fs-extra": "^10.1.0", + "is-ci": "^3.0.0", + "lazy-val": "^1.0.5", + "read-config-file": "6.3.2", + "simple-update-notifier": "2.0.0", + "yargs": "^17.6.2" + }, + "bin": { + "electron-builder": "cli.js", + "install-app-deps": "install-app-deps.js" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/electron-builder-squirrel-windows": { + "version": "24.13.3", + "resolved": "https://registry.npmjs.org/electron-builder-squirrel-windows/-/electron-builder-squirrel-windows-24.13.3.tgz", + "integrity": "sha512-oHkV0iogWfyK+ah9ZIvMDpei1m9ZRpdXcvde1wTpra2U8AFDNNpqJdnin5z+PM1GbQ5BoaKCWas2HSjtR0HwMg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "app-builder-lib": "24.13.3", + "archiver": "^5.3.1", + "builder-util": "24.13.1", + "fs-extra": "^10.1.0" + } + }, + "node_modules/electron-builder-squirrel-windows/node_modules/fs-extra": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", + "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/electron-builder-squirrel-windows/node_modules/jsonfile": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.0.tgz", + "integrity": "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/electron-builder-squirrel-windows/node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/electron-builder/node_modules/fs-extra": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", + "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/electron-builder/node_modules/jsonfile": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.0.tgz", + "integrity": "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/electron-builder/node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/electron-publish": { + "version": "24.13.1", + "resolved": "https://registry.npmjs.org/electron-publish/-/electron-publish-24.13.1.tgz", + "integrity": "sha512-2ZgdEqJ8e9D17Hwp5LEq5mLQPjqU3lv/IALvgp+4W8VeNhryfGhYEQC/PgDPMrnWUp+l60Ou5SJLsu+k4mhQ8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/fs-extra": "^9.0.11", + "builder-util": "24.13.1", + "builder-util-runtime": "9.2.4", + "chalk": "^4.1.2", + "fs-extra": "^10.1.0", + "lazy-val": "^1.0.5", + "mime": "^2.5.2" + } + }, + "node_modules/electron-publish/node_modules/fs-extra": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", + "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/electron-publish/node_modules/jsonfile": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.0.tgz", + "integrity": "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/electron-publish/node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/electron-updater": { + "version": "6.8.9", + "resolved": "https://registry.npmjs.org/electron-updater/-/electron-updater-6.8.9.tgz", + "integrity": "sha512-ZhVxM9iGONUpZGI1FxdMRgJjUFXi7AYGVa5PwKlO1tV1/4zDxQmfKpXOHVztKrd6L9rLcFjERvi1Mf2vxyTkig==", + "license": "MIT", + "dependencies": { + "builder-util-runtime": "9.7.0", + "fs-extra": "^10.1.0", + "js-yaml": "^4.1.0", + "lazy-val": "^1.0.5", + "lodash.escaperegexp": "^4.1.2", + "lodash.isequal": "^4.5.0", + "semver": "~7.7.3", + "tiny-typed-emitter": "^2.1.0" + } + }, + "node_modules/electron-updater/node_modules/builder-util-runtime": { + "version": "9.7.0", + "resolved": "https://registry.npmjs.org/builder-util-runtime/-/builder-util-runtime-9.7.0.tgz", + "integrity": "sha512-g/kR520giAFYkSXTzcmF3kqQq7wi8F6N6SzeDgZrqTBN+VHdmgWOyTdD1yD7AATDId/yXLvuP34CxW46/BwCdw==", + "license": "MIT", + "dependencies": { + "debug": "^4.3.4", + "sax": "^1.2.4" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/electron-updater/node_modules/fs-extra": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", + "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/electron-updater/node_modules/jsonfile": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.0.tgz", + "integrity": "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==", + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/electron-updater/node_modules/semver": { + "version": "7.7.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", + "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/electron-updater/node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/electron/node_modules/@types/node": { + "version": "18.19.130", + "resolved": "https://registry.npmjs.org/@types/node/-/node-18.19.130.tgz", + "integrity": "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~5.26.4" + } + }, + "node_modules/electron/node_modules/undici-types": { + "version": "5.26.5", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", + "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==", + "dev": true, + "license": "MIT" + }, + "node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/end-of-stream": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", + "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", + "license": "MIT", + "dependencies": { + "once": "^1.4.0" + } + }, + "node_modules/env-paths": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz", + "integrity": "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/err-code": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/err-code/-/err-code-2.0.3.tgz", + "integrity": "sha512-2bmlRpNKBxT/CRmPOlyISQpNj+qSeYvcym/uT0Jx2bMOlKLtSy1ZmLuVxSEKKyor/N5yhvp/ZiG1oE3DEYMSFA==", + "dev": true, + "license": "MIT" + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "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.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es6-error": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/es6-error/-/es6-error-4.1.1.tgz", + "integrity": "sha512-Um/+FxMr9CISWh0bi5Zv0iOD+4cFh5qLeks1qhAopKVAJw3drgKbKySikp7wGhDL0HPeaja0P5ULZrxLkniUVg==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.4.0.tgz", + "integrity": "sha512-loXy6bWOoP3EP6JA7jo6p5jMpBJmHmsNZM5SFRHLdh1MGOPurMnNBj4ZlAbaqUAaQWbCr7jHV4P7gzAyryZWkQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.2", + "@eslint/config-array": "^0.23.5", + "@eslint/config-helpers": "^0.6.0", + "@eslint/core": "^1.2.1", + "@eslint/plugin-kit": "^0.7.1", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "ajv": "^6.14.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^9.1.2", + "eslint-visitor-keys": "^5.0.1", + "espree": "^11.2.0", + "esquery": "^1.7.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^8.0.0", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "minimatch": "^10.2.4", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-plugin-security": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-security/-/eslint-plugin-security-4.0.0.tgz", + "integrity": "sha512-tfuQT8K/Li1ZxhFzyD8wPIKtlzZxqBcPr9q0jFMQ77wWAbKBVEhaMPVQRTMTvCMUDhwBe5vPVqQPwAGk/ASfxQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "safe-regex": "^2.1.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-scope": { + "version": "9.1.2", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-9.1.2.tgz", + "integrity": "sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "@types/esrecurse": "^4.3.1", + "@types/estree": "^1.0.8", + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/eslint/node_modules/brace-expansion": { + "version": "5.0.9", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", + "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/eslint/node_modules/minimatch": { + "version": "10.2.4", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.4.tgz", + "integrity": "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/espree": { + "version": "11.2.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-11.2.0.tgz", + "integrity": "sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.16.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^5.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esquery": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "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/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/expand-template": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz", + "integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==", + "license": "(MIT OR WTFPL)", + "engines": { + "node": ">=6" + } + }, + "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/extract-zip": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/extract-zip/-/extract-zip-2.0.1.tgz", + "integrity": "sha512-GDhU9ntwuKyGXdZBUgTIe+vXnWj0fppUEtMDL0+idd5Sta8TGpHssn/eusA9mrPr9qNDym6SxAYZjNvCn/9RBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "debug": "^4.1.1", + "get-stream": "^5.1.0", + "yauzl": "^2.10.0" + }, + "bin": { + "extract-zip": "cli.js" + }, + "engines": { + "node": ">= 10.17.0" + }, + "optionalDependencies": { + "@types/yauzl": "^2.9.1" + } + }, + "node_modules/extsprintf": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/extsprintf/-/extsprintf-1.4.1.tgz", + "integrity": "sha512-Wrk35e8ydCKDj/ArClo1VrPVmN8zph5V4AtHwIuHhvMXsKf73UT3BOD+azBIW+3wOJ4FhEH7zyaJCFvChjYvMA==", + "dev": true, + "engines": [ + "node >=0.6.0" + ], + "license": "MIT", + "optional": true + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fd-slicer": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/fd-slicer/-/fd-slicer-1.1.0.tgz", + "integrity": "sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "pend": "~1.2.0" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/file-entry-cache": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", + "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^4.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/file-uri-to-path": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz", + "integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==", + "license": "MIT" + }, + "node_modules/filelist": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/filelist/-/filelist-1.0.4.tgz", + "integrity": "sha512-w1cEuf3S+DrLCQL7ET6kz+gmlJdbq9J7yXCSjK/OZCPA+qEN1WyF4ZAf0YYJa4/shHJra2t/d/r8SV4Ji+x+8Q==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "minimatch": "^5.0.1" + } + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat-cache": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", + "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/flatted": { + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz", + "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==", + "dev": true, + "license": "ISC" + }, + "node_modules/follow-redirects": { + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz", + "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/foreground-child": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", + "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", + "dev": true, + "license": "ISC", + "dependencies": { + "cross-spawn": "^7.0.6", + "signal-exit": "^4.0.1" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/form-data": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz", + "integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.4", + "mime-types": "^2.1.35" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fs-constants": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", + "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==", + "license": "MIT" + }, + "node_modules/fs-extra": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-8.1.0.tgz", + "integrity": "sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^4.0.0", + "universalify": "^0.1.0" + }, + "engines": { + "node": ">=6 <7 || >=8" + } + }, + "node_modules/fs-minipass": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fs-minipass/-/fs-minipass-2.1.0.tgz", + "integrity": "sha512-V/JgOLFCS+R6Vcq0slCuaeWEdNC3ouDlJMNIsacH2VtALiu9mV4LPrHc5cDl8k5aw6J8jwgWWpiTo5RYhmIzvg==", + "dev": true, + "license": "ISC", + "dependencies": { + "minipass": "^3.0.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/fs-minipass/node_modules/minipass": { + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", + "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/fs.realpath": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", + "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", + "dev": true, + "license": "ISC" + }, + "node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "dev": true, + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/get-stream": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-5.2.0.tgz", + "integrity": "sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==", + "dev": true, + "license": "MIT", + "dependencies": { + "pump": "^3.0.0" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/github-from-package": { + "version": "0.0.0", + "resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz", + "integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==", + "license": "MIT" + }, + "node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "dev": true, + "license": "ISC", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/glob/node_modules/brace-expansion": { + "version": "1.1.18", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz", + "integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/glob/node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/global-agent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/global-agent/-/global-agent-3.0.0.tgz", + "integrity": "sha512-PT6XReJ+D07JvGoxQMkT6qji/jVNfX/h364XHZOWeRzy64sSFr+xJ5OX7LI3b4MPQzdL4H8Y8M0xzPpsVMwA8Q==", + "dev": true, + "license": "BSD-3-Clause", + "optional": true, + "dependencies": { + "boolean": "^3.0.1", + "es6-error": "^4.1.1", + "matcher": "^3.0.0", + "roarr": "^2.15.3", + "semver": "^7.3.2", + "serialize-error": "^7.0.1" + }, + "engines": { + "node": ">=10.0" + } + }, + "node_modules/global-agent/node_modules/semver": { + "version": "7.7.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", + "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", + "dev": true, + "license": "ISC", + "optional": true, + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/globalthis": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/globalthis/-/globalthis-1.0.4.tgz", + "integrity": "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "define-properties": "^1.2.1", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/got": { + "version": "11.8.6", + "resolved": "https://registry.npmjs.org/got/-/got-11.8.6.tgz", + "integrity": "sha512-6tfZ91bOr7bOXnK7PRDCGBLa1H4U080YHNaAQ2KsMGlLEzRbk44nsZF2E1IeRc3vtJHPVbKCYgdFbaGO2ljd8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@sindresorhus/is": "^4.0.0", + "@szmarczak/http-timer": "^4.0.5", + "@types/cacheable-request": "^6.0.1", + "@types/responselike": "^1.0.0", + "cacheable-lookup": "^5.0.3", + "cacheable-request": "^7.0.2", + "decompress-response": "^6.0.0", + "http2-wrapper": "^1.0.0-beta.5.2", + "lowercase-keys": "^2.0.0", + "p-cancelable": "^2.0.0", + "responselike": "^2.0.0" + }, + "engines": { + "node": ">=10.19.0" + }, + "funding": { + "url": "https://github.com/sindresorhus/got?sponsor=1" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "license": "ISC" + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/has-property-descriptors": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", + "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "es-define-property": "^1.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/hosted-git-info": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-4.1.0.tgz", + "integrity": "sha512-kyCuEOWjJqZuDbRHzL8V93NzQhwIB71oFWSyzVo+KPZI+pnQPPxucdkrOZvkLRnrf5URsQM+IJ09Dw29cRALIA==", + "dev": true, + "license": "ISC", + "dependencies": { + "lru-cache": "^6.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/http-cache-semantics": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz", + "integrity": "sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ==", + "dev": true, + "license": "BSD-2-Clause" + }, + "node_modules/http-proxy-agent": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-5.0.0.tgz", + "integrity": "sha512-n2hY8YdoRE1i7r6M0w9DIw5GgZN0G25P8zLCRQ8rjXtTU3vsNFBI/vWK/UIeE6g5MUUz6avwAPXmL6Fy9D/90w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@tootallnate/once": "2", + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/http2-wrapper": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/http2-wrapper/-/http2-wrapper-1.0.3.tgz", + "integrity": "sha512-V+23sDMr12Wnz7iTcDeJr3O6AIxlnvT/bmaAAAP/Xda35C90p9599p0F1eHR/N1KILWSoWVAiOMFjBBXaXSMxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "quick-lru": "^5.1.1", + "resolve-alpn": "^1.0.0" + }, + "engines": { + "node": ">=10.19.0" + } + }, + "node_modules/https-proxy-agent": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", + "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", + "license": "MIT", + "dependencies": { + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/iconv-corefoundation": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/iconv-corefoundation/-/iconv-corefoundation-1.1.7.tgz", + "integrity": "sha512-T10qvkw0zz4wnm560lOEg0PovVqUXuOFhhHAkixw8/sycy7TJt7v/RrkEKEQnAw2viPSJu6iAkErxnzR0g8PpQ==", + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "dependencies": { + "cli-truncate": "^2.1.0", + "node-addon-api": "^1.6.3" + }, + "engines": { + "node": "^8.11.2 || >=10" + } + }, + "node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/inflight": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", + "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", + "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", + "dev": true, + "license": "ISC", + "dependencies": { + "once": "^1.3.0", + "wrappy": "1" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ini": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", + "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", + "license": "ISC" + }, + "node_modules/is-ci": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/is-ci/-/is-ci-3.0.1.tgz", + "integrity": "sha512-ZYvCgrefwqoQ6yTyYUbQu64HsITZ3NfKX1lzaEYdkTDcfKzzCI/wthRRYKkdjHKFVgNiXKAKm65Zo1pk2as/QQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ci-info": "^3.2.0" + }, + "bin": { + "is-ci": "bin.js" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/isbinaryfile": { + "version": "5.0.7", + "resolved": "https://registry.npmjs.org/isbinaryfile/-/isbinaryfile-5.0.7.tgz", + "integrity": "sha512-gnWD14Jh3FzS3CPhF0AxNOJ8CxqeblPTADzI38r0wt8ZyQl5edpy75myt08EG2oKvpyiqSqsx+Wkz9vtkbTqYQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 18.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/gjtorikian/" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/jackspeak": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", + "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/cliui": "^8.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + }, + "optionalDependencies": { + "@pkgjs/parseargs": "^0.11.0" + } + }, + "node_modules/jake": { + "version": "10.9.4", + "resolved": "https://registry.npmjs.org/jake/-/jake-10.9.4.tgz", + "integrity": "sha512-wpHYzhxiVQL+IV05BLE2Xn34zW1S223hvjtqk0+gsPrwd/8JNLXJgZZM/iPFsYc1xyphF+6M6EvdE5E9MBGkDA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "async": "^3.2.6", + "filelist": "^1.0.4", + "picocolors": "^1.1.1" + }, + "bin": { + "jake": "bin/cli.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/js-yaml": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.1.tgz", + "integrity": "sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stringify-safe": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz", + "integrity": "sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA==", + "dev": true, + "license": "ISC", + "optional": true + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/jsonfile": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-4.0.0.tgz", + "integrity": "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==", + "dev": true, + "license": "MIT", + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/lazy-val": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/lazy-val/-/lazy-val-1.0.5.tgz", + "integrity": "sha512-0/BnGCCfyUMkBpeDgWihanIAF9JmZhHBgUhEqzvf+adhNGLoP6TaiI5oF8oyb3I45P+PcnrqihSf01M0l0G5+Q==", + "license": "MIT" + }, + "node_modules/lazystream": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/lazystream/-/lazystream-1.0.1.tgz", + "integrity": "sha512-b94GiNHQNy6JNTrt5w6zNyffMrNkXZb3KTkCZJb2V1xaEGCk093vkZ2jk3tpaeP33/OiXC+WvK9AxUebnf5nbw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "readable-stream": "^2.0.5" + }, + "engines": { + "node": ">= 0.6.3" + } + }, + "node_modules/lazystream/node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/lazystream/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/lazystream/node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/lightningcss": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", + "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.33.0", + "lightningcss-darwin-arm64": "1.33.0", + "lightningcss-darwin-x64": "1.33.0", + "lightningcss-freebsd-x64": "1.33.0", + "lightningcss-linux-arm-gnueabihf": "1.33.0", + "lightningcss-linux-arm64-gnu": "1.33.0", + "lightningcss-linux-arm64-musl": "1.33.0", + "lightningcss-linux-x64-gnu": "1.33.0", + "lightningcss-linux-x64-musl": "1.33.0", + "lightningcss-win32-arm64-msvc": "1.33.0", + "lightningcss-win32-x64-msvc": "1.33.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz", + "integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz", + "integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz", + "integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz", + "integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz", + "integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz", + "integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz", + "integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz", + "integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz", + "integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz", + "integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz", + "integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lodash": { + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", + "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.defaults": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/lodash.defaults/-/lodash.defaults-4.2.0.tgz", + "integrity": "sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/lodash.difference": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/lodash.difference/-/lodash.difference-4.5.0.tgz", + "integrity": "sha512-dS2j+W26TQ7taQBGN8Lbbq04ssV3emRw4NY58WErlTO29pIqS0HmoT5aJ9+TUQ1N3G+JOZSji4eugsWwGp9yPA==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/lodash.escaperegexp": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/lodash.escaperegexp/-/lodash.escaperegexp-4.1.2.tgz", + "integrity": "sha512-TM9YBvyC84ZxE3rgfefxUWiQKLilstD6k7PTGt6wfbtXF8ixIJLOL3VYyV/z+ZiPLsVxAsKAFVwWlWeb2Y8Yyw==", + "license": "MIT" + }, + "node_modules/lodash.flatten": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/lodash.flatten/-/lodash.flatten-4.4.0.tgz", + "integrity": "sha512-C5N2Z3DgnnKr0LOpv/hKCgKdb7ZZwafIrsesve6lmzvZIRZRGaZ/l6Q8+2W7NaT+ZwO3fFlSCzCzrDCFdJfZ4g==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/lodash.isequal": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/lodash.isequal/-/lodash.isequal-4.5.0.tgz", + "integrity": "sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ==", + "deprecated": "This package is deprecated. Use require('node:util').isDeepStrictEqual instead.", + "license": "MIT" + }, + "node_modules/lodash.isplainobject": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", + "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/lodash.union": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/lodash.union/-/lodash.union-4.6.0.tgz", + "integrity": "sha512-c4pB2CdGrGdjMKYLA+XiRDO7Y0PRQbm/Gzg8qMj+QH+pFVAoTp5sBpO0odL3FjoPCGjK96p6qsP+yQoiLoOBcw==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/lowercase-keys": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/lowercase-keys/-/lowercase-keys-2.0.0.tgz", + "integrity": "sha512-tqNXrS78oMOE73NMxK4EMLQsQowWf8jKooH9g7xPavRT706R6bkQJ6DY2Te7QukaZsulxa30wQ7bk0pm4XiHmA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/lru-cache": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", + "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/matcher": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/matcher/-/matcher-3.0.0.tgz", + "integrity": "sha512-OkeDaAZ/bQCxeFAozM55PKcKU0yJMPGifLwV4Qgjitu+5MoAfSQN4lsLJeXZ1b8w0x+/Emda6MZgXS1jvsapng==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "escape-string-regexp": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/mime": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-2.6.0.tgz", + "integrity": "sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg==", + "dev": true, + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mimic-response": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-1.0.1.tgz", + "integrity": "sha512-j5EctnkH7amfV/q5Hgmoal1g2QHFJRraOtmx0JpIqkxhBhI/lJSl1nMpQ45hVarwNETOoWEimndZ4QK0RHxuxQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/minimatch": { + "version": "5.1.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.9.tgz", + "integrity": "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/minipass": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-5.0.0.tgz", + "integrity": "sha512-3FnjYuehv9k6ovOEbyOswadCDPX1piCfhV8ncmYtHOjuPwylVWsghTLo7rabjC3Rx5xD4HDx8Wm1xnMF7S5qFQ==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=8" + } + }, + "node_modules/minizlib": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-2.1.2.tgz", + "integrity": "sha512-bAxsR8BVfj60DWXHE3u30oHzfl4G7khkSuPW+qvpd7jFRHm7dLxOjUk1EHACJ/hxLY8phGJ0YhYHZo7jil7Qdg==", + "dev": true, + "license": "MIT", + "dependencies": { + "minipass": "^3.0.0", + "yallist": "^4.0.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/minizlib/node_modules/minipass": { + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", + "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/mkdirp": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz", + "integrity": "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==", + "dev": true, + "license": "MIT", + "bin": { + "mkdirp": "bin/cmd.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/mkdirp-classic": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz", + "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==", + "license": "MIT" + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.17", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.17.tgz", + "integrity": "sha512-xQLf0A3HOMlgHq0n247/LRuAOYmB7dXJ/DvAxGvsSBij45XtBSmQycu+F8ODbHwns/XyFZagyL1+J0Offw1E0g==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/napi-build-utils": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz", + "integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==", + "license": "MIT" + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, + "node_modules/node-abi": { + "version": "3.92.0", + "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.92.0.tgz", + "integrity": "sha512-KdHvFWZjEKDf0cakgFjebl371GPsISX2oZHcuyKqM7DtogIsHrqKeLTo8wBHxaXRAQlY2PsPlZmfo+9ZCxEREQ==", + "license": "MIT", + "dependencies": { + "semver": "^7.3.5" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/node-abi/node_modules/semver": { + "version": "7.8.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.0.tgz", + "integrity": "sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/node-addon-api": { + "version": "1.7.2", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-1.7.2.tgz", + "integrity": "sha512-ibPK3iA+vaY1eEjESkQkM0BbCqFOaZMiXRTtdB0u7b4djtY6JnsjvPdUHVMg6xQt3B8fpTTWHI9A+ADjM9frzg==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/normalize-url": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/normalize-url/-/normalize-url-6.1.0.tgz", + "integrity": "sha512-DlL+XwOy3NxAQ8xuC0okPgK46iuVNAK01YN7RueYBqqFeGsBjV9XmCAzAdgt+667bCl5kPh9EqKKDwnaPG1I7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/object-keys": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", + "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/obug": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz", + "integrity": "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT" + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/p-cancelable": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/p-cancelable/-/p-cancelable-2.1.1.tgz", + "integrity": "sha512-BZOr3nRQHOntUjTrH8+Lh54smKHoHyur8We1V8DSMVrl5A2malOOwuJRnKRDjSnkoeBh4at6BwEnb5I7Jl31wg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/package-json-from-dist": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", + "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", + "dev": true, + "license": "BlueOak-1.0.0" + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-is-absolute": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", + "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-scurry": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", + "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^10.2.0", + "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" + }, + "engines": { + "node": ">=16 || 14 >=14.18" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/path-scurry/node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true, + "license": "ISC" + }, + "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/pend": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz", + "integrity": "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/playwright": { + "version": "1.60.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.60.0.tgz", + "integrity": "sha512-hheHdokM8cdqCb0lcE3s+zT4t4W+vvjpGxsZlDnikarzx8tSzMebh3UiFtgqwFwnTnjYQcsyMF8ei2mCO/tpeA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.60.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.60.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.60.0.tgz", + "integrity": "sha512-9bW6zvX/m0lEbgTKJ6YppOKx8H3VOPBMOCFh2irXFOT4BbHgrx5hPjwJYLT40Lu+4qtD36qKc/Hn56StUW57IA==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/plist": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/plist/-/plist-3.1.0.tgz", + "integrity": "sha512-uysumyrvkUX0rX/dEVqt8gC3sTBzd4zoWfLeS29nb53imdaXVvLINYXTI2GNqzaMuvacNx4uJQ8+b3zXR0pkgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@xmldom/xmldom": "^0.8.8", + "base64-js": "^1.5.1", + "xmlbuilder": "^15.1.1" + }, + "engines": { + "node": ">=10.4.0" + } + }, + "node_modules/postcss": { + "version": "8.5.25", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.25.tgz", + "integrity": "sha512-DTPx3RWSSnWyzLxQnlH0rJP+EW5ekl16ZU4/psbIhA0e53kJfdgaN5vKM+xP7yJtXVu+nfdVFmlgFDEKAe4Pyw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.16", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/prebuild-install": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz", + "integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==", + "deprecated": "No longer maintained. Please contact the author of the relevant native addon; alternatives are available.", + "license": "MIT", + "dependencies": { + "detect-libc": "^2.0.0", + "expand-template": "^2.0.3", + "github-from-package": "0.0.0", + "minimist": "^1.2.3", + "mkdirp-classic": "^0.5.3", + "napi-build-utils": "^2.0.0", + "node-abi": "^3.3.0", + "pump": "^3.0.0", + "rc": "^1.2.7", + "simple-get": "^4.0.0", + "tar-fs": "^2.0.0", + "tunnel-agent": "^0.6.0" + }, + "bin": { + "prebuild-install": "bin.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/process-nextick-args": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", + "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/progress": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz", + "integrity": "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/promise-retry": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/promise-retry/-/promise-retry-2.0.1.tgz", + "integrity": "sha512-y+WKFlBR8BGXnsNlIHFGPZmyDf3DFMoLhaflAnyZgV6rG6xu+JwesTo2Q9R6XwYmtmwAFCkAk3e35jEdoeh/3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "err-code": "^2.0.2", + "retry": "^0.12.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/proxy-from-env": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz", + "integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/pump": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.3.tgz", + "integrity": "sha512-todwxLMY7/heScKmntwQG8CXVkWUOdYxIvY2s0VWAAMh/nd8SoYiRaKjlr7+iCs984f2P8zvrfWcDDYVb73NfA==", + "license": "MIT", + "dependencies": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/quick-lru": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/quick-lru/-/quick-lru-5.1.1.tgz", + "integrity": "sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/rc": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", + "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==", + "license": "(BSD-2-Clause OR MIT OR Apache-2.0)", + "dependencies": { + "deep-extend": "^0.6.0", + "ini": "~1.3.0", + "minimist": "^1.2.0", + "strip-json-comments": "~2.0.1" + }, + "bin": { + "rc": "cli.js" + } + }, + "node_modules/read-config-file": { + "version": "6.3.2", + "resolved": "https://registry.npmjs.org/read-config-file/-/read-config-file-6.3.2.tgz", + "integrity": "sha512-M80lpCjnE6Wt6zb98DoW8WHR09nzMSpu8XHtPkiTHrJ5Az9CybfeQhTJ8D7saeBHpGhLPIVyA8lcL6ZmdKwY6Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "config-file-ts": "^0.2.4", + "dotenv": "^9.0.2", + "dotenv-expand": "^5.1.0", + "js-yaml": "^4.1.0", + "json5": "^2.2.0", + "lazy-val": "^1.0.4" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/readdir-glob": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/readdir-glob/-/readdir-glob-1.1.3.tgz", + "integrity": "sha512-v05I2k7xN8zXvPD9N+z/uhXPaj0sUFCe2rcWZIpBsqxfP7xXFQ0tipAd/wjj1YxWyWtUS5IDJpOG82JKt2EAVA==", + "dev": true, + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "minimatch": "^5.1.0" + } + }, + "node_modules/regexp-tree": { + "version": "0.1.27", + "resolved": "https://registry.npmjs.org/regexp-tree/-/regexp-tree-0.1.27.tgz", + "integrity": "sha512-iETxpjK6YoRWJG5o6hXLwvjYAoW+FEZn9os0PD/b6AP6xQwsa/Y7lCVgIixBbUPMfhu+i2LtdeAqVTgGlQarfA==", + "dev": true, + "license": "MIT", + "bin": { + "regexp-tree": "bin/regexp-tree" + } + }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/resolve-alpn": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/resolve-alpn/-/resolve-alpn-1.2.1.tgz", + "integrity": "sha512-0a1F4l73/ZFZOakJnQ3FvkJ2+gSTQWz/r2KE5OdDY0TxPm5h4GkqkWWfM47T7HsbnOtcJVEF4epCVy6u7Q3K+g==", + "dev": true, + "license": "MIT" + }, + "node_modules/responselike": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/responselike/-/responselike-2.0.1.tgz", + "integrity": "sha512-4gl03wn3hj1HP3yzgdI7d3lCkF95F21Pz4BPGvKHinyQzALR5CapwC8yIi0Rh58DEMQ/SguC03wFj2k0M/mHhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "lowercase-keys": "^2.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/retry": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/retry/-/retry-0.12.0.tgz", + "integrity": "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/roarr": { + "version": "2.15.4", + "resolved": "https://registry.npmjs.org/roarr/-/roarr-2.15.4.tgz", + "integrity": "sha512-CHhPh+UNHD2GTXNYhPWLnU8ONHdI+5DI+4EYIAOaiD63rHeYlZvyh8P+in5999TTSFgUYuKUAjzRI4mdh/p+2A==", + "dev": true, + "license": "BSD-3-Clause", + "optional": true, + "dependencies": { + "boolean": "^3.0.1", + "detect-node": "^2.0.4", + "globalthis": "^1.0.1", + "json-stringify-safe": "^5.0.1", + "semver-compare": "^1.0.0", + "sprintf-js": "^1.1.2" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/rolldown": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.3.tgz", + "integrity": "sha512-rn9wpmxplLf7NLNyCk9FyWh3FM43DbY8jOzCdEPzH7uflhTftRbCEpqi6Ly2osgoU8OwObtmavMbWLaWy4LX7A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.143.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm64": "1.2.3", + "@rolldown/binding-darwin-arm64": "1.2.3", + "@rolldown/binding-darwin-x64": "1.2.3", + "@rolldown/binding-freebsd-x64": "1.2.3", + "@rolldown/binding-linux-arm-gnueabihf": "1.2.3", + "@rolldown/binding-linux-arm64-gnu": "1.2.3", + "@rolldown/binding-linux-arm64-musl": "1.2.3", + "@rolldown/binding-linux-ppc64-gnu": "1.2.3", + "@rolldown/binding-linux-s390x-gnu": "1.2.3", + "@rolldown/binding-linux-x64-gnu": "1.2.3", + "@rolldown/binding-linux-x64-musl": "1.2.3", + "@rolldown/binding-openharmony-arm64": "1.2.3", + "@rolldown/binding-win32-arm64-msvc": "1.2.3", + "@rolldown/binding-win32-x64-msvc": "1.2.3" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/safe-regex": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/safe-regex/-/safe-regex-2.1.1.tgz", + "integrity": "sha512-rx+x8AMzKb5Q5lQ95Zoi6ZbJqwCLkqi3XuJXp5P3rT8OEc6sZCJG5AE5dU3lsgRr/F4Bs31jSlVN+j5KrsGu9A==", + "dev": true, + "license": "MIT", + "dependencies": { + "regexp-tree": "~0.1.1" + } + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "dev": true, + "license": "MIT" + }, + "node_modules/sanitize-filename": { + "version": "1.6.3", + "resolved": "https://registry.npmjs.org/sanitize-filename/-/sanitize-filename-1.6.3.tgz", + "integrity": "sha512-y/52Mcy7aw3gRm7IrcGDFx/bCk4AhRh2eI9luHOQM86nZsqwiRkkq2GekHXBBD+SmPidc8i2PqtYZl+pWJ8Oeg==", + "dev": true, + "license": "WTFPL OR ISC", + "dependencies": { + "truncate-utf8-bytes": "^1.0.0" + } + }, + "node_modules/sax": { + "version": "1.4.4", + "resolved": "https://registry.npmjs.org/sax/-/sax-1.4.4.tgz", + "integrity": "sha512-1n3r/tGXO6b6VXMdFT54SHzT9ytu9yr7TaELowdYpMqY/Ao7EnlQGmAQ1+RatX7Tkkdm6hONI2owqNx2aZj5Sw==", + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=11.0.0" + } + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/semver-compare": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/semver-compare/-/semver-compare-1.0.0.tgz", + "integrity": "sha512-YM3/ITh2MJ5MtzaM429anh+x2jiLVjqILF4m4oyQB18W7Ggea7BfqdH/wGMK7dDiMghv/6WG7znWMwUDzJiXow==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/serialize-error": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/serialize-error/-/serialize-error-7.0.1.tgz", + "integrity": "sha512-8I8TjW5KMOKsZQTvoxjuSIa7foAwPWGOts+6o7sgjz41/qMD9VQHEDxi6PBvK2l0MXUmqZyNpUK+T2tQaaElvw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "type-fest": "^0.13.1" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "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": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/simple-concat": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz", + "integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/simple-get": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz", + "integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "decompress-response": "^6.0.0", + "once": "^1.3.1", + "simple-concat": "^1.0.0" + } + }, + "node_modules/simple-update-notifier": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/simple-update-notifier/-/simple-update-notifier-2.0.0.tgz", + "integrity": "sha512-a2B9Y0KlNXl9u/vsW6sTIu9vGEpfKu2wRV6l1H3XEas/0gUIzGzBoP/IouTcUQbm9JWZLH3COxyn03TYlFax6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/simple-update-notifier/node_modules/semver": { + "version": "7.7.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", + "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/slice-ansi": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-3.0.0.tgz", + "integrity": "sha512-pSyv7bSTC7ig9Dcgbw9AuRNUb5k5V6oDudjZoMBSr13qpLBG7tB+zgCkARjq7xIUgdz5P1Qe8u+rSGdouOOIyQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "ansi-styles": "^4.0.0", + "astral-regex": "^2.0.0", + "is-fullwidth-code-point": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/smart-buffer": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz", + "integrity": "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">= 6.0.0", + "npm": ">= 3.0.0" + } + }, + "node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/source-map-support": { + "version": "0.5.21", + "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz", + "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "source-map": "^0.6.0" + } + }, + "node_modules/sprintf-js": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.1.3.tgz", + "integrity": "sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA==", + "dev": true, + "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", + "integrity": "sha512-jH9EhtKIjuXZ2cWxmXS8ZP80XyC3iasQxMDV8jzhNJpfDb7VbQLVW4Wvsxz9QZvzV+G4YoSfBUVKDOyxLzi/sg==", + "dev": true, + "license": "MIT", + "engines": { + "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.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width-cjs": { + "name": "string-width", + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi-cjs": { + "name": "strip-ansi", + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-json-comments": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", + "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/sumchecker": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/sumchecker/-/sumchecker-3.0.1.tgz", + "integrity": "sha512-MvjXzkz/BOfyVDkG0oFOtBxHX2u3gKbMHIF/dXblZsgD3BWOFLmHovIpZY7BykJdAjcqRCBi1WYBNdEC9yI7vg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "debug": "^4.1.0" + }, + "engines": { + "node": ">= 8.0" + } + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/tar": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/tar/-/tar-6.2.1.tgz", + "integrity": "sha512-DZ4yORTwrbTj/7MZYq2w+/ZFdI6OZ/f9SFHR+71gIVUZhOQPHzVCLpvRnPgyaMpfWxxk/4ONva3GQSyNIKRv6A==", + "deprecated": "Old versions of tar are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "dev": true, + "license": "ISC", + "dependencies": { + "chownr": "^2.0.0", + "fs-minipass": "^2.0.0", + "minipass": "^5.0.0", + "minizlib": "^2.1.1", + "mkdirp": "^1.0.3", + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/tar-fs": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.4.tgz", + "integrity": "sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==", + "license": "MIT", + "dependencies": { + "chownr": "^1.1.1", + "mkdirp-classic": "^0.5.2", + "pump": "^3.0.0", + "tar-stream": "^2.1.4" + } + }, + "node_modules/tar-fs/node_modules/chownr": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz", + "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==", + "license": "ISC" + }, + "node_modules/tar-stream": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz", + "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==", + "license": "MIT", + "dependencies": { + "bl": "^4.0.3", + "end-of-stream": "^1.4.1", + "fs-constants": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.1.1" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/temp-file": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/temp-file/-/temp-file-3.4.0.tgz", + "integrity": "sha512-C5tjlC/HCtVUOi3KWVokd4vHVViOmGjtLwIh4MuzPo/nMYTV/p1urt3RnMz2IWXDdKEGJH3k5+KPxtqRsUYGtg==", + "dev": true, + "license": "MIT", + "dependencies": { + "async-exit-hook": "^2.0.1", + "fs-extra": "^10.0.0" + } + }, + "node_modules/temp-file/node_modules/fs-extra": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", + "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/temp-file/node_modules/jsonfile": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.0.tgz", + "integrity": "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/temp-file/node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/tiny-typed-emitter": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/tiny-typed-emitter/-/tiny-typed-emitter-2.1.0.tgz", + "integrity": "sha512-qVtvMxeXbVej0cQWKqVSSAHmKZEHAvxdF8HEUBFWts8h+xEo5m/lEiPakuyZ3BnCBjOD8i24kzNOiOLLgsSxhA==", + "license": "MIT" + }, + "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.1.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.1.2.tgz", + "integrity": "sha512-dAqSqE/RabpBKI8+h26GfLq6Vb3JVXs30XYQjdMjaj/c2tS8IYYMbIzP599KtRj7c57/wYApb3QjgRgXmrCukA==", + "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", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "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", + "integrity": "sha512-e0votIpp4Uo2AJYSzVHV6xCcawuiez3DzqDAbrTc3YxBkplN6e+dM13ZeIcZnDg/QpSuU2zfZ3rzwY8ukEnaXw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.14" + } + }, + "node_modules/tmp-promise": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/tmp-promise/-/tmp-promise-3.0.3.tgz", + "integrity": "sha512-RwM7MoPojPxsOBYnyd2hy0bxtIlVrihNs9pj5SUvY8Zz1sQcQG2tG1hSr8PDxfgEB8RNKDhqbIlroIarSNDNsQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tmp": "^0.2.0" + } + }, + "node_modules/truncate-utf8-bytes": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/truncate-utf8-bytes/-/truncate-utf8-bytes-1.0.2.tgz", + "integrity": "sha512-95Pu1QXQvruGEhv62XCMO3Mm90GscOCClvrIUwCM0PYOXK3kaF3l3sIHxx71ThJfcbM2O5Au6SO3AWCSEfW4mQ==", + "dev": true, + "license": "WTFPL", + "dependencies": { + "utf8-byte-length": "^1.0.1" + } + }, + "node_modules/ts-api-utils": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", + "integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.12" + }, + "peerDependencies": { + "typescript": ">=4.8.4" + } + }, + "node_modules/tunnel-agent": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", + "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + }, + "engines": { + "node": "*" + } + }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/type-fest": { + "version": "0.13.1", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.13.1.tgz", + "integrity": "sha512-34R7HTnG0XIJcBSn5XhDd7nNFPRcXYRZrBB2O2jdKqYODldSzBAqzsWoZYYvduky73toYS/ESqxPvkDf/F0XMg==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "optional": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/typescript-eslint": { + "version": "8.59.4", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.59.4.tgz", + "integrity": "sha512-Rw6+44QNFaXtgHSjPy+Kw8hrJniMYzR85E9yLmOLcfZ91/rz+JXQbDTCmc6ccxMPY6K6PgAq26f0JCBfR7LIPQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/eslint-plugin": "8.59.4", + "@typescript-eslint/parser": "8.59.4", + "@typescript-eslint/typescript-estree": "8.59.4", + "@typescript-eslint/utils": "8.59.4" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/universalify": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.1.2.tgz", + "integrity": "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4.0.0" + } + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/utf8-byte-length": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/utf8-byte-length/-/utf8-byte-length-1.0.5.tgz", + "integrity": "sha512-Xn0w3MtiQ6zoz2vFyUVruaCL53O/DwUvkEeOvj+uulMm0BkUGYWmBYVyElqZaSLhY6ZD0ulfU3aBra2aVT4xfA==", + "dev": true, + "license": "(WTFPL OR MIT)" + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "license": "MIT" + }, + "node_modules/verror": { + "version": "1.10.1", + "resolved": "https://registry.npmjs.org/verror/-/verror-1.10.1.tgz", + "integrity": "sha512-veufcmxri4e3XSrT0xwfUR7kguIkaxBeosDg00yDWhk49wdwkSUrvvsm7nc75e1PUyvIeZj6nS8VQRYz2/S4Xg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "assert-plus": "^1.0.0", + "core-util-is": "1.0.2", + "extsprintf": "^1.2.0" + }, + "engines": { + "node": ">=0.6.0" + } + }, + "node_modules/vite": { + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.2.0.tgz", + "integrity": "sha512-pn+CFpM0lwDeKwmOq1ZaBK/9sjorZcgqxki6MbY/jPEVd9vichIlmlD4HmQ5wdP5EgqQCFRaACBxMC7uEGc6lQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.33.0", + "picomatch": "^4.0.5", + "postcss": "^8.5.23", + "rolldown": "~1.2.0", + "tinyglobby": "^0.2.17" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.4.0", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vite/node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/vitest": { + "version": "4.1.6", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.6.tgz", + "integrity": "sha512-6lvjbS3p9b4CrdCmguzbh2/4uoXhGE2q71R4OX5sqF9R1bo9Xd6fGrMAfvp5wnCzlBnFVdCOp6onuTQVbo8iUQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.1.6", + "@vitest/mocker": "4.1.6", + "@vitest/pretty-format": "4.1.6", + "@vitest/runner": "4.1.6", + "@vitest/snapshot": "4.1.6", + "@vitest/spy": "4.1.6", + "@vitest/utils": "4.1.6", + "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.6", + "@vitest/browser-preview": "4.1.6", + "@vitest/browser-webdriverio": "4.1.6", + "@vitest/coverage-istanbul": "4.1.6", + "@vitest/coverage-v8": "4.1.6", + "@vitest/ui": "4.1.6", + "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/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "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/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs": { + "name": "wrap-ansi", + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC" + }, + "node_modules/xmlbuilder": { + "version": "15.1.1", + "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-15.1.1.tgz", + "integrity": "sha512-yMqGBqtXyeN1e3TGYvgNgDVZ3j84W4cwkOXQswghol6APgZWaff9lnbvN7MHYJOiXsvGPXtjTYJEiC9J2wv9Eg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.0" + } + }, + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "dev": true, + "license": "ISC" + }, + "node_modules/yargs": { + "version": "17.7.2", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", + "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/yauzl": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/yauzl/-/yauzl-2.10.0.tgz", + "integrity": "sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer-crc32": "~0.2.3", + "fd-slicer": "~1.1.0" + } + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/zip-stream": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/zip-stream/-/zip-stream-4.1.1.tgz", + "integrity": "sha512-9qv4rlDiopXg4E69k+vMHjNN63YFMe9sZMrdlvKnCjlCRWeCBswPPMPUfx+ipsAWq1LXHe70RcbaHdJJpS6hyQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "archiver-utils": "^3.0.4", + "compress-commons": "^4.1.2", + "readable-stream": "^3.6.0" + }, + "engines": { + "node": ">= 10" + } + }, + "node_modules/zip-stream/node_modules/archiver-utils": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/archiver-utils/-/archiver-utils-3.0.4.tgz", + "integrity": "sha512-KVgf4XQVrTjhyWmx6cte4RxonPLR9onExufI1jhvw/MQ4BB6IsZD5gT8Lq+u/+pRkWna/6JoHpiQioaqFP5Rzw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "glob": "^7.2.3", + "graceful-fs": "^4.2.0", + "lazystream": "^1.0.0", + "lodash.defaults": "^4.2.0", + "lodash.difference": "^4.5.0", + "lodash.flatten": "^4.4.0", + "lodash.isplainobject": "^4.0.6", + "lodash.union": "^4.6.0", + "normalize-path": "^3.0.0", + "readable-stream": "^3.6.0" + }, + "engines": { + "node": ">= 10" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..13590c9 --- /dev/null +++ b/package.json @@ -0,0 +1,74 @@ +{ + "name": "twitch-vod-manager", + "version": "1.0.1", + "description": "Twitch VOD Manager - Download Twitch VODs easily", + "main": "dist/main.js", + "author": "Sucukdeluxe", + "license": "MIT", + "scripts": { + "build": "tsc", + "start": "npm run build && electron .", + "test:unit": "vitest run --passWithNoTests", + "test:unit:watch": "vitest", + "test:e2e:update-logic": "node scripts/smoke-test-update-version-logic.js", + "test:e2e:public-release": "node scripts/smoke-test-public-release-config.js", + "test:e2e": "node scripts/smoke-test.js", + "test:e2e:guide": "node scripts/smoke-test-template-guide.js", + "test:e2e:full": "node scripts/smoke-test-full.js", + "test:e2e:release": "npm run build && npm run test:unit && npm run test:e2e:update-logic && npm run test:e2e:public-release && npm run test:e2e && npm run test:e2e:guide && npm run test:e2e:full", + "test:e2e:stress": "npm run test:e2e:release && npm run test:e2e:release && npm run test:e2e:release", + "pack": "npm run build && electron-builder --dir", + "dist": "npm run build && electron-builder", + "dist:win": "npm run test:e2e:release && electron-builder --win", + "test:merge-split": "node scripts/smoke-test-merge-split-logic.js" + }, + "dependencies": { + "axios": "^1.16.1", + "better-sqlite3": "^12.10.0", + "electron-updater": "^6.8.3" + }, + "devDependencies": { + "@eslint/js": "^10.0.1", + "@types/better-sqlite3": "^7.6.13", + "@types/node": "^20.10.0", + "electron": "^28.0.0", + "electron-builder": "^24.9.0", + "eslint": "^10.4.0", + "eslint-plugin-security": "^4.0.0", + "playwright": "^1.60.0", + "typescript": "^5.3.0", + "typescript-eslint": "^8.59.4", + "vitest": "^4.1.6" + }, + "build": { + "appId": "io.github.sucukdeluxe.twitch-vod-manager", + "productName": "Twitch VOD Manager", + "files": [ + "dist/**/*", + "src/index.html", + "src/styles.css", + "package.json" + ], + "directories": { + "output": "release" + }, + "win": { + "target": "nsis", + "signAndEditExecutable": false, + "artifactName": "Twitch-VOD-Manager-Setup-${version}.${ext}" + }, + "nsis": { + "oneClick": false, + "allowToChangeInstallationDirectory": true, + "deleteAppDataOnUninstall": false, + "createDesktopShortcut": true, + "createStartMenuShortcut": true, + "shortcutName": "Twitch VOD Manager v${version}", + "include": "build/installer.nsh" + }, + "publish": { + "provider": "generic", + "url": "https://github.com/Sucukdeluxe/Twitch-VOD-Manager/releases/latest/download/" + } + } +} diff --git a/scripts/public-release-files.json b/scripts/public-release-files.json new file mode 100644 index 0000000..be73c40 --- /dev/null +++ b/scripts/public-release-files.json @@ -0,0 +1,24 @@ +{ + "files": [ + ".gitignore", + "CHANGELOG.md", + "LICENSE", + "README.md", + "assets", + "build", + "eslint.config.mjs", + "package-lock.json", + "package.json", + "scripts/public-release-files.json", + "scripts/smoke-test-full.js", + "scripts/smoke-test-merge-split-logic.js", + "scripts/smoke-test-public-release-config.js", + "scripts/smoke-test-settings-autosave.js", + "scripts/smoke-test-template-guide.js", + "scripts/smoke-test-update-version-logic.js", + "scripts/smoke-test.js", + "src", + "tsconfig.json", + "vitest.config.ts" + ] +} diff --git a/scripts/smoke-test-full.js b/scripts/smoke-test-full.js new file mode 100644 index 0000000..11b14e8 --- /dev/null +++ b/scripts/smoke-test-full.js @@ -0,0 +1,466 @@ +const { _electron: electron } = require('playwright'); +const path = require('path'); +const fs = require('fs'); +const { spawnSync } = require('child_process'); + +const APPDATA_DIR = path.join(process.env.PROGRAMDATA || 'C:\\ProgramData', 'Twitch_VOD_Manager'); +const CONFIG_FILE = path.join(APPDATA_DIR, 'config.json'); +const QUEUE_FILE = path.join(APPDATA_DIR, 'download_queue.json'); +const TMP_DIR = path.join(process.cwd(), 'tmp_e2e_full'); +const MEDIA_A = path.join(TMP_DIR, 'in_a.mp4'); +const MEDIA_B = path.join(TMP_DIR, 'in_b.mp4'); + +function backupFile(filePath) { + if (!fs.existsSync(filePath)) return null; + return fs.readFileSync(filePath); +} + +function restoreFile(filePath, backup) { + if (backup === null) { + if (fs.existsSync(filePath)) { + fs.rmSync(filePath, { force: true }); + } + return; + } + + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + fs.writeFileSync(filePath, backup); +} + +function findFileRecursive(rootDir, fileName) { + if (!fs.existsSync(rootDir)) return null; + + const entries = fs.readdirSync(rootDir, { withFileTypes: true }); + for (const entry of entries) { + const fullPath = path.join(rootDir, entry.name); + if (entry.isFile() && entry.name.toLowerCase() === fileName.toLowerCase()) { + return fullPath; + } + + if (entry.isDirectory()) { + const nested = findFileRecursive(fullPath, fileName); + if (nested) return nested; + } + } + + return null; +} + +function resolveFfmpegBinary() { + const direct = spawnSync('ffmpeg', ['-version'], { stdio: 'ignore', windowsHide: true }); + if (direct.status === 0) return 'ffmpeg'; + + const bundledRoot = path.join(APPDATA_DIR, 'tools', 'ffmpeg'); + const bundled = findFileRecursive(bundledRoot, process.platform === 'win32' ? 'ffmpeg.exe' : 'ffmpeg'); + if (bundled) return bundled; + + throw new Error('ffmpeg not found. Install ffmpeg or run app preflight auto-fix first.'); +} + +function runFfmpeg(ffmpegPath, args) { + const res = spawnSync(ffmpegPath, args, { windowsHide: true, stdio: 'pipe' }); + if (res.status !== 0) { + const stderr = (res.stderr || Buffer.from('')).toString('utf-8').slice(0, 800); + throw new Error(`ffmpeg failed: ${stderr || `exit ${res.status}`}`); + } +} + +function ensureTestMedia() { + fs.mkdirSync(TMP_DIR, { recursive: true }); + const ffmpeg = resolveFfmpegBinary(); + + runFfmpeg(ffmpeg, [ + '-y', + '-f', 'lavfi', + '-i', 'testsrc=size=640x360:rate=30', + '-t', '4', + '-pix_fmt', 'yuv420p', + MEDIA_A + ]); + + runFfmpeg(ffmpeg, [ + '-y', + '-f', 'lavfi', + '-i', 'testsrc=size=640x360:rate=30', + '-t', '3', + '-pix_fmt', 'yuv420p', + MEDIA_B + ]); +} + +async function run() { + const configBackup = backupFile(CONFIG_FILE); + const queueBackup = backupFile(QUEUE_FILE); + + let app; + try { + ensureTestMedia(); + + const electronPath = require('electron'); + app = await electron.launch({ + executablePath: electronPath, + args: ['.'], + cwd: process.cwd() + }); + + const win = await app.firstWindow(); + const issues = []; + + win.on('pageerror', (err) => { + issues.push(`pageerror: ${String(err)}`); + }); + + win.on('console', (msg) => { + if (msg.type() === 'error') { + issues.push(`console.error: ${msg.text()}`); + } + }); + + await win.waitForTimeout(2200); + + const summary = await win.evaluate(async ({ mediaA, mediaB, tmpDir }) => { + const failures = []; + const checks = {}; + + const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); + + const assert = (condition, message) => { + if (!condition) failures.push(message); + }; + + const waitFor = async (predicate, timeoutMs = 15000, intervalMs = 250) => { + const start = Date.now(); + while (Date.now() - start < timeoutMs) { + if (predicate()) return true; + await sleep(intervalMs); + } + return false; + }; + + const clearQueue = async () => { + const q = await window.api.getQueue(); + for (const item of q) { + await window.api.removeFromQueue(item.id); + } + }; + + const cleanupDownloads = async () => { + await window.api.cancelDownload(); + await sleep(400); + }; + + const initialConfig = await window.api.getConfig(); + + try { + await cleanupDownloads(); + await clearQueue(); + + const requiredGlobals = [ + 'showTab', + 'addStreamer', + 'refreshVODs', + 'downloadClip', + 'saveSettings', + 'runPreflight', + 'refreshDebugLog', + 'toggleDebugAutoRefresh', + 'retryFailedDownloads', + 'toggleDownload' + ]; + + const missingGlobals = requiredGlobals.filter((name) => typeof window[name] !== 'function'); + checks.globals = { missingGlobals }; + assert(missingGlobals.length === 0, `Missing globals: ${missingGlobals.join(', ')}`); + + const tabs = ['vods', 'clips', 'cutter', 'merge', 'settings']; + const tabChecks = {}; + for (const tab of tabs) { + window.showTab(tab); + tabChecks[tab] = document.querySelector('.tab-content.active')?.id === `${tab}Tab`; + } + checks.tabs = tabChecks; + assert(Object.values(tabChecks).every(Boolean), 'Tab switching failed for at least one tab'); + + window.showTab('settings'); + const preflight = await window.api.runPreflight(false); + await window.runPreflight(false); + await window.refreshDebugLog(); + checks.preflight = { + ok: preflight.ok, + checks: preflight.checks, + panelText: (document.getElementById('preflightResult')?.textContent || '').slice(0, 180), + healthBadge: (document.getElementById('healthBadge')?.textContent || '').trim() + }; + assert(Boolean(checks.preflight.panelText), 'Preflight panel is empty'); + assert(Boolean(checks.preflight.healthBadge), 'Health badge is empty'); + + const lang = document.getElementById('languageSelect'); + lang.value = 'de'; + lang.dispatchEvent(new Event('change', { bubbles: true })); + await sleep(160); + const deState = { + nav: (document.getElementById('navSettingsText')?.textContent || '').trim(), + retry: (document.getElementById('btnRetryFailed')?.textContent || '').trim(), + deText: (document.getElementById('languageDeText')?.textContent || '').trim(), + deIcon: !!document.querySelector('#langOptionDe .flag-icon.flag-de'), + deActive: !!document.getElementById('langOptionDe')?.classList.contains('active') + }; + + lang.value = 'en'; + lang.dispatchEvent(new Event('change', { bubbles: true })); + await sleep(160); + const enState = { + nav: (document.getElementById('navSettingsText')?.textContent || '').trim(), + retry: (document.getElementById('btnRetryFailed')?.textContent || '').trim(), + enText: (document.getElementById('languageEnText')?.textContent || '').trim(), + enIcon: !!document.querySelector('#langOptionEn .flag-icon.flag-en'), + enActive: !!document.getElementById('langOptionEn')?.classList.contains('active') + }; + + checks.language = { deState, enState }; + assert(deState.nav.includes('Einstellungen'), 'German language switch failed'); + assert(enState.nav.includes('Settings'), 'English language switch failed'); + assert(deState.deIcon, 'German flag icon missing'); + assert(enState.enIcon, 'English flag icon missing'); + assert(deState.deActive, 'German language button did not activate'); + assert(enState.enActive, 'English language button did not activate'); + + await window.api.saveConfig({ client_id: '', client_secret: '', download_path: tmpDir }); + window.showTab('vods'); + await window.selectStreamer('xrohat'); + + await waitFor(() => document.querySelectorAll('.vod-card').length > 0, 18000, 300); + const vodCards = document.querySelectorAll('.vod-card').length; + checks.vods = { + cards: vodCards, + status: (document.getElementById('statusText')?.textContent || '').trim() + }; + assert(vodCards > 0, 'No VOD cards loaded'); + + if (vodCards > 0) { + document.querySelector('.vod-card .vod-btn.primary')?.click(); + await sleep(350); + } + + const queueAfterUiAdd = Number(document.getElementById('queueCount')?.textContent || '0'); + checks.queueBasic = { queueAfterUiAdd }; + assert(queueAfterUiAdd >= 1, 'Queue did not increase after VOD add button'); + + await clearQueue(); + + await window.api.saveConfig({ prevent_duplicate_downloads: true }); + await window.api.addToQueue({ + url: 'https://www.twitch.tv/videos/2695851503', + title: '__E2E_FULL__dup', + date: '2026-02-01T00:00:00Z', + streamer: 'xrohat', + duration_str: '1h0m0s' + }); + await window.api.addToQueue({ + url: 'https://www.twitch.tv/videos/2695851503', + title: '__E2E_FULL__dup', + date: '2026-02-01T00:00:00Z', + streamer: 'xrohat', + duration_str: '1h0m0s' + }); + let q = await window.api.getQueue(); + const duplicateCount = q.filter((item) => item.title === '__E2E_FULL__dup').length; + checks.duplicatePrevention = { duplicateCount }; + assert(duplicateCount === 1, 'Duplicate prevention did not block second queue add'); + await clearQueue(); + + const runtimeMetrics = await window.api.getRuntimeMetrics(); + checks.runtimeMetrics = { + hasQueue: !!runtimeMetrics?.queue, + hasCache: !!runtimeMetrics?.caches, + hasConfig: !!runtimeMetrics?.config, + mode: runtimeMetrics?.config?.performanceMode || 'unknown' + }; + assert(Boolean(checks.runtimeMetrics.hasQueue && checks.runtimeMetrics.hasCache && checks.runtimeMetrics.hasConfig), 'Runtime metrics snapshot missing expected sections'); + + window.showTab('clips'); + const clipUrl = document.getElementById('clipUrl'); + clipUrl.value = ''; + await window.downloadClip(); + const clipEmptyStatus = (document.getElementById('clipStatus')?.textContent || '').trim(); + assert(clipEmptyStatus.includes('Please enter a URL') || clipEmptyStatus.includes('Bitte URL eingeben'), 'Empty clip URL validation failed'); + + clipUrl.value = 'invalid-url'; + await window.downloadClip(); + const clipInvalidStatus = (document.getElementById('clipStatus')?.textContent || '').trim(); + assert(clipInvalidStatus.includes('Invalid clip URL') || clipInvalidStatus.includes('Ungueltige Clip-URL'), 'Invalid clip URL localization failed'); + + window.openClipDialog('https://www.twitch.tv/videos/2695851503', '__E2E_FULL__clip', '2026-02-01T00:00:00Z', 'xrohat', '1h0m0s'); + document.getElementById('clipStartTime').value = '00:00:10'; + document.getElementById('clipEndTime').value = '00:00:22'; + window.updateFromInput('start'); + window.updateFromInput('end'); + await window.confirmClipDialog(); + q = await window.api.getQueue(); + const clipItem = q.find((item) => item.title === '__E2E_FULL__clip'); + checks.clipQueue = { queued: !!clipItem, duration: clipItem?.customClip?.durationSec || 0 }; + assert(Boolean(clipItem && clipItem.customClip && clipItem.customClip.durationSec === 12), 'Clip dialog queue entry invalid'); + + await clearQueue(); + + await window.api.addToQueue({ + url: 'https://www.twitch.tv/videos/2695851503', + title: '__E2E_FULL__pause', + date: '2026-02-01T00:00:00Z', + streamer: 'xrohat', + duration_str: '4h0m0s' + }); + + await window.api.startDownload(); + await waitFor(async () => { + const list = await window.api.getQueue(); + const it = list.find((x) => x.title === '__E2E_FULL__pause'); + return it && (it.status === 'downloading' || it.status === 'error'); + }, 25000, 400); + + await window.api.pauseDownload(); + await sleep(1400); + q = await window.api.getQueue(); + const paused = q.find((item) => item.title === '__E2E_FULL__pause'); + checks.pauseResume = { + pausedStatus: paused?.status || 'none', + buttonText: (document.getElementById('btnStart')?.textContent || '').trim() + }; + assert(paused?.status === 'paused', 'Pause did not set item status to paused'); + + await window.api.startDownload(); + await sleep(900); + const resumed = await window.api.isDownloading(); + checks.pauseResume.resumed = resumed; + assert(resumed === true, 'Resume did not restart downloading'); + + await cleanupDownloads(); + await clearQueue(); + + await window.api.addToQueue({ + url: 'not-a-valid-url', + title: '__E2E_FULL__retry', + date: '2026-02-01T00:00:00Z', + streamer: 'xrohat', + duration_str: '1h0m0s' + }); + await window.api.startDownload(); + + const reachedError = await waitFor(async () => { + const list = await window.api.getQueue(); + const it = list.find((item) => item.title === '__E2E_FULL__retry'); + return it && it.status === 'error'; + }, 90000, 1000); + + q = await window.api.getQueue(); + const failed = q.find((item) => item.title === '__E2E_FULL__retry'); + checks.retryFlow = { + failedStatus: failed?.status || 'none', + failedReason: failed?.last_error || '' + }; + assert(reachedError && failed?.status === 'error', 'Retry item did not reach deterministic error state'); + assert(Boolean(failed?.last_error), 'Retry test item missing error reason'); + + await window.api.retryFailedDownloads(); + await sleep(500); + q = await window.api.getQueue(); + const afterRetry = q.find((item) => item.title === '__E2E_FULL__retry'); + checks.retryFlow.afterRetryStatus = afterRetry?.status || 'none'; + const retryAcceptedStatuses = ['pending', 'downloading', 'error']; + assert(retryAcceptedStatuses.includes(afterRetry?.status || ''), 'Retry failed action did not update item state'); + + await cleanupDownloads(); + await clearQueue(); + + await window.api.addToQueue({ + url: 'https://www.twitch.tv/videos/does-not-exist', + title: '__E2E_FULL__orderA', + date: '2026-02-01T00:00:00Z', + streamer: 'xrohat', + duration_str: '1h0m0s' + }); + await window.api.addToQueue({ + url: 'https://www.twitch.tv/videos/does-not-exist', + title: '__E2E_FULL__orderB', + date: '2026-02-01T00:00:00Z', + streamer: 'xrohat', + duration_str: '1h0m0s' + }); + + q = await window.api.getQueue(); + const ids = q.map((item) => item.id); + const reversed = [...ids].reverse(); + await window.api.reorderQueue(reversed); + const reordered = await window.api.getQueue(); + const reorderOk = JSON.stringify(reordered.map((item) => item.id)) === JSON.stringify(reversed); + checks.reorder = { reorderOk }; + assert(reorderOk, 'Queue reorder API failed'); + + await clearQueue(); + + const info = await window.api.getVideoInfo(mediaA); + const frame = await window.api.extractFrame(mediaA, 1); + const cut = await window.api.cutVideo(mediaA, 0.5, 1.7); + const merge = await window.api.mergeVideos([mediaA, mediaB], `${tmpDir.replace(/\\/g, '/')}/merged_full.mp4`); + checks.media = { + infoOk: !!info && info.duration > 0, + frameOk: typeof frame === 'string' && frame.length > 100, + cutOk: cut.success, + mergeOk: merge.success + }; + assert(checks.media.infoOk, 'getVideoInfo failed for test media'); + assert(checks.media.frameOk, 'extractFrame failed for test media'); + assert(checks.media.cutOk, 'cutVideo failed for test media'); + assert(checks.media.mergeOk, 'mergeVideos failed for test media'); + + const updateResult = await window.api.checkUpdate(); + checks.update = updateResult; + assert(typeof updateResult === 'object', 'checkUpdate did not return object'); + } catch (e) { + failures.push(`Unexpected exception: ${String(e)}`); + } finally { + await cleanupDownloads(); + await clearQueue(); + await window.api.saveConfig(initialConfig); + config = await window.api.getConfig(); + await window.connect(); + } + + return { checks, failures }; + }, { + mediaA: MEDIA_A.replace(/\\/g, '/'), + mediaB: MEDIA_B.replace(/\\/g, '/'), + tmpDir: TMP_DIR.replace(/\\/g, '/') + }); + + await app.close(); + app = null; + + const output = { + ...summary, + runtimeIssues: issues + }; + + console.log(JSON.stringify(output, null, 2)); + + const failed = output.failures.length > 0 || output.runtimeIssues.length > 0; + process.exit(failed ? 1 : 0); + } finally { + if (app) { + try { + await app.close(); + } catch { + // ignore + } + } + + restoreFile(CONFIG_FILE, configBackup); + restoreFile(QUEUE_FILE, queueBackup); + fs.rmSync(TMP_DIR, { recursive: true, force: true }); + } +} + +run().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/scripts/smoke-test-merge-split-logic.js b/scripts/smoke-test-merge-split-logic.js new file mode 100644 index 0000000..7370fbd --- /dev/null +++ b/scripts/smoke-test-merge-split-logic.js @@ -0,0 +1,134 @@ +function run() { + const failures = []; + const assert = (condition, message) => { + if (!condition) failures.push(message); + }; + + // ---- Test 1: parseDuration summation ---- + function parseDuration(duration) { + let seconds = 0; + const hours = duration.match(/(\d+)h/); + const minutes = duration.match(/(\d+)m/); + const secs = duration.match(/(\d+)s/); + if (hours) seconds += parseInt(hours[1]) * 3600; + if (minutes) seconds += parseInt(minutes[1]) * 60; + if (secs) seconds += parseInt(secs[1]); + return seconds; + } + + const vods = [ + { duration_str: '2h30m0s' }, + { duration_str: '1h45m30s' } + ]; + const totalDuration = vods.reduce((sum, v) => sum + parseDuration(v.duration_str), 0); + assert(totalDuration === 15330, `Duration sum: expected 15330, got ${totalDuration}`); + + // ---- Test 2: Chronological sort by ISO timestamp ---- + const items = [ + { date: '2026-03-01T18:00:00Z', title: 'Evening' }, + { date: '2026-03-01T16:00:00Z', title: 'Afternoon' }, + { date: '2026-03-02T10:00:00Z', title: 'Next Day' } + ]; + const sorted = [...items].sort((a, b) => new Date(a.date).getTime() - new Date(b.date).getTime()); + assert(sorted[0].title === 'Afternoon', `Sort[0]: expected Afternoon, got ${sorted[0].title}`); + assert(sorted[1].title === 'Evening', `Sort[1]: expected Evening, got ${sorted[1].title}`); + assert(sorted[2].title === 'Next Day', `Sort[2]: expected Next Day, got ${sorted[2].title}`); + + // ---- Test 3: Same day, different times ---- + const sameDay = [ + { date: '2026-03-01T18:30:00Z', title: 'Later' }, + { date: '2026-03-01T16:15:00Z', title: 'Earlier' } + ]; + const sortedSameDay = [...sameDay].sort((a, b) => new Date(a.date).getTime() - new Date(b.date).getTime()); + assert(sortedSameDay[0].title === 'Earlier', `SameDay[0]: expected Earlier, got ${sortedSameDay[0].title}`); + assert(sortedSameDay[1].title === 'Later', `SameDay[1]: expected Later, got ${sortedSameDay[1].title}`); + + // ---- Test 4: Merge group title generation ---- + function makeMergeTitle(items, isEnglish) { + if (items.length === 2) return `Merge: ${items[0].title} + ${items[1].title}`; + return `Merge: ${items[0].title} + ${items.length - 1} ${isEnglish ? 'more' : 'weitere'}`; + } + assert( + makeMergeTitle([{ title: 'A' }, { title: 'B' }], true) === 'Merge: A + B', + 'Title 2 items failed' + ); + assert( + makeMergeTitle([{ title: 'A' }, { title: 'B' }, { title: 'C' }], false) === 'Merge: A + 2 weitere', + 'Title 3 items DE failed' + ); + assert( + makeMergeTitle([{ title: 'A' }, { title: 'B' }, { title: 'C' }], true) === 'Merge: A + 2 more', + 'Title 3 items EN failed' + ); + + // ---- Test 5: Progress weighting (70/20/10) ---- + const totalSec = 10800; // 180min + const vod1Dur = 3600; // 60min + const vod2Dur = 7200; // 120min + const vod1Weight = vod1Dur / totalSec; + const vod2Weight = vod2Dur / totalSec; + const priorWeight = vod1Weight; + const vodProgress = 50; + const overallProgress = (priorWeight + vod2Weight * (vodProgress / 100)) * 70; + assert( + Math.abs(overallProgress - 46.67) < 0.1, + `Progress weighting: expected ~46.67, got ${overallProgress}` + ); + + // ---- Test 6: Split part count ---- + const partMinutes = 60; + const mergedDuration = 15330; // 4h15m30s + const numParts = Math.ceil(mergedDuration / (partMinutes * 60)); + assert(numParts === 5, `Split parts: expected 5, got ${numParts}`); + + // ---- Test 7: Object.keys explicit sort for downloadedFiles ---- + const downloadedFiles = { 2: '/path/c.mp4', 0: '/path/a.mp4', 1: '/path/b.mp4' }; + const sortedPaths = Object.keys(downloadedFiles) + .sort((a, b) => Number(a) - Number(b)) + .map(k => downloadedFiles[Number(k)]); + assert(sortedPaths[0] === '/path/a.mp4', `Sort files[0]: expected a.mp4, got ${sortedPaths[0]}`); + assert(sortedPaths[1] === '/path/b.mp4', `Sort files[1]: expected b.mp4, got ${sortedPaths[1]}`); + assert(sortedPaths[2] === '/path/c.mp4', `Sort files[2]: expected c.mp4, got ${sortedPaths[2]}`); + + // ---- Test 8: FFmpeg split args order (-ss before -i) ---- + function buildSplitArgs(startSec, inputFile, durationSec) { + const formatDur = (s) => { + const h = Math.floor(s / 3600); + const m = Math.floor((s % 3600) / 60); + const sec = Math.floor(s % 60); + return `${h.toString().padStart(2, '0')}:${m.toString().padStart(2, '0')}:${sec.toString().padStart(2, '0')}`; + }; + return ['-ss', formatDur(startSec), '-i', inputFile, '-t', formatDur(durationSec), '-c', 'copy', '-y', 'out.mp4']; + } + const args = buildSplitArgs(3600, 'input.mp4', 3600); + const ssIndex = args.indexOf('-ss'); + const iIndex = args.indexOf('-i'); + assert(ssIndex < iIndex, `FFmpeg args: -ss (${ssIndex}) must be before -i (${iIndex})`); + + // ---- Test 9: ensureUniqueFilename pattern ---- + function ensureUnique(base, ext, existingFiles) { + let candidate = base + ext; + if (!existingFiles.includes(candidate)) return candidate; + let counter = 1; + while (existingFiles.includes(candidate)) { + candidate = `${base}_${counter}${ext}`; + counter++; + } + return candidate; + } + assert(ensureUnique('video', '.mp4', []) === 'video.mp4', 'Unique: no conflict'); + assert(ensureUnique('video', '.mp4', ['video.mp4']) === 'video_1.mp4', 'Unique: one conflict'); + assert(ensureUnique('video', '.mp4', ['video.mp4', 'video_1.mp4']) === 'video_2.mp4', 'Unique: two conflicts'); + + // ---- Results ---- + if (failures.length > 0) { + console.error(`FAIL: ${failures.length} test(s) failed:`); + failures.forEach(f => console.error(` - ${f}`)); + process.exit(1); + } + + console.log('All merge-split logic tests passed!'); + process.exit(0); +} + +run(); diff --git a/scripts/smoke-test-public-release-config.js b/scripts/smoke-test-public-release-config.js new file mode 100644 index 0000000..85f6d3b --- /dev/null +++ b/scripts/smoke-test-public-release-config.js @@ -0,0 +1,41 @@ +const fs = require('fs'); +const path = require('path'); + +const root = process.cwd(); +const packageJson = JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8')); +const packageLock = JSON.parse(fs.readFileSync(path.join(root, 'package-lock.json'), 'utf8')); +const mainSource = fs.readFileSync(path.join(root, 'src', 'main.ts'), 'utf8'); +const indexSource = fs.readFileSync(path.join(root, 'src', 'index.html'), 'utf8'); +const manifestPath = path.join(root, 'scripts', 'public-release-files.json'); +const failures = []; + +function check(condition, message) { + if (!condition) failures.push(message); +} + +check(packageJson.version === '1.0.1', `package version is ${packageJson.version}`); +check(packageLock.version === '1.0.1', `lockfile version is ${packageLock.version}`); +check(packageLock.packages?.['']?.version === '1.0.1', `lockfile root package version is ${packageLock.packages?.['']?.version}`); +check(packageJson.build?.appId === 'io.github.sucukdeluxe.twitch-vod-manager', `appId is ${packageJson.build?.appId}`); +check(packageJson.build?.publish?.provider === 'generic', `publish provider is ${packageJson.build?.publish?.provider}`); +check(packageJson.build?.publish?.url === 'https://github.com/Sucukdeluxe/Twitch-VOD-Manager/releases/latest/download/', `publish URL is ${packageJson.build?.publish?.url}`); +check(JSON.stringify(packageJson.build?.files) === JSON.stringify(['dist/**/*', 'src/index.html', 'src/styles.css', 'package.json']), 'packaged file list is not restricted'); +check(mainSource.includes('GITHUB_RELEASES_API_LATEST_URL'), 'GitHub releases API constant is missing'); +check(mainSource.includes('GITHUB_RELEASES_DOWNLOAD_BASE_URL'), 'GitHub releases download constant is missing'); +check(mainSource.includes('https://api.github.com/repos/Sucukdeluxe/Twitch-VOD-Manager/releases/latest'), 'GitHub latest release API URL is missing'); +check(mainSource.includes('https://github.com/Sucukdeluxe/Twitch-VOD-Manager/releases/download'), 'GitHub release download URL is missing'); +check(indexSource.includes('Version: v1.0.1'), 'initial version label is not 1.0.1'); +check(!indexSource.includes('Version: v4.1.13'), 'legacy version label is still present'); +check(fs.existsSync(manifestPath), 'public release manifest is missing'); + +if (fs.existsSync(manifestPath)) { + const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8')); + const entries = Array.isArray(manifest.files) ? manifest.files : []; + for (const entry of entries) { + check(fs.existsSync(path.join(root, entry)), `public release entry does not exist: ${entry}`); + } +} + +console.log(JSON.stringify({ failures }, null, 2)); + +if (failures.length) process.exitCode = 1; diff --git a/scripts/smoke-test-settings-autosave.js b/scripts/smoke-test-settings-autosave.js new file mode 100644 index 0000000..d11baa9 --- /dev/null +++ b/scripts/smoke-test-settings-autosave.js @@ -0,0 +1,196 @@ +const { _electron: electron } = require('playwright'); +const path = require('path'); +const fs = require('fs'); + +const APPDATA_DIR = path.join(process.env.PROGRAMDATA || 'C:\\ProgramData', 'Twitch_VOD_Manager'); +const CONFIG_FILE = path.join(APPDATA_DIR, 'config.json'); + +const DEFAULT_CONFIG = { + client_id: '', + client_secret: '', + download_path: path.join(process.env.USERPROFILE || 'C:\\Users\\ploet', 'Desktop', 'Twitch_VODs'), + streamers: [], + theme: 'twitch', + download_mode: 'full', + part_minutes: 120, + language: 'en', + filename_template_vod: '{title}.mp4', + filename_template_parts: '{date}_Part{part_padded}.mp4', + filename_template_clip: '{date}_{part}.mp4', + smart_queue_scheduler: true, + performance_mode: 'balanced', + prevent_duplicate_downloads: true, + metadata_cache_minutes: 10 +}; + +function backupFile(filePath) { + if (!fs.existsSync(filePath)) return null; + return fs.readFileSync(filePath); +} + +function restoreFile(filePath, backup) { + if (backup === null) { + if (fs.existsSync(filePath)) { + fs.rmSync(filePath, { force: true }); + } + return; + } + + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + fs.writeFileSync(filePath, backup); +} + +function writeConfig(config) { + fs.mkdirSync(path.dirname(CONFIG_FILE), { recursive: true }); + fs.writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2)); +} + +function readConfig() { + return JSON.parse(fs.readFileSync(CONFIG_FILE, 'utf8')); +} + +async function launchApp() { + const electronPath = require('electron'); + return electron.launch({ + executablePath: electronPath, + args: ['.'], + cwd: process.cwd() + }); +} + +async function setSettingsAndBlur(win, mode, partMinutes) { + await win.evaluate(async ({ mode, partMinutes }) => { + window.showTab('settings'); + const modeField = document.getElementById('downloadMode'); + const partField = document.getElementById('partMinutes'); + + modeField.value = mode; + modeField.dispatchEvent(new Event('change', { bubbles: true })); + + partField.focus(); + partField.value = String(partMinutes); + partField.dispatchEvent(new Event('input', { bubbles: true })); + partField.blur(); + + await new Promise((resolve) => setTimeout(resolve, 250)); + }, { mode, partMinutes }); +} + +async function setSettingsAndCloseImmediately(win, mode, partMinutes) { + await win.evaluate(({ mode, partMinutes }) => { + window.showTab('settings'); + const modeField = document.getElementById('downloadMode'); + const partField = document.getElementById('partMinutes'); + + modeField.value = mode; + modeField.dispatchEvent(new Event('change', { bubbles: true })); + + partField.focus(); + partField.value = String(partMinutes); + partField.dispatchEvent(new Event('input', { bubbles: true })); + }, { mode, partMinutes }); +} + +async function readSettingsFromUi(win) { + return win.evaluate(() => { + window.showTab('settings'); + return { + downloadMode: document.getElementById('downloadMode')?.value || '', + partMinutes: document.getElementById('partMinutes')?.value || '' + }; + }); +} + +async function run() { + const configBackup = backupFile(CONFIG_FILE); + const baseConfig = configBackup ? { ...DEFAULT_CONFIG, ...JSON.parse(String(configBackup)) } : { ...DEFAULT_CONFIG }; + + let app = null; + try { + writeConfig({ + ...baseConfig, + client_id: '', + client_secret: '', + download_mode: 'full', + part_minutes: 120 + }); + + app = await launchApp(); + let win = await app.firstWindow(); + await win.waitForTimeout(2200); + await setSettingsAndBlur(win, 'parts', 60); + await app.close(); + app = null; + + const afterBlurClose = readConfig(); + + app = await launchApp(); + win = await app.firstWindow(); + await win.waitForTimeout(2200); + const reopenedAfterBlur = await readSettingsFromUi(win); + await app.close(); + app = null; + + writeConfig({ + ...baseConfig, + client_id: '', + client_secret: '', + download_mode: 'full', + part_minutes: 120 + }); + + app = await launchApp(); + win = await app.firstWindow(); + await win.waitForTimeout(2200); + await setSettingsAndCloseImmediately(win, 'parts', 75); + await app.close(); + app = null; + + const afterDirectClose = readConfig(); + + const result = { + afterBlurClose: { + config: { + download_mode: afterBlurClose.download_mode, + part_minutes: afterBlurClose.part_minutes + }, + ui: reopenedAfterBlur + }, + afterDirectClose: { + config: { + download_mode: afterDirectClose.download_mode, + part_minutes: afterDirectClose.part_minutes + } + } + }; + + console.log(JSON.stringify(result, null, 2)); + + const blurCaseOk = + afterBlurClose.download_mode === 'parts' && + afterBlurClose.part_minutes === 60 && + reopenedAfterBlur.downloadMode === 'parts' && + reopenedAfterBlur.partMinutes === '60'; + + const directCloseOk = + afterDirectClose.download_mode === 'parts' && + afterDirectClose.part_minutes === 75; + + process.exit(blurCaseOk && directCloseOk ? 0 : 1); + } finally { + if (app) { + try { + await app.close(); + } catch { + // ignore + } + } + + restoreFile(CONFIG_FILE, configBackup); + } +} + +run().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/scripts/smoke-test-template-guide.js b/scripts/smoke-test-template-guide.js new file mode 100644 index 0000000..716a71b --- /dev/null +++ b/scripts/smoke-test-template-guide.js @@ -0,0 +1,146 @@ +const { _electron: electron } = require('playwright'); + +async function run() { + const electronPath = require('electron'); + const app = await electron.launch({ + executablePath: electronPath, + args: ['.'], + cwd: process.cwd() + }); + + const win = await app.firstWindow(); + const issues = []; + const failures = []; + + win.on('pageerror', (err) => { + issues.push(`pageerror: ${String(err)}`); + }); + + win.on('console', (msg) => { + if (msg.type() === 'error') { + issues.push(`console.error: ${msg.text()}`); + } + }); + + const fail = (message) => failures.push(message); + + let settingsPreview = ''; + let variableRows = 0; + let clipPreviewBefore = ''; + let clipPreviewAfter = ''; + + try { + await win.waitForTimeout(2500); + + await win.evaluate(() => { + window.showTab('settings'); + }); + await win.waitForTimeout(200); + + await win.click('#settingsTemplateGuideBtn'); + await win.waitForTimeout(180); + + const guideVisibleFromSettings = await win.evaluate(() => { + return document.getElementById('templateGuideModal')?.classList.contains('show') || false; + }); + + if (!guideVisibleFromSettings) { + fail('Template guide did not open from settings'); + } + + await win.fill('#templateGuideInput', '{title}_{part_padded}_{date_custom="yyyy-MM-dd"}.mp4'); + await win.waitForTimeout(160); + + settingsPreview = await win.locator('#templateGuideOutput').innerText(); + if (!settingsPreview.includes('.mp4')) { + fail('Settings template preview missing .mp4 output'); + } + if (settingsPreview.includes('{title}') || settingsPreview.includes('{part_padded}') || settingsPreview.includes('{date_custom=')) { + fail('Settings template preview did not replace placeholders'); + } + + variableRows = await win.locator('#templateGuideBody tr').count(); + if (variableRows < 12) { + fail(`Template variable table too short (${variableRows})`); + } + + await win.click('#templateGuideUseParts'); + await win.waitForTimeout(150); + const partsContext = await win.locator('#templateGuideContext').innerText(); + if (!/part|teil/i.test(partsContext)) { + fail('Template guide parts context text missing'); + } + + await win.click('#templateGuideCloseBtn'); + await win.waitForTimeout(100); + + await win.evaluate(async () => { + window.showTab('vods'); + await window.selectStreamer('xrohat'); + }); + await win.waitForTimeout(3200); + + const clipButtons = win.locator('.vod-card .vod-btn.secondary'); + const clipCount = await clipButtons.count(); + if (clipCount < 1) { + fail('No clip buttons found in VOD list'); + } else { + await clipButtons.first().click(); + await win.waitForTimeout(260); + + await win.locator('input[name="filenameFormat"][value="template"]').check(); + await win.waitForTimeout(140); + + await win.click('#clipTemplateGuideBtn'); + await win.waitForTimeout(140); + + const clipContext = await win.locator('#templateGuideContext').innerText(); + if (!/clip/i.test(clipContext)) { + fail('Template guide clip context text missing'); + } + + await win.fill('#templateGuideInput', '{trim_start}_{part}.mp4'); + await win.waitForTimeout(120); + clipPreviewBefore = await win.locator('#templateGuideOutput').innerText(); + + await win.fill('#clipStartTime', '00:00:10'); + await win.evaluate(() => { + window.updateFromInput('start'); + }); + await win.waitForTimeout(240); + + clipPreviewAfter = await win.locator('#templateGuideOutput').innerText(); + if (clipPreviewAfter === clipPreviewBefore) { + fail('Clip template guide preview did not react to clip start time changes'); + } + + await win.click('#templateGuideCloseBtn'); + await win.evaluate(() => { + window.closeClipDialog(); + }); + } + } finally { + await app.close(); + } + + const summary = { + failures, + issues, + checks: { + settingsPreview, + variableRows, + clipPreviewBefore, + clipPreviewAfter + } + }; + + console.log(JSON.stringify(summary, null, 2)); + + const hasFailure = failures.length > 0 || issues.length > 0; + process.exit(hasFailure ? 1 : 0); +} + +run().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/scripts/smoke-test-update-version-logic.js b/scripts/smoke-test-update-version-logic.js new file mode 100644 index 0000000..431042c --- /dev/null +++ b/scripts/smoke-test-update-version-logic.js @@ -0,0 +1,84 @@ +const path = require('path'); + +const { + normalizeUpdateVersion, + compareUpdateVersions, + isNewerUpdateVersion +} = require(path.join(process.cwd(), 'dist', 'main', 'domain', 'update-version-utils.js')); + +function run() { + const failures = []; + + const assert = (condition, message) => { + if (!condition) failures.push(message); + }; + + const comparisons = [ + { left: '1.0.2', right: '1.0.1', expected: 1 }, + { left: '1.0.1', right: '1.0.2', expected: -1 }, + { left: 'v1.0.1', right: '1.0.1', expected: 0 }, + { left: '1.0.1', right: '1.0.1.1', expected: -1 }, + { left: '2.0.0', right: '1.99.999', expected: 1 }, + { left: '1.0.1-beta', right: '1.0.1', expected: 0 } + ]; + + const compareResults = comparisons.map((testCase) => { + const actual = compareUpdateVersions(testCase.left, testCase.right); + const pass = actual === testCase.expected; + assert(pass, `compare failed: ${testCase.left} vs ${testCase.right} expected ${testCase.expected}, got ${actual}`); + return { ...testCase, actual, pass }; + }); + + const skipVersionScenarios = [ + { + name: 'old downloaded, newer available', + downloaded: '1.0.1', + latestKnown: '1.0.2', + expectedNeedsNewer: true + }, + { + name: 'already latest downloaded', + downloaded: '1.0.2', + latestKnown: '1.0.2', + expectedNeedsNewer: false + }, + { + name: 'downgrade should not trigger', + downloaded: '1.0.2', + latestKnown: '1.0.1', + expectedNeedsNewer: false + } + ]; + + const scenarioResults = skipVersionScenarios.map((scenario) => { + const needsNewer = isNewerUpdateVersion(scenario.latestKnown, scenario.downloaded); + const pass = needsNewer === scenario.expectedNeedsNewer; + assert(pass, `${scenario.name} expected ${scenario.expectedNeedsNewer}, got ${needsNewer}`); + return { ...scenario, needsNewer, pass }; + }); + + const normalizationChecks = { + fromVPrefix: normalizeUpdateVersion('v1.0.1') === '1.0.1', + trimmed: normalizeUpdateVersion(' 1.0.1 ') === '1.0.1' + }; + + assert(normalizationChecks.fromVPrefix, 'normalize did not remove v prefix'); + assert(normalizationChecks.trimmed, 'normalize did not trim whitespace'); + + const summary = { + checks: { + compareResults, + scenarioResults, + normalizationChecks + }, + failures + }; + + console.log(JSON.stringify(summary, null, 2)); + + if (failures.length) { + process.exitCode = 1; + } +} + +run(); diff --git a/scripts/smoke-test.js b/scripts/smoke-test.js new file mode 100644 index 0000000..43d6ac0 --- /dev/null +++ b/scripts/smoke-test.js @@ -0,0 +1,149 @@ +const { _electron: electron } = require('playwright'); + +async function run() { + const electronPath = require('electron'); + const app = await electron.launch({ + executablePath: electronPath, + args: ['.'], + cwd: process.cwd() + }); + + const win = await app.firstWindow(); + const issues = []; + + win.on('pageerror', (err) => { + issues.push(`pageerror: ${String(err)}`); + }); + + win.on('console', (msg) => { + if (msg.type() === 'error') { + issues.push(`console.error: ${msg.text()}`); + } + }); + + await win.waitForTimeout(2500); + + const globals = await win.evaluate(async () => { + const names = [ + 'showTab', + 'addStreamer', + 'refreshVODs', + 'downloadClip', + 'selectCutterVideo', + 'startCutting', + 'addMergeFiles', + 'startMerging', + 'saveSettings', + 'checkUpdate', + 'downloadUpdate', + 'updateFromInput', + 'updateFromSlider', + 'runPreflight', + 'retryFailedDownloads', + 'toggleDebugAutoRefresh' + ]; + const map = {}; + for (const n of names) map[n] = typeof window[n]; + return map; + }); + + await win.evaluate(() => { + window.showTab('clips'); + window.showTab('cutter'); + window.showTab('merge'); + window.showTab('settings'); + window.showTab('vods'); + }); + + const input = win.locator('#newStreamer'); + const randomName = `smoketest_${Date.now()}`; + await input.fill(randomName); + await win.evaluate(async () => { + await window.addStreamer(); + }); + + const hasTempStreamer = await win.locator('#streamerList').innerText(); + + await win.evaluate(async (name) => { + await window.removeStreamer(name); + }, randomName); + + await win.evaluate(async () => { + await window.selectStreamer('xrohat'); + }); + + await win.waitForTimeout(3500); + + const vodCount = await win.locator('.vod-card').count(); + + if (vodCount > 0) { + await win.locator('.vod-card .vod-btn.primary').first().click(); + await win.waitForTimeout(500); + } + + const queueCountAfterAdd = await win.locator('#queueCount').innerText(); + + const queueRemove = win.locator('#queueList .remove').first(); + if (await queueRemove.count()) { + await queueRemove.click(); + await win.waitForTimeout(300); + } + + await win.evaluate(() => { + window.showTab('clips'); + }); + + await win.fill('#clipUrl', ''); + await win.evaluate(async () => { + await window.downloadClip(); + }); + + const clipStatus = await win.locator('#clipStatus').innerText(); + + await win.evaluate(async () => { + await window.runPreflight(false); + await window.startCutting(); + await window.startMerging(); + }); + + const mergeButtonDisabled = await win.locator('#btnMerge').isDisabled(); + const preflightText = await win.locator('#preflightResult').innerText(); + const healthBadge = await win.locator('#healthBadge').innerText(); + + await app.close(); + + const failedGlobals = Object.entries(globals) + .filter(([, type]) => type !== 'function') + .map(([name, type]) => `${name}=${type}`); + + const summary = { + failedGlobals, + hasTempStreamer: hasTempStreamer.includes(randomName), + vodCount, + queueCountAfterAdd, + clipStatus, + mergeButtonDisabled, + preflightText, + healthBadge, + issues + }; + + console.log(JSON.stringify(summary, null, 2)); + + const hasFailure = + failedGlobals.length > 0 || + !summary.hasTempStreamer || + summary.vodCount < 1 || + !(summary.clipStatus.includes('Bitte URL eingeben') || summary.clipStatus.includes('Please enter a URL')) || + !summary.mergeButtonDisabled || + !summary.preflightText || + !summary.healthBadge || + summary.issues.length > 0; + + process.exit(hasFailure ? 1 : 0); +} + +run().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/src/index.html b/src/index.html new file mode 100644 index 0000000..aaff500 --- /dev/null +++ b/src/index.html @@ -0,0 +1,846 @@ + + + + + + + Twitch VOD Manager + + + +
+ Neue Version verfügbar! + + +
+ + + + + + + + + + + + + + + +
+ + +
+
+

VODs

+
+ + +
+
+ +
+ +
+ +
+ + + + + + +
+ +
+
+ +

Keine VODs

+

Wahle einen Streamer aus der Liste oder fuge einen neuen hinzu.

+
+
+
+ + +
+
+

Twitch Clip-Download

+ + +
+
+ +
+

Info

+

+ Unterstutzte Formate: + - https://clips.twitch.tv/ClipName + - https://www.twitch.tv/streamer/clip/ClipName + + Clips werden im Download-Ordner unter "Clips/StreamerName/" gespeichert. +

+
+
+ + +
+
+
+

Video auswahlen

+
+ + +
+
+ +
+
+ +

Video auswaehlen um Vorschau zu sehen

+
+
+ +
+
+ Dauer + --:--:-- +
+
+ Aufloesung + ----x---- +
+
+ FPS + -- +
+
+ Auswahl + --:--:-- +
+
+ +
+
+
+
+
+ +
+
+ + +
+
+ + +
+
+
+ +
+
+
+
+
0%
+
+ +
+ +
+
+
+ + +
+
+
+

Videos zusammenfugen

+

+ Wahle mehrere Videos aus um sie zu einem Video zusammenzufugen. + Die Reihenfolge kann per Drag & Drop geandert werden. +

+ +
+ +
+
+ +

Keine Videos ausgewahlt

+
+
+ +
+
+
+
+
0%
+
+ +
+ +
+
+
+ + +
+
+
+

Archiv-Statistik

+
+ + +
+
+

Aggregiert ueber den Download-Ordner. Live-Aufnahmen liegen unter {streamer}/live/, VOD-Downloads direkt unter {streamer}/. Lade-Zeit skaliert mit der Anzahl Dateien.

+
+ +
+

Uebersicht

+
+
+ +
+

Top Streamer (nach Groesse)

+
+
+ +
+

Aktivitaet (letzte 30 Tage)

+
+
+ +
+

Aufnahme-Groessen-Verteilung

+
+
+
+ + +
+
+

Archiv durchsuchen

+

Suche nach Dateinamen, Streamern oder Datum-Strings. Treffer zeigen Recordings (Live + VOD); zugehoerige Chat- und Events-Dateien werden als Companion-Buttons angeboten.

+ +
+
+
+
+
+
+ + +
+
+

Design

+
+ + +
+
+ +
+ + +
+ +
+
+ +
+

Twitch API

+

+ Du brauchst eine Client-ID und ein Client-Secret von Twitch. + dev.twitch.tv/console/apps +

+
+ + +
+
+ + +
+ +
+ +
+

Download-Einstellungen

+
+ +
+ + + +
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + + + + + + + + + + + +
+
+ + +
+
+
+ + +
+
+ + + +
+
+ + + + + + + + +
+
Platzhalter: {title} {id} {channel} {date} {part} {part_padded} {trim_start} {trim_end} {trim_length} {date_custom="yyyy-MM-dd"}
+
Template-Check: OK
+
+
+ +
+

Updates

+

Version: v1.0.1

+ +
+ +
+
+

System-Check

+ System: Unbekannt +
+
+ + +
+
Noch kein Check ausgefuhrt.
+
+ +
+

Live Debug-Log

+
+ + + +
+
Lade...
+
+ +
+
+

Storage

+ +
+

Disk-Verbrauch pro Streamer im aktuellen Download-Ordner. Live-Aufnahmen werden separat ausgewiesen.

+
+
+ +
+

Auto-Cleanup

+

Aufnahmen aelter als X Tage automatisch archivieren oder loeschen. Schiebt Sidecar-Chat-Dateien (.chat.json/.chat.jsonl) mit der Aufnahme.

+ +
+ + + +
+
+ + +
+
+
+ +
+

Discord-Webhook

+

Sende Benachrichtigungen an einen Discord-Channel via Webhook — nuetzlich fuer Multi-Device-Setups oder eine dedizierte Archiv-Maschine.

+
+ + +
+
+ + + + +
+
+ +
+

Auto-VOD-Download

+

Streamer mit aktiviertem VOD-Toggle werden in dem hier festgelegten Intervall auf neue Twitch-VODs geprueft. Neue VODs innerhalb des Alters-Fensters werden automatisch zur Download-Queue hinzugefuegt.

+
+ + + + +
+
+ + + +
+
+ +
+

Sicherung & Wartung

+

Konfiguration sichern, auf einem anderen Geraet wiederherstellen, oder die Liste der bereits heruntergeladenen VODs zuruecksetzen.

+
+ + + +
+
+ +
+

Runtime Metrics

+
+ + + +
+
Lade...
+
+
+
+ +
+
+ + Nicht verbunden +
+ + +
+
+
+ + + + + + + + + + + + + + + + + + + diff --git a/src/main.ts b/src/main.ts new file mode 100644 index 0000000..d403960 --- /dev/null +++ b/src/main.ts @@ -0,0 +1,7420 @@ +import { app, BrowserWindow, ipcMain, dialog, shell, nativeTheme, Notification } from 'electron'; +import * as path from 'path'; +import * as fs from 'fs'; +import { spawn, ChildProcess, execSync, spawnSync } from 'child_process'; +import { connect as tlsConnect, TLSSocket } from 'node:tls'; +import axios from 'axios'; +import { autoUpdater } from 'electron-updater'; +import { compareUpdateVersions, isNewerUpdateVersion, normalizeUpdateVersion } from './main/domain/update-version-utils'; +import { writeFileAtomicSync } from './main/infra/fs-atomic'; +import { parseDuration, formatDuration, formatDurationDashed } from './main/infra/duration'; +import { + sanitizeFilenamePart, + formatTwitchDurationFromSeconds, + formatDateWithPattern, + getMergeGroupPhaseText as getMergeGroupPhaseTextCore, +} from './main/infra/format-helpers'; +import { tBackend as tBackendCore, type BackendMessageKey } from './main/domain/i18n-backend'; +import type { DbHandle } from './main/infra/db'; +import { + normalizeLogin, + normalizeAutoRecordPollSeconds, + normalizeAutoRecordList, + normalizeStreamlinkQuality, + normalizeFilenameTemplate, + normalizeMetadataCacheMinutes, + normalizePerformanceMode, + isPlainObject, + VALID_STREAMLINK_QUALITIES, + DEFAULT_METADATA_CACHE_MINUTES, + DEFAULT_PERFORMANCE_MODE, + type PerformanceMode, +} from './main/domain/config-normalize'; +import { CustomClip, MergeGroupItem, MergeGroup, QueueItem, DownloadProgress, DownloadResult } from './types'; +import { + setDebugLogFn, initToolDirs, + getStreamlinkPath, getStreamlinkCommand, getFFmpegPath, getFFprobePath, + refreshBundledToolPaths, ensureStreamlinkInstalled, ensureFfmpegInstalled, + canExecute, canExecuteCommand, + cacheVerifiedStreamlinkCommand, isVerifiedStreamlinkCommand, + cacheVerifiedFfmpegCommands, isVerifiedFfmpegCommands, + invalidateVerifiedToolCaches +} from './tools'; + +// ========================================== +// CONFIG & CONSTANTS +// ========================================== +const APP_VERSION = app.getVersion(); +const GITHUB_REPO_OWNER = 'Sucukdeluxe'; +const GITHUB_REPO_NAME = 'Twitch-VOD-Manager'; +const GITHUB_RELEASES_API_LATEST_URL = 'https://api.github.com/repos/Sucukdeluxe/Twitch-VOD-Manager/releases/latest'; +const GITHUB_RELEASES_DOWNLOAD_BASE_URL = 'https://github.com/Sucukdeluxe/Twitch-VOD-Manager/releases/download'; + +// Paths +const APPDATA_DIR = path.join(process.env.PROGRAMDATA || 'C:\\ProgramData', 'Twitch_VOD_Manager'); +const CONFIG_FILE = path.join(APPDATA_DIR, 'config.json'); +const QUEUE_FILE = path.join(APPDATA_DIR, 'download_queue.json'); +const DEBUG_LOG_FILE = path.join(APPDATA_DIR, 'debug.log'); +const TOOLS_DIR = path.join(APPDATA_DIR, 'tools'); +const TOOLS_STREAMLINK_DIR = path.join(TOOLS_DIR, 'streamlink'); +const TOOLS_FFMPEG_DIR = path.join(TOOLS_DIR, 'ffmpeg'); +const DEFAULT_DOWNLOAD_PATH = path.join(app.getPath('desktop'), 'Twitch_VODs'); +const DEFAULT_FILENAME_TEMPLATE_VOD = '{title}.mp4'; +const DEFAULT_FILENAME_TEMPLATE_PARTS = '{date}_Part{part_padded}.mp4'; +const DEFAULT_FILENAME_TEMPLATE_CLIP = '{date}_{part}.mp4'; +// DEFAULT_METADATA_CACHE_MINUTES + DEFAULT_PERFORMANCE_MODE kommen aus +// ./main/domain/config-normalize (Single-Source-Of-Truth, vermeidet +// Drift wenn man eine der Defaults aendert). +const QUEUE_SAVE_DEBOUNCE_MS = 250; +const MIN_FREE_DISK_BYTES = 128 * 1024 * 1024; +const DEBUG_LOG_FLUSH_INTERVAL_MS = 1000; +const DEBUG_LOG_BUFFER_FLUSH_LINES = 48; +const DEBUG_LOG_READ_TAIL_BYTES = 512 * 1024; +const DEBUG_LOG_MAX_BYTES = 8 * 1024 * 1024; +const DEBUG_LOG_TRIM_TO_BYTES = 4 * 1024 * 1024; +const AUTO_UPDATE_CHECK_INTERVAL_MS = 10 * 60 * 1000; +const AUTO_UPDATE_STARTUP_CHECK_DELAY_MS = 5000; +const AUTO_UPDATE_MIN_CHECK_GAP_MS = 45 * 1000; +const AUTO_UPDATE_AUTO_DOWNLOAD = false; +const AUTO_UPDATE_CHECK_TIMEOUT_MS = 30 * 1000; +const CACHE_CLEANUP_INTERVAL_MS = 60 * 1000; +const MAX_LOGIN_TO_USER_ID_CACHE_ENTRIES = 4096; +const MAX_VOD_LIST_CACHE_ENTRIES = 512; +const MAX_CLIP_INFO_CACHE_ENTRIES = 4096; + +// Timeouts +const API_TIMEOUT = 10000; +const DEFAULT_RETRY_DELAY_SECONDS = 5; +const MIN_FILE_BYTES = 256 * 1024; +const TWITCH_WEB_CLIENT_ID = 'kimne78kx3ncx6brgo4mv6wki5h1ko'; + +type RetryErrorClass = 'network' | 'rate_limit' | 'auth' | 'tooling' | 'integrity' | 'io' | 'validation' | 'unknown'; +type UpdateCheckSource = 'startup' | 'interval' | 'manual'; +type UpdateDownloadSource = 'auto' | 'manual'; + +function getMergeGroupPhaseText(phase: string): string { + return getMergeGroupPhaseTextCore(phase, config?.language ?? 'de'); +} + +// ========================================== +// BACKEND I18N +// ========================================== +// Backend-Messages sind in src/main/domain/i18n-backend.ts. +// tBackend bleibt als 2-Arg-Adapter hier — pure Variante uebernimmt language +// als 3. Parameter, der hier aus config.language injected wird. +function tBackend(key: BackendMessageKey, params?: Record): string { + return tBackendCore(key, params, config?.language ?? 'de'); +} + +// Ensure directories exist +if (!fs.existsSync(APPDATA_DIR)) { + fs.mkdirSync(APPDATA_DIR, { recursive: true }); +} + +// ========================================== +// INTERFACES +// ========================================== +interface Config { + client_id: string; + client_secret: string; + download_path: string; + streamers: string[]; + theme: string; + download_mode: 'parts' | 'full'; + part_minutes: number; + language: 'de' | 'en'; + filename_template_vod: string; + filename_template_parts: string; + filename_template_clip: string; + smart_queue_scheduler: boolean; + performance_mode: PerformanceMode; + prevent_duplicate_downloads: boolean; + persist_queue_on_restart: boolean; + metadata_cache_minutes: number; + parallel_downloads: number; + auto_resume_queue_on_startup: boolean; + downloaded_vod_ids: string[]; + streamlink_quality: string; + notify_on_each_completion: boolean; + streamlink_disable_ads: boolean; + auto_record_streamers: string[]; + auto_record_poll_seconds: number; + download_chat_replay: boolean; + capture_live_chat: boolean; + discord_webhook_url: string; + discord_notify_live_start: boolean; + discord_notify_live_end: boolean; + discord_notify_vod_complete: boolean; + discord_notify_vod_auto_queued: boolean; + auto_cleanup_enabled: boolean; + auto_cleanup_days: number; + auto_cleanup_target: 'live_only' | 'all'; + auto_cleanup_action: 'delete' | 'archive'; + log_stream_events: boolean; + auto_vod_download_streamers: string[]; + auto_vod_download_poll_minutes: number; + auto_vod_max_age_hours: number; + auto_resume_live_recording: boolean; + auto_merge_resumed_parts: boolean; + delete_parts_after_merge: boolean; +} + +interface RuntimeMetrics { + cacheHits: number; + cacheMisses: number; + duplicateSkips: number; + retriesScheduled: number; + retriesExhausted: number; + integrityFailures: number; + downloadsStarted: number; + downloadsCompleted: number; + downloadsFailed: number; + downloadedBytesTotal: number; + lastSpeedBytesPerSec: number; + avgSpeedBytesPerSec: number; + activeItemId: string | null; + activeItemTitle: string | null; + lastErrorClass: RetryErrorClass | null; + lastRetryDelaySeconds: number; +} + +interface RuntimeMetricsSnapshot extends RuntimeMetrics { + timestamp: string; + queue: { + pending: number; + downloading: number; + paused: number; + completed: number; + error: number; + total: number; + }; + caches: { + loginToUserId: number; + vodList: number; + clipInfo: number; + }; + config: { + performanceMode: PerformanceMode; + smartScheduler: boolean; + metadataCacheMinutes: number; + duplicatePrevention: boolean; + }; +} + +interface CacheEntry { + value: T; + expiresAt: number; +} + +interface VOD { + id: string; + title: string; + created_at: string; + duration: string; + thumbnail_url: string; + url: string; + view_count: number; + stream_id: string; +} + +interface PreflightChecks { + internet: boolean; + streamlink: boolean; + ffmpeg: boolean; + ffprobe: boolean; + downloadPathWritable: boolean; +} + +interface PreflightResult { + ok: boolean; + autoFixApplied: boolean; + checks: PreflightChecks; + messages: string[]; + timestamp: string; +} + +interface VideoInfo { + duration: number; + width: number; + height: number; + fps: number; +} + +interface ReleaseUpdateInfo { + tagName?: string; + version?: string; + releaseDate?: string; + releaseName?: string; + releaseNotes?: string; +} + +// ========================================== +// CONFIG MANAGEMENT +// ========================================== +const defaultConfig: Config = { + client_id: '', + client_secret: '', + download_path: DEFAULT_DOWNLOAD_PATH, + streamers: [], + theme: 'twitch', + download_mode: 'full', + part_minutes: 120, + language: 'en', + filename_template_vod: DEFAULT_FILENAME_TEMPLATE_VOD, + filename_template_parts: DEFAULT_FILENAME_TEMPLATE_PARTS, + filename_template_clip: DEFAULT_FILENAME_TEMPLATE_CLIP, + smart_queue_scheduler: true, + performance_mode: DEFAULT_PERFORMANCE_MODE, + prevent_duplicate_downloads: true, + persist_queue_on_restart: true, + metadata_cache_minutes: DEFAULT_METADATA_CACHE_MINUTES, + parallel_downloads: 1, + auto_resume_queue_on_startup: false, + downloaded_vod_ids: [], + streamlink_quality: 'best', + notify_on_each_completion: false, + streamlink_disable_ads: true, + auto_record_streamers: [], + auto_record_poll_seconds: 90, + download_chat_replay: false, + capture_live_chat: false, + discord_webhook_url: '', + discord_notify_live_start: false, + discord_notify_live_end: false, + discord_notify_vod_complete: false, + discord_notify_vod_auto_queued: false, + auto_cleanup_enabled: false, + auto_cleanup_days: 30, + auto_cleanup_target: 'live_only', + auto_cleanup_action: 'archive', + log_stream_events: true, + auto_vod_download_streamers: [], + auto_vod_download_poll_minutes: 15, + auto_vod_max_age_hours: 24, + auto_resume_live_recording: true, + auto_merge_resumed_parts: false, + delete_parts_after_merge: false +}; + +// normalize* helpers + VALID_STREAMLINK_QUALITIES + isPlainObject + normalizeLogin +// kommen aus ./main/domain/config-normalize. getStreamlinkStreamArg bleibt +// hier, da es config liest. +function getStreamlinkStreamArg(): string { + const choice = normalizeStreamlinkQuality(config.streamlink_quality); + if (choice === 'best') return 'best'; + return `${choice},best`; +} + +function normalizeConfigTemplates(input: Config): Config { + // downloaded_vod_ids is bounded so a long-running app doesn't accumulate + // an unbounded list across years of downloads. Latest entries kept. + const DOWNLOADED_IDS_MAX = 4096; + const rawIds = Array.isArray(input.downloaded_vod_ids) ? input.downloaded_vod_ids : []; + const cleanIds = rawIds.filter((id): id is string => typeof id === 'string' && id.length > 0); + const trimmedIds = cleanIds.length > DOWNLOADED_IDS_MAX + ? cleanIds.slice(cleanIds.length - DOWNLOADED_IDS_MAX) + : cleanIds; + + return { + ...input, + filename_template_vod: normalizeFilenameTemplate(input.filename_template_vod, DEFAULT_FILENAME_TEMPLATE_VOD), + filename_template_parts: normalizeFilenameTemplate(input.filename_template_parts, DEFAULT_FILENAME_TEMPLATE_PARTS), + filename_template_clip: normalizeFilenameTemplate(input.filename_template_clip, DEFAULT_FILENAME_TEMPLATE_CLIP), + smart_queue_scheduler: input.smart_queue_scheduler !== false, + performance_mode: normalizePerformanceMode(input.performance_mode), + prevent_duplicate_downloads: input.prevent_duplicate_downloads !== false, + persist_queue_on_restart: input.persist_queue_on_restart !== false, + metadata_cache_minutes: normalizeMetadataCacheMinutes(input.metadata_cache_minutes), + auto_resume_queue_on_startup: input.auto_resume_queue_on_startup === true, + downloaded_vod_ids: trimmedIds, + streamlink_quality: normalizeStreamlinkQuality(input.streamlink_quality), + notify_on_each_completion: input.notify_on_each_completion === true, + // Default-true on first launch (most users hit this), but respect + // an explicit `false` from the loaded config. + streamlink_disable_ads: input.streamlink_disable_ads !== false, + auto_record_streamers: normalizeAutoRecordList(input.auto_record_streamers), + auto_record_poll_seconds: normalizeAutoRecordPollSeconds(input.auto_record_poll_seconds), + download_chat_replay: input.download_chat_replay === true, + capture_live_chat: input.capture_live_chat === true, + // Webhook URL is stored but never validated server-side — invalid + // URLs just cause the post to fail (logged, non-fatal). Users with + // accidental whitespace are saved by the .trim(). + discord_webhook_url: typeof input.discord_webhook_url === 'string' ? input.discord_webhook_url.trim() : '', + discord_notify_live_start: input.discord_notify_live_start === true, + discord_notify_live_end: input.discord_notify_live_end === true, + discord_notify_vod_complete: input.discord_notify_vod_complete === true, + discord_notify_vod_auto_queued: input.discord_notify_vod_auto_queued === true, + auto_cleanup_enabled: input.auto_cleanup_enabled === true, + auto_cleanup_days: (() => { + const n = Number(input.auto_cleanup_days); + if (!Number.isFinite(n) || n < 1) return 30; + return Math.min(3650, Math.floor(n)); + })(), + auto_cleanup_target: input.auto_cleanup_target === 'all' ? 'all' : 'live_only', + auto_cleanup_action: input.auto_cleanup_action === 'delete' ? 'delete' : 'archive', + log_stream_events: input.log_stream_events !== false, + auto_vod_download_streamers: normalizeAutoRecordList(input.auto_vod_download_streamers), + auto_vod_download_poll_minutes: (() => { + const n = Number(input.auto_vod_download_poll_minutes); + if (!Number.isFinite(n)) return 15; + return Math.max(5, Math.min(360, Math.floor(n))); + })(), + auto_vod_max_age_hours: (() => { + const n = Number(input.auto_vod_max_age_hours); + if (!Number.isFinite(n)) return 24; + return Math.max(1, Math.min(720, Math.floor(n))); + })(), + auto_resume_live_recording: input.auto_resume_live_recording !== false, + auto_merge_resumed_parts: input.auto_merge_resumed_parts === true, + delete_parts_after_merge: input.delete_parts_after_merge === true + }; +} + +function recordDownloadedVodId(vodId: string): void { + if (!vodId) return; + if (!Array.isArray(config.downloaded_vod_ids)) config.downloaded_vod_ids = []; + if (config.downloaded_vod_ids.includes(vodId)) return; + config.downloaded_vod_ids.push(vodId); + // Cap to keep config size bounded — drop oldest first. + const DOWNLOADED_IDS_MAX = 4096; + if (config.downloaded_vod_ids.length > DOWNLOADED_IDS_MAX) { + config.downloaded_vod_ids = config.downloaded_vod_ids.slice( + config.downloaded_vod_ids.length - DOWNLOADED_IDS_MAX + ); + } + saveConfig(config); +} + +function loadConfig(): Config { + try { + if (fs.existsSync(CONFIG_FILE)) { + const data = fs.readFileSync(CONFIG_FILE, 'utf-8'); + const parsed = JSON.parse(data); + if (!isPlainObject(parsed)) { + console.error('Config file is not a JSON object — using defaults'); + return normalizeConfigTemplates(defaultConfig); + } + return normalizeConfigTemplates({ ...defaultConfig, ...parsed }); + } + } catch (e) { + console.error('Error loading config:', e); + } + return normalizeConfigTemplates(defaultConfig); +} + +function saveConfig(config: Config): void { + try { + writeFileAtomicSync(CONFIG_FILE, JSON.stringify(config, null, 2)); + } catch (e) { + console.error('Error saving config:', e); + } +} + +// ========================================== +// QUEUE MANAGEMENT +// ========================================== +const VALID_QUEUE_STATUSES: ReadonlyArray = ['pending', 'downloading', 'paused', 'completed', 'error']; +const VALID_MERGE_PHASES: ReadonlyArray = ['downloading', 'merging', 'splitting', 'cleanup', 'done']; + +function isValidQueueStatus(status: unknown): status is QueueItem['status'] { + return typeof status === 'string' && (VALID_QUEUE_STATUSES as readonly string[]).includes(status); +} + +function sanitizeMergeGroup(raw: unknown): MergeGroup | undefined { + if (!isPlainObject(raw)) return undefined; + if (!Array.isArray(raw.items) || raw.items.length < 2) return undefined; + + const items: MergeGroupItem[] = []; + for (const mi of raw.items) { + if (!isPlainObject(mi)) continue; + if (typeof mi.url !== 'string' || typeof mi.title !== 'string' + || typeof mi.date !== 'string' || typeof mi.streamer !== 'string' + || typeof mi.duration_str !== 'string') continue; + items.push({ url: mi.url, title: mi.title, date: mi.date, streamer: mi.streamer, duration_str: mi.duration_str }); + } + if (items.length < 2) return undefined; + + const phase: MergeGroup['mergePhase'] = (VALID_MERGE_PHASES as readonly string[]).includes(String(raw.mergePhase)) + ? raw.mergePhase as MergeGroup['mergePhase'] + : 'downloading'; + + const downloadedFiles: Record = {}; + if (isPlainObject(raw.downloadedFiles)) { + for (const [k, v] of Object.entries(raw.downloadedFiles)) { + const idx = Number(k); + if (Number.isFinite(idx) && typeof v === 'string') downloadedFiles[idx] = v; + } + } + + return { + items, + mergePhase: phase, + currentItemIndex: typeof raw.currentItemIndex === 'number' && Number.isFinite(raw.currentItemIndex) ? raw.currentItemIndex : 0, + downloadedFiles, + mergedFile: typeof raw.mergedFile === 'string' ? raw.mergedFile : undefined, + splitFiles: Array.isArray(raw.splitFiles) ? raw.splitFiles.filter((f): f is string => typeof f === 'string') : undefined, + totalDurationSec: typeof raw.totalDurationSec === 'number' && Number.isFinite(raw.totalDurationSec) ? raw.totalDurationSec : undefined + }; +} + +function sanitizeCustomClip(raw: unknown): CustomClip | undefined { + if (!isPlainObject(raw)) return undefined; + const startSec = Number(raw.startSec); + const durationSec = Number(raw.durationSec); + const startPart = Number(raw.startPart); + if (!Number.isFinite(startSec) || !Number.isFinite(durationSec) || durationSec <= 0 || !Number.isFinite(startPart)) return undefined; + + const filenameFormat = raw.filenameFormat; + if (filenameFormat !== 'simple' && filenameFormat !== 'timestamp' && filenameFormat !== 'template' && filenameFormat !== 'parts') return undefined; + + return { + startSec: Math.max(0, startSec), + durationSec: Math.max(1, durationSec), + startPart: Math.max(1, Math.floor(startPart)), + filenameFormat, + filenameTemplate: typeof raw.filenameTemplate === 'string' ? raw.filenameTemplate : undefined + }; +} + +function sanitizeQueueItem(raw: unknown): QueueItem | null { + if (!isPlainObject(raw)) return null; + if (typeof raw.id !== 'string' || !raw.id) return null; + if (typeof raw.url !== 'string' || !raw.url) return null; + if (!isValidQueueStatus(raw.status)) return null; + + // 'downloading' on cold start is stale — no download is actually running + // and the user expects to resume from start, so map it back to 'pending' + const isStaleDownloading = raw.status === 'downloading'; + const finalStatus: QueueItem['status'] = isStaleDownloading ? 'pending' : raw.status; + + const progressNum = Number(raw.progress); + const safeProgress = Number.isFinite(progressNum) ? Math.max(0, Math.min(100, progressNum)) : 0; + + const item: QueueItem = { + id: raw.id, + url: raw.url, + title: typeof raw.title === 'string' ? raw.title : '', + date: typeof raw.date === 'string' ? raw.date : '', + streamer: typeof raw.streamer === 'string' ? raw.streamer : '', + duration_str: typeof raw.duration_str === 'string' ? raw.duration_str : '0s', + status: finalStatus, + progress: isStaleDownloading ? 0 : safeProgress + }; + + if (typeof raw.currentPart === 'number' && Number.isFinite(raw.currentPart)) item.currentPart = raw.currentPart; + if (typeof raw.totalParts === 'number' && Number.isFinite(raw.totalParts)) item.totalParts = raw.totalParts; + if (typeof raw.speed === 'string') item.speed = raw.speed; + if (typeof raw.eta === 'string') item.eta = raw.eta; + if (typeof raw.last_error === 'string') item.last_error = raw.last_error; + if (typeof raw.downloadedBytes === 'number' && Number.isFinite(raw.downloadedBytes)) item.downloadedBytes = raw.downloadedBytes; + if (typeof raw.totalBytes === 'number' && Number.isFinite(raw.totalBytes)) item.totalBytes = raw.totalBytes; + + if (Array.isArray(raw.outputFiles)) { + const files = raw.outputFiles.filter((f): f is string => typeof f === 'string' && f.length > 0); + if (files.length > 0) item.outputFiles = files; + } + + if (raw.isLive === true) { + item.isLive = true; + } + + const customClip = sanitizeCustomClip(raw.customClip); + if (customClip) item.customClip = customClip; + + const mergeGroup = sanitizeMergeGroup(raw.mergeGroup); + if (mergeGroup) item.mergeGroup = mergeGroup; + + return item; +} + +function loadQueue(): QueueItem[] { + if (config.persist_queue_on_restart === false) { + return []; + } + + try { + if (fs.existsSync(QUEUE_FILE)) { + const data = fs.readFileSync(QUEUE_FILE, 'utf-8'); + const parsed = JSON.parse(data); + if (!Array.isArray(parsed)) { + console.error('Queue file is not a JSON array — ignoring'); + return []; + } + + const items: QueueItem[] = []; + let droppedCount = 0; + for (const raw of parsed) { + const sanitized = sanitizeQueueItem(raw); + if (sanitized) items.push(sanitized); + else droppedCount++; + } + if (droppedCount > 0) { + console.error(`loadQueue: dropped ${droppedCount} invalid queue item(s)`); + } + return items; + } + } catch (e) { + console.error('Error loading queue:', e); + } + return []; +} + +let queueSaveTimer: NodeJS.Timeout | null = null; +let pendingQueueSnapshot: QueueItem[] | null = null; + +function clearQueueFileFromDisk(): void { + try { + if (fs.existsSync(QUEUE_FILE)) { + fs.unlinkSync(QUEUE_FILE); + } + } catch (e) { + console.error('Error clearing queue file:', e); + } +} + +function writeQueueToDisk(queue: QueueItem[]): void { + if (config.persist_queue_on_restart === false) { + clearQueueFileFromDisk(); + return; + } + + try { + writeFileAtomicSync(QUEUE_FILE, JSON.stringify(queue, null, 2)); + } catch (e) { + console.error('Error saving queue:', e); + } +} + +function saveQueue(queue: QueueItem[], force = false): void { + if (config.persist_queue_on_restart === false) { + pendingQueueSnapshot = null; + if (queueSaveTimer) { + clearTimeout(queueSaveTimer); + queueSaveTimer = null; + } + clearQueueFileFromDisk(); + return; + } + + pendingQueueSnapshot = queue; + + if (force) { + if (queueSaveTimer) { + clearTimeout(queueSaveTimer); + queueSaveTimer = null; + } + + writeQueueToDisk(pendingQueueSnapshot); + pendingQueueSnapshot = null; + return; + } + + if (queueSaveTimer) { + return; + } + + queueSaveTimer = setTimeout(() => { + queueSaveTimer = null; + if (pendingQueueSnapshot) { + writeQueueToDisk(pendingQueueSnapshot); + pendingQueueSnapshot = null; + } + }, QUEUE_SAVE_DEBOUNCE_MS); +} + +function flushQueueSave(): void { + if (pendingQueueSnapshot) { + saveQueue(pendingQueueSnapshot, true); + } else { + saveQueue(downloadQueue, true); + } +} + +// ========================================== +// GLOBAL STATE +// ========================================== +let mainWindow: BrowserWindow | null = null; +let config = loadConfig(); +let accessToken: string | null = null; +let downloadQueue: QueueItem[] = loadQueue(); +let queueIdCounter = 0; +let lastQueueBroadcastFingerprint = ''; +let isDownloading = false; +// Process handle for the standalone video editor pipeline (cutter / merger / +// splitter). Queue downloads track their own children via activeDownloads, +// and clip downloads via activeClipProcesses. Keeping these separate +// prevents cancel-download from killing an unrelated cutter ffmpeg. +let currentEditorProcess: ChildProcess | null = null; +// Per-item cancellation lives in `cancelledItemIds`. The previous global +// `currentDownloadCancelled` flag was redundant once pause/cancel/remove +// started iterating activeDownloads and adding each item to that Set; it +// was removed in the 4.5.27 cleanup. +let pauseRequested = false; +let activeQueueItemId: string | null = null; +let downloadStartTime = 0; +let downloadedBytes = 0; +// Per-item tracking for parallel downloads +const activeDownloads = new Map(); +const cancelledItemIds = new Set(); +// userId -> login reverse map. Bounded via Map insertion-order eviction so +// a long-running session doesn't grow it unbounded across thousands of +// streamer lookups. Values are short (~20 char each) but accumulate. +const USER_ID_LOGIN_CACHE_MAX = 4096; +const userIdLoginCache = new Map(); +function setUserIdLogin(userId: string, login: string): void { + if (!userId || !login) return; + if (userIdLoginCache.has(userId)) { + userIdLoginCache.delete(userId); + } + userIdLoginCache.set(userId, login); + while (userIdLoginCache.size > USER_ID_LOGIN_CACHE_MAX) { + const oldest = userIdLoginCache.keys().next().value as string | undefined; + if (!oldest) break; + userIdLoginCache.delete(oldest); + } +} +const loginToUserIdCache = new Map>(); +const vodListCache = new Map>(); +const clipInfoCache = new Map>(); +const inFlightUserIdRequests = new Map>(); +const inFlightVodRequests = new Map>(); +const inFlightClipRequests = new Map>(); +let cacheCleanupTimer: NodeJS.Timeout | null = null; +const runtimeMetrics: RuntimeMetrics = { + cacheHits: 0, + cacheMisses: 0, + duplicateSkips: 0, + retriesScheduled: 0, + retriesExhausted: 0, + integrityFailures: 0, + downloadsStarted: 0, + downloadsCompleted: 0, + downloadsFailed: 0, + downloadedBytesTotal: 0, + lastSpeedBytesPerSec: 0, + avgSpeedBytesPerSec: 0, + activeItemId: null, + activeItemTitle: null, + lastErrorClass: null, + lastRetryDelaySeconds: 0 +}; +let debugLogFlushTimer: NodeJS.Timeout | null = null; +let pendingDebugLogLines: string[] = []; +let autoUpdaterInitialized = false; +let autoUpdateCheckTimer: NodeJS.Timeout | null = null; +let autoUpdateStartupTimer: NodeJS.Timeout | null = null; +let autoUpdateCheckInProgress = false; +let autoUpdateReadyToInstall = false; +let autoUpdateDownloadInProgress = false; +let lastAutoUpdateCheckAt = 0; +let latestKnownUpdateVersion: string | null = null; +let downloadedUpdateVersion: string | null = null; +let latestReleaseUpdateInfo: ReleaseUpdateInfo | null = null; +let twitchLoginInFlight: Promise | null = null; + +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +function isDownloadPathWritable(targetPath: string): boolean { + try { + fs.mkdirSync(targetPath, { recursive: true }); + const probeFile = path.join(targetPath, `.write_test_${Date.now()}.tmp`); + fs.writeFileSync(probeFile, 'ok'); + fs.unlinkSync(probeFile); + return true; + } catch { + return false; + } +} + +async function hasInternetConnection(): Promise { + try { + const res = await axios.get('https://id.twitch.tv/oauth2/validate', { + timeout: 5000, + validateStatus: () => true + }); + return res.status > 0; + } catch { + return false; + } +} + +async function runPreflight(autoFix = false): Promise { + appendDebugLog('preflight-start', { autoFix }); + + refreshBundledToolPaths(); + + const checks: PreflightChecks = { + internet: await hasInternetConnection(), + streamlink: false, + ffmpeg: false, + ffprobe: false, + downloadPathWritable: isDownloadPathWritable(config.download_path) + }; + + if (autoFix) { + await ensureStreamlinkInstalled(); + await ensureFfmpegInstalled(); + refreshBundledToolPaths(true); + } + + const streamlinkCmd = getStreamlinkCommand(); + checks.streamlink = canExecuteCommand(streamlinkCmd.command, [...streamlinkCmd.prefixArgs, '--version']); + if (checks.streamlink) { + cacheVerifiedStreamlinkCommand(streamlinkCmd.command, [...streamlinkCmd.prefixArgs, '--version']); + } + + const ffmpegPath = getFFmpegPath(); + const ffprobePath = getFFprobePath(); + checks.ffmpeg = canExecuteCommand(ffmpegPath, ['-version']); + checks.ffprobe = canExecuteCommand(ffprobePath, ['-version']); + if (checks.ffmpeg && checks.ffprobe) { + cacheVerifiedFfmpegCommands(ffmpegPath, ffprobePath); + } + + const messages: string[] = []; + if (!checks.internet) messages.push(tBackend('preflightNoInternet')); + if (!checks.streamlink) messages.push(tBackend('preflightStreamlinkMissing')); + if (!checks.ffmpeg) messages.push(tBackend('preflightFfmpegMissing')); + if (!checks.ffprobe) messages.push(tBackend('preflightFfprobeMissing')); + if (!checks.downloadPathWritable) messages.push(tBackend('preflightDownloadPathNotWritable')); + + const result: PreflightResult = { + ok: messages.length === 0, + autoFixApplied: autoFix, + checks, + messages, + timestamp: new Date().toISOString() + }; + + appendDebugLog('preflight-finished', result); + return result; +} + +function flushPendingDebugLogLines(): void { + if (!pendingDebugLogLines.length) { + return; + } + + try { + const payload = pendingDebugLogLines.join(''); + pendingDebugLogLines = []; + fs.appendFileSync(DEBUG_LOG_FILE, payload); + trimDebugLogFileIfNeeded(); + } catch { + // ignore debug log errors + } +} + +function trimDebugLogFileIfNeeded(): void { + try { + if (!fs.existsSync(DEBUG_LOG_FILE)) { + return; + } + + const stats = fs.statSync(DEBUG_LOG_FILE); + if (stats.size <= DEBUG_LOG_MAX_BYTES) { + return; + } + + const bytesToKeep = Math.min(DEBUG_LOG_TRIM_TO_BYTES, stats.size); + const startOffset = Math.max(0, stats.size - bytesToKeep); + const buffer = Buffer.allocUnsafe(bytesToKeep); + + let fileHandle: number | null = null; + try { + fileHandle = fs.openSync(DEBUG_LOG_FILE, 'r'); + fs.readSync(fileHandle, buffer, 0, bytesToKeep, startOffset); + } finally { + if (fileHandle !== null) { + fs.closeSync(fileHandle); + } + } + + const firstLineBreak = buffer.indexOf(0x0a); + const trimmed = firstLineBreak >= 0 && firstLineBreak + 1 < buffer.length + ? buffer.subarray(firstLineBreak + 1) + : buffer; + + fs.writeFileSync(DEBUG_LOG_FILE, trimmed); + } catch { + // ignore debug log errors + } +} + +function readDebugLogTailFromDisk(): string { + const stats = fs.statSync(DEBUG_LOG_FILE); + if (stats.size <= 0) { + return ''; + } + + const bytesToRead = Math.min(stats.size, DEBUG_LOG_READ_TAIL_BYTES); + if (bytesToRead === stats.size) { + return fs.readFileSync(DEBUG_LOG_FILE, 'utf-8'); + } + + const buffer = Buffer.allocUnsafe(bytesToRead); + let fileHandle: number | null = null; + try { + fileHandle = fs.openSync(DEBUG_LOG_FILE, 'r'); + fs.readSync(fileHandle, buffer, 0, bytesToRead, stats.size - bytesToRead); + } finally { + if (fileHandle !== null) { + fs.closeSync(fileHandle); + } + } + + const firstLineBreak = buffer.indexOf(0x0a); + const slice = firstLineBreak >= 0 && firstLineBreak + 1 < buffer.length + ? buffer.subarray(firstLineBreak + 1) + : buffer; + + return slice.toString('utf-8'); +} + +function startDebugLogFlushTimer(): void { + if (debugLogFlushTimer) { + return; + } + + debugLogFlushTimer = setInterval(() => { + flushPendingDebugLogLines(); + }, DEBUG_LOG_FLUSH_INTERVAL_MS); + + debugLogFlushTimer.unref?.(); +} + +function stopDebugLogFlushTimer(flush = true): void { + if (debugLogFlushTimer) { + clearInterval(debugLogFlushTimer); + debugLogFlushTimer = null; + } + + if (flush) { + flushPendingDebugLogLines(); + } +} + +function readDebugLog(lines = 200): string { + try { + flushPendingDebugLogLines(); + + if (!fs.existsSync(DEBUG_LOG_FILE)) { + return 'Debug-Log ist leer.'; + } + + const text = readDebugLogTailFromDisk(); + const rows = text.split(/\r?\n/).filter(Boolean); + return rows.slice(-lines).join('\n') || 'Debug-Log ist leer.'; + } catch (e) { + return `Debug-Log konnte nicht gelesen werden: ${String(e)}`; + } +} + +function appendDebugLog(message: string, details?: unknown): void { + try { + const ts = new Date().toISOString(); + const payload = details === undefined + ? '' + : ` | ${typeof details === 'string' ? details : JSON.stringify(details)}`; + + pendingDebugLogLines.push(`[${ts}] ${message}${payload}\n`); + + if (pendingDebugLogLines.length >= DEBUG_LOG_BUFFER_FLUSH_LINES) { + flushPendingDebugLogLines(); + } else { + startDebugLogFlushTimer(); + } + } catch { + // ignore debug log errors + } +} + +// Wire up tools module with debug logging and directory paths +setDebugLogFn(appendDebugLog); +initToolDirs(TOOLS_STREAMLINK_DIR, TOOLS_FFMPEG_DIR, () => app.getPath('temp')); + +const claimedFilenames = new Set(); +const itemClaimedFilenames = new Map>(); + +function ensureUniqueFilename(filePath: string, itemId: string | null = null): string { + const dir = path.dirname(filePath); + const ext = path.extname(filePath); + const base = path.basename(filePath, ext); + let candidate = filePath; + let counter = 0; + while (fs.existsSync(candidate) || claimedFilenames.has(candidate)) { + counter++; + candidate = path.join(dir, `${base}_${counter}${ext}`); + } + claimedFilenames.add(candidate); + if (itemId) { + let perItem = itemClaimedFilenames.get(itemId); + if (!perItem) { + perItem = new Set(); + itemClaimedFilenames.set(itemId, perItem); + } + perItem.add(candidate); + } + return candidate; +} + +function releaseClaimedFilenamesForItem(itemId: string): void { + const perItem = itemClaimedFilenames.get(itemId); + if (!perItem) return; + for (const f of perItem) claimedFilenames.delete(f); + itemClaimedFilenames.delete(itemId); +} + + +function formatSecondsWithPattern(totalSeconds: number, pattern: string): string { + const safe = Math.max(0, Math.floor(totalSeconds)); + const hours = Math.floor(safe / 3600); + const minutes = Math.floor((safe % 3600) / 60); + const seconds = safe % 60; + + const tokenMap: Record = { + HH: hours.toString().padStart(2, '0'), + H: hours.toString(), + hh: hours.toString().padStart(2, '0'), + h: hours.toString(), + mm: minutes.toString().padStart(2, '0'), + m: minutes.toString(), + ss: seconds.toString().padStart(2, '0'), + s: seconds.toString() + }; + + return pattern + .replace(/HH|H|hh|h|mm|m|ss|s/g, (token) => tokenMap[token] ?? token) + .replace(/\\(.)/g, '$1'); +} + +function parseVodId(url: string): string { + const match = url.match(/videos\/(\d+)/i); + return match?.[1] || ''; +} + +function isLikelyVodUrl(url: string): boolean { + return /twitch\.tv\/videos\/\d+/i.test(url || ''); +} + +function parseFrameRate(rawFrameRate: string | undefined): number { + const fallback = 30; + const value = (rawFrameRate || '').trim(); + if (!value) return fallback; + + if (/^\d+(\.\d+)?$/.test(value)) { + const numeric = Number(value); + return Number.isFinite(numeric) && numeric > 0 ? numeric : fallback; + } + + const ratio = value.match(/^(\d+(?:\.\d+)?)\/(\d+(?:\.\d+)?)$/); + if (!ratio) return fallback; + + const numerator = Number(ratio[1]); + const denominator = Number(ratio[2]); + if (!Number.isFinite(numerator) || !Number.isFinite(denominator) || denominator <= 0) { + return fallback; + } + + const fps = numerator / denominator; + return Number.isFinite(fps) && fps > 0 ? fps : fallback; +} + +interface ClipTemplateContext { + template: string; + title: string; + vodId: string; + channel: string; + date: Date; + part: number; + partPadded: string; + trimStartSec: number; + trimEndSec: number; + trimLengthSec: number; + fullLengthSec: number; +} + +function renderClipFilenameTemplate(context: ClipTemplateContext): string { + const baseDate = `${context.date.getDate().toString().padStart(2, '0')}.${(context.date.getMonth() + 1).toString().padStart(2, '0')}.${context.date.getFullYear()}`; + let rendered = context.template + .replace(/\{title\}/g, sanitizeFilenamePart(context.title, 'untitled')) + .replace(/\{id\}/g, sanitizeFilenamePart(context.vodId, 'unknown')) + .replace(/\{channel\}/g, sanitizeFilenamePart(context.channel, 'unknown')) + .replace(/\{channel_id\}/g, '') + .replace(/\{date\}/g, baseDate) + .replace(/\{part\}/g, String(context.part)) + .replace(/\{part_padded\}/g, context.partPadded) + .replace(/\{trim_start\}/g, formatDurationDashed(context.trimStartSec)) + .replace(/\{trim_end\}/g, formatDurationDashed(context.trimEndSec)) + .replace(/\{trim_length\}/g, formatDurationDashed(context.trimLengthSec)) + .replace(/\{length\}/g, formatDurationDashed(context.fullLengthSec)) + .replace(/\{ext\}/g, 'mp4') + .replace(/\{random_string\}/g, Math.random().toString(36).slice(2, 10)); + + rendered = rendered.replace(/\{date_custom="(.*?)"\}/g, (_, pattern: string) => { + return sanitizeFilenamePart(formatDateWithPattern(context.date, pattern), 'date'); + }); + rendered = rendered.replace(/\{trim_start_custom="(.*?)"\}/g, (_, pattern: string) => { + return sanitizeFilenamePart(formatSecondsWithPattern(context.trimStartSec, pattern), '00-00-00'); + }); + rendered = rendered.replace(/\{trim_end_custom="(.*?)"\}/g, (_, pattern: string) => { + return sanitizeFilenamePart(formatSecondsWithPattern(context.trimEndSec, pattern), '00-00-00'); + }); + rendered = rendered.replace(/\{trim_length_custom="(.*?)"\}/g, (_, pattern: string) => { + return sanitizeFilenamePart(formatSecondsWithPattern(context.trimLengthSec, pattern), '00-00-00'); + }); + rendered = rendered.replace(/\{length_custom="(.*?)"\}/g, (_, pattern: string) => { + return sanitizeFilenamePart(formatSecondsWithPattern(context.fullLengthSec, pattern), '00-00-00'); + }); + + const parts = rendered + .split(/[\\/]+/) + .map((segment) => sanitizeFilenamePart(segment, 'unnamed')) + .filter((segment) => segment !== '.' && segment !== '..'); + + if (parts.length === 0) { + return 'clip.mp4'; + } + + const lastIdx = parts.length - 1; + if (!/\.[A-Za-z0-9]{1,8}$/.test(parts[lastIdx])) { + parts[lastIdx] = `${parts[lastIdx]}.mp4`; + } + + return path.join(...parts); +} + +function formatBytes(bytes: number): string { + if (bytes < 1024) return bytes + ' B'; + if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB'; + if (bytes < 1024 * 1024 * 1024) return (bytes / (1024 * 1024)).toFixed(1) + ' MB'; + return (bytes / (1024 * 1024 * 1024)).toFixed(2) + ' GB'; +} + +function formatSpeed(bytesPerSec: number): string { + if (bytesPerSec < 1024) return bytesPerSec.toFixed(0) + ' B/s'; + if (bytesPerSec < 1024 * 1024) return (bytesPerSec / 1024).toFixed(1) + ' KB/s'; + return (bytesPerSec / (1024 * 1024)).toFixed(1) + ' MB/s'; +} + +function formatETA(seconds: number): string { + if (seconds < 60) return `${Math.floor(seconds)}s`; + if (seconds < 3600) return `${Math.floor(seconds / 60)}m ${Math.floor(seconds % 60)}s`; + const h = Math.floor(seconds / 3600); + const m = Math.floor((seconds % 3600) / 60); + return `${h}h ${m}m`; +} + +function getFreeDiskBytes(targetPath: string): number | null { + try { + const statfsSync = (fs as unknown as { statfsSync?: (path: string) => { bsize?: number; frsize?: number; bavail?: number } }).statfsSync; + if (!statfsSync) { + return null; + } + + const info = statfsSync(targetPath); + const blockSize = Number(info?.bsize || info?.frsize || 0); + const availableBlocks = Number(info?.bavail || 0); + if (!Number.isFinite(blockSize) || !Number.isFinite(availableBlocks) || blockSize <= 0 || availableBlocks < 0) { + return null; + } + + return Math.floor(blockSize * availableBlocks); + } catch { + return null; + } +} + +function estimateRequiredDownloadBytes(item: QueueItem): number { + const durationSeconds = Math.max(1, item.customClip?.durationSec || parseDuration(item.duration_str || '0s')); + + const bytesPerSecondByMode: Record = { + stability: 900 * 1024, + balanced: 700 * 1024, + speed: 550 * 1024 + }; + + const mode = normalizePerformanceMode(config.performance_mode); + const baseEstimate = durationSeconds * bytesPerSecondByMode[mode]; + const withHeadroom = Math.ceil(baseEstimate * (item.customClip ? 1.2 : 1.35)); + + return Math.max(64 * 1024 * 1024, Math.min(withHeadroom, 40 * 1024 * 1024 * 1024)); +} + +function ensureDiskSpace(targetPath: string, requiredBytes: number, context: string): DownloadResult { + const freeBytes = getFreeDiskBytes(targetPath); + if (freeBytes === null) { + appendDebugLog('disk-space-check-skipped', { targetPath, requiredBytes, context }); + return { success: true }; + } + + if (freeBytes < Math.max(requiredBytes, MIN_FREE_DISK_BYTES)) { + const message = tBackend('diskSpaceShortFor', { context, free: formatBytes(freeBytes), required: formatBytes(requiredBytes) }); + appendDebugLog('disk-space-check-failed', { + targetPath, + requiredBytes, + freeBytes, + context + }); + return { success: false, error: message }; + } + + return { success: true }; +} + +function getMetadataCacheTtlMs(): number { + return normalizeMetadataCacheMinutes(config.metadata_cache_minutes) * 60 * 1000; +} + +function getCachedValue(cache: Map>, key: string): T | undefined { + const cached = cache.get(key); + if (!cached) { + return undefined; + } + + if (cached.expiresAt <= Date.now()) { + cache.delete(key); + return undefined; + } + + cache.delete(key); + cache.set(key, cached); + return cached.value; +} + +function pruneExpiredCacheEntries(cache: Map>): number { + const now = Date.now(); + let removed = 0; + + for (const [key, entry] of cache.entries()) { + if (entry.expiresAt <= now) { + cache.delete(key); + removed += 1; + } + } + + return removed; +} + +function enforceCacheEntryLimit(cache: Map>, maxEntries: number): number { + if (maxEntries <= 0) { + const removed = cache.size; + cache.clear(); + return removed; + } + + let removed = 0; + while (cache.size > maxEntries) { + const oldest = cache.keys().next().value as string | undefined; + if (!oldest) { + break; + } + cache.delete(oldest); + removed += 1; + } + + return removed; +} + +function setCachedValue( + cache: Map>, + key: string, + value: T, + maxEntries: number +): void { + cache.set(key, { + value, + expiresAt: Date.now() + getMetadataCacheTtlMs() + }); + + if (cache.size > maxEntries) { + pruneExpiredCacheEntries(cache); + enforceCacheEntryLimit(cache, maxEntries); + } +} + +function cleanupMetadataCaches(reason: 'interval' | 'manual' | 'shutdown'): void { + const before = { + loginToUserId: loginToUserIdCache.size, + vodList: vodListCache.size, + clipInfo: clipInfoCache.size + }; + + const expired = { + loginToUserId: pruneExpiredCacheEntries(loginToUserIdCache), + vodList: pruneExpiredCacheEntries(vodListCache), + clipInfo: pruneExpiredCacheEntries(clipInfoCache) + }; + + const evicted = { + loginToUserId: enforceCacheEntryLimit(loginToUserIdCache, MAX_LOGIN_TO_USER_ID_CACHE_ENTRIES), + vodList: enforceCacheEntryLimit(vodListCache, MAX_VOD_LIST_CACHE_ENTRIES), + clipInfo: enforceCacheEntryLimit(clipInfoCache, MAX_CLIP_INFO_CACHE_ENTRIES) + }; + + const removedTotal = + expired.loginToUserId + expired.vodList + expired.clipInfo + + evicted.loginToUserId + evicted.vodList + evicted.clipInfo; + + if (removedTotal > 0) { + appendDebugLog('metadata-cache-cleanup', { + reason, + before, + after: { + loginToUserId: loginToUserIdCache.size, + vodList: vodListCache.size, + clipInfo: clipInfoCache.size + }, + expired, + evicted, + removedTotal + }); + } +} + +function clearMetadataCaches(): void { + loginToUserIdCache.clear(); + vodListCache.clear(); + clipInfoCache.clear(); +} + +function startMetadataCacheCleanup(): void { + if (cacheCleanupTimer) { + return; + } + + cacheCleanupTimer = setInterval(() => { + cleanupMetadataCaches('interval'); + }, CACHE_CLEANUP_INTERVAL_MS); + + cacheCleanupTimer.unref?.(); +} + +function stopMetadataCacheCleanup(): void { + if (!cacheCleanupTimer) { + return; + } + + clearInterval(cacheCleanupTimer); + cacheCleanupTimer = null; +} + +function withInFlightDedup( + store: Map>, + key: string, + factory: () => Promise +): Promise { + const existing = store.get(key); + if (existing) { + return existing; + } + + const requestPromise: Promise = factory().finally(() => { + if (store.get(key) === requestPromise) { + store.delete(key); + } + }); + + store.set(key, requestPromise); + return requestPromise; +} + +function getRetryAttemptLimit(): number { + switch (normalizePerformanceMode(config.performance_mode)) { + case 'stability': + return 5; + case 'speed': + return 2; + case 'balanced': + default: + return 3; + } +} + +function classifyDownloadError(errorMessage: string): RetryErrorClass { + const text = (errorMessage || '').toLowerCase(); + if (!text) return 'unknown'; + + if (text.includes('ungueltige vod-url') || text.includes('invalid vod url')) return 'validation'; + if (text.includes('429') || text.includes('rate limit') || text.includes('too many requests')) return 'rate_limit'; + if (text.includes('401') || text.includes('403') || text.includes('unauthorized') || text.includes('forbidden') || text.includes('subscriber only') || text.includes('sub-only') || text.includes('not subscribed')) return 'auth'; + if (text.includes('timed out') || text.includes('timeout') || text.includes('network') || text.includes('connection') || text.includes('dns') || text.includes('http error') || text.includes('connectionerror') || text.includes('readerror')) return 'network'; + if (text.includes('streamlink nicht gefunden') || text.includes('streamlink not found') || text.includes('streamlink is missing') || text.includes('ffmpeg') || text.includes('ffprobe') || text.includes('enoent')) return 'tooling'; + if (text.includes('integritaet') || text.includes('integrity') || text.includes('kein videostream') || text.includes('no video stream')) return 'integrity'; + if (text.includes('access denied') || text.includes('permission') || text.includes('disk') || text.includes('file') || text.includes('ordner') || text.includes('folder')) return 'io'; + // Twitch-spezifische streamlink errors: + // "error: No playable streams found on this URL" — VOD weg / private / sub-only + // "error: Could not find any kind of stream" — gleich + // "error: Unable to validate session token" — Twitch-API rejected + // "error: Unable to fetch access token" — Auth pre-flight failed + if (text.includes('no playable streams') || text.includes('could not find any kind of stream')) return 'validation'; + if (text.includes('access token') || text.includes('session token') || text.includes('signature') || text.includes('integrity token')) return 'auth'; + + return 'unknown'; +} + +function getRetryDelaySeconds(errorClass: RetryErrorClass, attempt: number): number { + const jitter = Math.floor(Math.random() * 3); + + switch (errorClass) { + case 'rate_limit': + return Math.min(45, 10 + attempt * 6 + jitter); + case 'network': + return Math.min(30, 4 * attempt + jitter); + case 'auth': + return Math.min(40, 8 + attempt * 5 + jitter); + case 'integrity': + return Math.min(20, 3 + attempt * 2 + jitter); + case 'io': + return Math.min(25, 5 + attempt * 3 + jitter); + case 'tooling': + return DEFAULT_RETRY_DELAY_SECONDS; + case 'validation': + return 0; + case 'unknown': + default: + return Math.min(25, DEFAULT_RETRY_DELAY_SECONDS + attempt * 2 + jitter); + } +} + +function getQueueCounts(queueData: QueueItem[] = downloadQueue): RuntimeMetricsSnapshot['queue'] { + const counts = { + pending: 0, + downloading: 0, + paused: 0, + completed: 0, + error: 0, + total: queueData.length + }; + + for (const item of queueData) { + if (item.status === 'pending') counts.pending += 1; + else if (item.status === 'downloading') counts.downloading += 1; + else if (item.status === 'paused') counts.paused += 1; + else if (item.status === 'completed') counts.completed += 1; + else if (item.status === 'error') counts.error += 1; + } + + return counts; +} + +function generateQueueItemId(): string { + queueIdCounter = (queueIdCounter + 1) % 1000; + return `${Date.now()}-${queueIdCounter}`; +} + +function getQueueBroadcastFingerprint(queueData: QueueItem[] = downloadQueue): string { + return queueData.map((item) => [ + item.id, + item.status, + Math.round((Number(item.progress) || 0) * 10), + item.currentPart || 0, + item.totalParts || 0, + item.speed || '', + item.eta || '', + item.last_error || '' + ].join(':')).join('|'); +} + +function emitQueueUpdated(force = false): void { + const nextFingerprint = getQueueBroadcastFingerprint(downloadQueue); + if (!force && nextFingerprint === lastQueueBroadcastFingerprint) { + return; + } + + lastQueueBroadcastFingerprint = nextFingerprint; + mainWindow?.webContents.send('queue-updated', downloadQueue); + updateTaskbarProgress(); +} + +// Per-item taskbar progress is tracked here because main's downloadQueue +// items don't update their .progress field mid-download (only the renderer +// gets a stream of progress events). Map is cleared in processOneQueueItem.finally. +const activeDownloadProgress = new Map(); + +function recordDownloadProgress(progress: DownloadProgress): void { + const p = Number(progress.progress); + const fraction = Number.isFinite(p) && p > 0 && p <= 100 ? p / 100 : 0.3; + activeDownloadProgress.set(progress.id, fraction); + updateTaskbarProgress(); +} + +function clearDownloadProgress(itemId: string): void { + activeDownloadProgress.delete(itemId); + updateTaskbarProgress(); +} + +// Aggregate progress across all currently-downloading items, mapped to the +// Windows taskbar progress indicator (-1 = no progress, 0..1 = fraction). +// Visible whenever the user has minimised / collapsed the window. Indeterminate +// downloads (no percentage yet) report a 30% bar so the taskbar still shows +// activity instead of going cold. +function updateTaskbarProgress(): void { + if (!mainWindow || mainWindow.isDestroyed()) return; + const entries = Array.from(activeDownloadProgress.values()); + if (entries.length === 0) { + try { mainWindow.setProgressBar(-1); } catch { /* unsupported on some platforms */ } + return; + } + const avg = entries.reduce((s, v) => s + v, 0) / entries.length; + try { mainWindow.setProgressBar(Math.max(0, Math.min(1, avg))); } catch { /* ignore */ } +} + +function hasQueueItemId(id: string): boolean { + return downloadQueue.some((item) => item.id === id); +} + +function getRuntimeMetricsSnapshot(): RuntimeMetricsSnapshot { + return { + ...runtimeMetrics, + timestamp: new Date().toISOString(), + queue: getQueueCounts(downloadQueue), + caches: { + loginToUserId: loginToUserIdCache.size, + vodList: vodListCache.size, + clipInfo: clipInfoCache.size + }, + config: { + performanceMode: normalizePerformanceMode(config.performance_mode), + smartScheduler: config.smart_queue_scheduler !== false, + metadataCacheMinutes: normalizeMetadataCacheMinutes(config.metadata_cache_minutes), + duplicatePrevention: config.prevent_duplicate_downloads !== false + } + }; +} + +function normalizeQueueUrlForFingerprint(url: string): string { + return (url || '').trim().toLowerCase().replace(/^https?:\/\/(www\.)?/, ''); +} + +function getQueueItemFingerprint(item: Pick): string { + const clip = item.customClip; + const clipFingerprint = clip + ? [ + 'clip', + clip.startSec, + clip.durationSec, + clip.startPart, + clip.filenameFormat, + (clip.filenameTemplate || '').trim().toLowerCase() + ].join(':') + : 'vod'; + + return [ + normalizeQueueUrlForFingerprint(item.url), + (item.streamer || '').trim().toLowerCase(), + (item.date || '').trim(), + clipFingerprint + ].join('|'); +} + +function isQueueItemActive(item: QueueItem): boolean { + return item.status === 'pending' || item.status === 'downloading' || item.status === 'paused'; +} + +function hasActiveDuplicate(candidate: Pick): boolean { + const candidateFingerprint = getQueueItemFingerprint(candidate); + + return downloadQueue.some((existing) => { + if (!isQueueItemActive(existing)) return false; + return getQueueItemFingerprint(existing) === candidateFingerprint; + }); +} + +function getQueuePriorityScore(item: QueueItem): number { + const now = Date.now(); + const createdMs = Number(item.id) || now; + const waitSeconds = Math.max(0, Math.floor((now - createdMs) / 1000)); + const durationSeconds = Math.max(0, parseDuration(item.duration_str || '0s')); + const clipBoost = item.customClip ? 1500 : 0; + const shortJobBoost = Math.max(0, 7200 - Math.min(7200, durationSeconds)) / 5; + const ageBoost = Math.min(waitSeconds, 1800) / 2; + + return clipBoost + shortJobBoost + ageBoost; +} + +function pickNextPendingQueueItem(): QueueItem | null { + const pendingItems = downloadQueue.filter((item) => item.status === 'pending'); + if (!pendingItems.length) return null; + + if (!config.smart_queue_scheduler) { + return pendingItems[0]; + } + + let best = pendingItems[0]; + let bestScore = getQueuePriorityScore(best); + + for (let i = 1; i < pendingItems.length; i += 1) { + const candidate = pendingItems[i]; + const score = getQueuePriorityScore(candidate); + if (score > bestScore) { + best = candidate; + bestScore = score; + } + } + + return best; +} + +function parseClockDurationSeconds(duration: string | null): number | null { + if (!duration) return null; + const parts = duration.split(':').map((part) => Number(part)); + if (parts.length !== 3 || parts.some((part) => !Number.isFinite(part))) { + return null; + } + + return Math.max(0, Math.floor(parts[0] * 3600 + parts[1] * 60 + parts[2])); +} + +function probeMediaFile(filePath: string): { durationSeconds: number; hasVideo: boolean } | null { + try { + const ffprobePath = getFFprobePath(); + if (!canExecuteCommand(ffprobePath, ['-version'])) { + return null; + } + + const res = spawnSync(ffprobePath, [ + '-v', 'error', + '-print_format', 'json', + '-show_format', + '-show_streams', + filePath + ], { + windowsHide: true, + encoding: 'utf-8' + }); + + if (res.status !== 0 || !res.stdout) { + return null; + } + + const parsed = JSON.parse(res.stdout) as { + format?: { duration?: string }; + streams?: Array<{ codec_type?: string }>; + }; + + const durationSeconds = Number(parsed?.format?.duration || 0); + const hasVideo = Boolean(parsed?.streams?.some((stream) => stream.codec_type === 'video')); + + return { + durationSeconds: Number.isFinite(durationSeconds) ? durationSeconds : 0, + hasVideo + }; + } catch { + return null; + } +} + +function validateDownloadedFileIntegrity(filePath: string, expectedDurationSeconds: number | null): DownloadResult { + const probed = probeMediaFile(filePath); + if (!probed) { + appendDebugLog('integrity-probe-skipped', { filePath }); + return { success: true }; + } + + if (!probed.hasVideo) { + runtimeMetrics.integrityFailures += 1; + return { success: false, error: tBackend('integrityNoVideo') }; + } + + if (probed.durationSeconds <= 1) { + runtimeMetrics.integrityFailures += 1; + return { success: false, error: tBackend('integrityTooShort', { duration: probed.durationSeconds.toFixed(2) }) }; + } + + if (expectedDurationSeconds && expectedDurationSeconds > 4) { + const minExpected = Math.max(2, expectedDurationSeconds * 0.45); + if (probed.durationSeconds < minExpected) { + runtimeMetrics.integrityFailures += 1; + return { + success: false, + error: tBackend('integrityDurationMismatch', { actual: probed.durationSeconds.toFixed(1), expected: String(expectedDurationSeconds) }) + }; + } + } + + return { success: true }; +} + +// ========================================== +// TWITCH API +// ========================================== +async function twitchLogin(): Promise { + if (!config.client_id || !config.client_secret) { + return false; + } + + try { + const response = await axios.post('https://id.twitch.tv/oauth2/token', null, { + params: { + client_id: config.client_id, + client_secret: config.client_secret, + grant_type: 'client_credentials' + }, + timeout: API_TIMEOUT + }); + accessToken = response.data.access_token; + return true; + } catch (e) { + console.error('Login error:', e); + return false; + } +} + +function requestTwitchLogin(): Promise { + if (twitchLoginInFlight) { + return twitchLoginInFlight; + } + + const loginPromise: Promise = twitchLogin().finally(() => { + if (twitchLoginInFlight === loginPromise) { + twitchLoginInFlight = null; + } + }); + + twitchLoginInFlight = loginPromise; + return loginPromise; +} + +async function ensureTwitchAuth(forceRefresh = false): Promise { + if (!config.client_id || !config.client_secret) { + accessToken = null; + return false; + } + + if (!forceRefresh && accessToken) { + return true; + } + + return await requestTwitchLogin(); +} + +// Transient HTTP errors that warrant a retry (5xx, 408 timeout, 429 rate limit). +// 4xx (other than 408/429) are application errors and not retried. +function isTransientAxiosError(err: unknown): boolean { + if (!axios.isAxiosError(err)) { + // Non-axios errors thrown from axios.post are typically network-layer + // failures (DNS, ECONNRESET, socket hangup) — retry those too. + return true; + } + const status = err.response?.status; + if (status === undefined) { + // No response means the request never reached / never returned — + // treat as transient (network blip, timeout). + return true; + } + return status === 408 || status === 429 || (status >= 500 && status < 600); +} + +const TWITCH_GQL_RETRY_ATTEMPTS = 3; +const TWITCH_GQL_RETRY_BASE_DELAY_MS = 400; + +async function fetchPublicTwitchGql(query: string, variables: Record): Promise { + let lastError: unknown = null; + + for (let attempt = 1; attempt <= TWITCH_GQL_RETRY_ATTEMPTS; attempt++) { + try { + const response = await axios.post<{ data?: T; errors?: Array<{ message: string }> }>( + 'https://gql.twitch.tv/gql', + { query, variables }, + { + headers: { + 'Client-ID': TWITCH_WEB_CLIENT_ID, + 'Content-Type': 'application/json' + }, + timeout: API_TIMEOUT + } + ); + + // GraphQL errors (in `errors[]`) are application-level and not + // retried — the query itself is rejected. + if (response.data.errors?.length) { + const messages = response.data.errors.map((err) => err.message).join('; '); + appendDebugLog('public-gql-errors', { messages, attempt }); + console.error('Public Twitch GQL errors:', messages); + return null; + } + + if (attempt > 1) { + appendDebugLog('public-gql-recovered', { attempt }); + } + return response.data.data || null; + } catch (e) { + lastError = e; + const transient = isTransientAxiosError(e); + const willRetry = transient && attempt < TWITCH_GQL_RETRY_ATTEMPTS; + appendDebugLog('public-gql-failed', { + attempt, + maxAttempts: TWITCH_GQL_RETRY_ATTEMPTS, + transient, + willRetry, + error: String(e) + }); + if (!willRetry) { + break; + } + // Exponential backoff with jitter + const delay = TWITCH_GQL_RETRY_BASE_DELAY_MS * Math.pow(2, attempt - 1) + Math.floor(Math.random() * 250); + await sleep(delay); + } + } + + console.error('Public Twitch GQL request failed:', lastError); + return null; +} + +async function getPublicUserId(username: string): Promise { + const login = normalizeLogin(username); + if (!login) return null; + + const cachedUserId = getCachedValue(loginToUserIdCache, login); + if (cachedUserId !== undefined) { + runtimeMetrics.cacheHits += 1; + return cachedUserId; + } + + runtimeMetrics.cacheMisses += 1; + + type UserQueryResult = { user: { id: string; login: string } | null }; + const data = await fetchPublicTwitchGql( + 'query($login:String!){ user(login:$login){ id login } }', + { login } + ); + + const user = data?.user; + if (!user?.id) return null; + + setCachedValue(loginToUserIdCache, login, user.id, MAX_LOGIN_TO_USER_ID_CACHE_ENTRIES); + setUserIdLogin(user.id, user.login || login); + return user.id; +} + +async function getPublicVODsByLogin(loginName: string): Promise { + const login = normalizeLogin(loginName); + if (!login) return []; + + type VideoNode = { + id: string; + title: string; + publishedAt: string; + lengthSeconds: number; + viewCount: number; + previewThumbnailURL: string; + }; + + type VodsQueryResult = { + user: { + videos: { + edges: Array<{ node: VideoNode }>; + }; + } | null; + }; + + const data = await fetchPublicTwitchGql( + 'query($login:String!,$first:Int!){ user(login:$login){ videos(first:$first, type:ARCHIVE, sort:TIME){ edges{ node{ id title publishedAt lengthSeconds viewCount previewThumbnailURL(width:320,height:180) } } } } }', + { login, first: 100 } + ); + + const edges = data?.user?.videos?.edges || []; + + return edges + .map(({ node }) => { + const id = node?.id; + if (!id) return null; + + return { + id, + title: node.title || 'Untitled VOD', + created_at: node.publishedAt || new Date(0).toISOString(), + duration: formatTwitchDurationFromSeconds(node.lengthSeconds || 0), + thumbnail_url: node.previewThumbnailURL || '', + url: `https://www.twitch.tv/videos/${id}`, + view_count: node.viewCount || 0, + stream_id: '' + } as VOD; + }) + .filter((vod): vod is VOD => Boolean(vod)); +} + +async function getUserId(username: string): Promise { + const login = normalizeLogin(username); + if (!login) return null; + + const cachedUserId = getCachedValue(loginToUserIdCache, login); + if (cachedUserId !== undefined) { + runtimeMetrics.cacheHits += 1; + return cachedUserId; + } + + return await withInFlightDedup(inFlightUserIdRequests, login, async () => { + const refreshedCachedUserId = getCachedValue(loginToUserIdCache, login); + if (refreshedCachedUserId !== undefined) { + runtimeMetrics.cacheHits += 1; + return refreshedCachedUserId; + } + + runtimeMetrics.cacheMisses += 1; + + const getUserViaPublicApi = async () => { + return await getPublicUserId(login); + }; + + if (!(await ensureTwitchAuth())) return await getUserViaPublicApi(); + + const fetchUser = async () => { + return await axios.get('https://api.twitch.tv/helix/users', { + params: { login }, + headers: { + 'Client-ID': config.client_id, + 'Authorization': `Bearer ${accessToken}` + }, + timeout: API_TIMEOUT + }); + }; + + try { + const response = await fetchUser(); + const user = response.data.data[0]; + if (!user?.id) return await getUserViaPublicApi(); + + setCachedValue(loginToUserIdCache, login, user.id, MAX_LOGIN_TO_USER_ID_CACHE_ENTRIES); + setUserIdLogin(user.id, user.login || login); + return user.id; + } catch (e) { + if (axios.isAxiosError(e) && e.response?.status === 401 && (await ensureTwitchAuth(true))) { + try { + const retryResponse = await fetchUser(); + const user = retryResponse.data.data[0]; + if (!user?.id) return await getUserViaPublicApi(); + + setCachedValue(loginToUserIdCache, login, user.id, MAX_LOGIN_TO_USER_ID_CACHE_ENTRIES); + setUserIdLogin(user.id, user.login || login); + return user.id; + } catch (retryError) { + console.error('Error getting user after relogin:', retryError); + return await getUserViaPublicApi(); + } + } + + console.error('Error getting user:', e); + return await getUserViaPublicApi(); + } + }); +} + +async function getVODs(userId: string, forceRefresh = false): Promise { + const cacheKey = `user:${userId}`; + if (!forceRefresh) { + const cachedVods = getCachedValue(vodListCache, cacheKey); + if (cachedVods !== undefined) { + runtimeMetrics.cacheHits += 1; + return cachedVods; + } + } + + const requestKey = `${cacheKey}|${forceRefresh ? 'force' : 'default'}`; + return await withInFlightDedup(inFlightVodRequests, requestKey, async () => { + if (!forceRefresh) { + const refreshedCachedVods = getCachedValue(vodListCache, cacheKey); + if (refreshedCachedVods !== undefined) { + runtimeMetrics.cacheHits += 1; + return refreshedCachedVods; + } + } + + runtimeMetrics.cacheMisses += 1; + + const getVodsViaPublicApi = async () => { + const login = userIdLoginCache.get(userId); + if (!login) return []; + + const vods = await getPublicVODsByLogin(login); + setCachedValue(vodListCache, cacheKey, vods, MAX_VOD_LIST_CACHE_ENTRIES); + return vods; + }; + + if (!(await ensureTwitchAuth())) return await getVodsViaPublicApi(); + + const MAX_VOD_PAGES = 50; // 50 pages x 100 per page = 5000 VODs max + + const fetchVodsPage = async (cursor?: string) => { + const params: Record = { + user_id: userId, + type: 'archive', + first: 100 + }; + if (cursor) params.after = cursor; + + return await axios.get('https://api.twitch.tv/helix/videos', { + params, + headers: { + 'Client-ID': config.client_id, + 'Authorization': `Bearer ${accessToken}` + }, + timeout: API_TIMEOUT + }); + }; + + const fetchAllVodPages = async (): Promise => { + const allVods: VOD[] = []; + let cursor: string | undefined; + let pageCount = 0; + + do { + const response = await fetchVodsPage(cursor); + const pageVods = response.data.data || []; + allVods.push(...pageVods); + + if (pageCount === 0) { + const login = pageVods[0]?.user_login; + if (login) { + setUserIdLogin(userId, normalizeLogin(login)); + } + } + + cursor = response.data.pagination?.cursor; + pageCount++; + } while (cursor && pageCount < MAX_VOD_PAGES); + + return allVods; + }; + + try { + const vods = await fetchAllVodPages(); + setCachedValue(vodListCache, cacheKey, vods, MAX_VOD_LIST_CACHE_ENTRIES); + return vods; + } catch (e) { + if (axios.isAxiosError(e) && e.response?.status === 401 && (await ensureTwitchAuth(true))) { + try { + const vods = await fetchAllVodPages(); + setCachedValue(vodListCache, cacheKey, vods, MAX_VOD_LIST_CACHE_ENTRIES); + return vods; + } catch (retryError) { + console.error('Error getting VODs after relogin:', retryError); + return await getVodsViaPublicApi(); + } + } + + console.error('Error getting VODs:', e); + return await getVodsViaPublicApi(); + } + }); +} + +interface LiveStreamInfo { + isLive: boolean; + title?: string; + gameName?: string; +} + +// Returns whether the streamer is currently live + a little metadata if +// available. Tries Helix first (better data), falls back to public GQL when +// the user has no client_id/secret configured. A `null` return means we +// couldn't determine — caller should treat as "best-effort". +async function getLiveStreamInfo(login: string): Promise { + const normalized = normalizeLogin(login); + if (!normalized) return null; + + if (await ensureTwitchAuth()) { + try { + const response = await axios.get('https://api.twitch.tv/helix/streams', { + params: { user_login: normalized, first: 1 }, + headers: { + 'Client-ID': config.client_id, + 'Authorization': `Bearer ${accessToken}` + }, + timeout: API_TIMEOUT + }); + const entries = response.data?.data || []; + if (entries.length === 0) return { isLive: false }; + const e = entries[0]; + return { + isLive: e.type === 'live', + title: typeof e.title === 'string' ? e.title : undefined, + gameName: typeof e.game_name === 'string' ? e.game_name : undefined + }; + } catch (e) { + appendDebugLog('helix-streams-failed', { login: normalized, error: String(e) }); + // fall through to public GQL + } + } + + type StreamQueryResult = { + user: { + stream: { id: string; type: string; title?: string; game?: { name?: string } } | null; + } | null; + }; + const data = await fetchPublicTwitchGql( + 'query($login:String!){ user(login:$login){ stream{ id type title game{ name } } } }', + { login: normalized } + ); + if (!data) return null; + const stream = data.user?.stream; + if (!stream) return { isLive: false }; + return { + isLive: stream.type === 'live', + title: stream.title, + gameName: stream.game?.name + }; +} + +// ========================================== +// STREAMER PROFILE — display-name, avatar, follower count, etc. +// ========================================== +// User-facing channel header data. Combines Helix /users (display name, +// avatar, bio, broadcaster type), public GQL (follower total — Helix +// requires moderator scope we don't have), the already-cached VOD list +// (vodCount + lastStreamAt come for free), and the live-status cache +// (isLive + currentTitle + currentGame). Cached for 30 min per login. +interface StreamerProfile { + login: string; + displayName: string; + avatarUrl: string; + bannerUrl: string; + description: string; + broadcasterType: '' | 'partner' | 'affiliate'; + followerCount: number | null; + vodCount: number; + lastStreamAt: string | null; + isLive: boolean; + currentTitle: string | null; + currentGame: string | null; + currentStreamPreviewUrl: string; + currentStreamViewers: number | null; + twitchUrl: string; + fetchedAt: number; +} + +const MAX_STREAMER_PROFILE_CACHE_ENTRIES = 512; +const streamerProfileCache = new Map>(); +const inFlightProfileRequests = new Map>(); + +// Avatar bytes get embedded as data URLs in the profile so the renderer +// doesn't have to do its own HTTPS fetch (Electron's renderer img loader +// has a habit of failing silently against the Twitch CDN — undocumented, +// but reproducibly: the same URL works in DevTools but not in the live +// page). Cached by source URL so a single avatar change across multiple +// streamer entries only downloads once. +const avatarDataUrlCache = new Map(); +const MAX_AVATAR_DATA_URL_CACHE = 256; + +async function fetchAvatarAsDataUrl(url: string): Promise { + if (!url) return ''; + const cached = avatarDataUrlCache.get(url); + if (cached !== undefined) return cached; + try { + const response = await axios.get(url, { + responseType: 'arraybuffer', + timeout: 8000, + headers: { 'User-Agent': 'TwitchVODManager/1.0' } + }); + const buf = Buffer.from(response.data); + // Twitch CDN almost always serves PNG or JPEG. Detect from the + // response content-type when available, fall back to PNG which is + // the default for profile_image_url. + const contentType = (response.headers['content-type'] as string | undefined)?.split(';')[0]?.trim() || 'image/png'; + const dataUrl = `data:${contentType};base64,${buf.toString('base64')}`; + avatarDataUrlCache.set(url, dataUrl); + if (avatarDataUrlCache.size > MAX_AVATAR_DATA_URL_CACHE) { + // FIFO eviction — Map preserves insertion order. + const firstKey = avatarDataUrlCache.keys().next().value as string | undefined; + if (firstKey) avatarDataUrlCache.delete(firstKey); + } + return dataUrl; + } catch (e) { + appendDebugLog('avatar-fetch-failed', { url, error: String(e) }); + return ''; + } +} + +interface HelixUser { + id: string; + login: string; + display_name: string; + description: string; + profile_image_url: string; + broadcaster_type: string; +} + +async function fetchHelixUserInfo(login: string): Promise { + if (!(await ensureTwitchAuth())) return null; + try { + const response = await axios.get('https://api.twitch.tv/helix/users', { + params: { login }, + headers: { + 'Client-ID': config.client_id, + 'Authorization': `Bearer ${accessToken}` + }, + timeout: API_TIMEOUT + }); + const u = response.data?.data?.[0]; + if (!u?.id) return null; + return u as HelixUser; + } catch (e) { + appendDebugLog('helix-user-info-failed', { login, error: String(e) }); + return null; + } +} + +interface PublicProfileQueryResult { + user: { + id: string; + login: string; + displayName: string; + description: string | null; + profileImageURL: string | null; + bannerImageURL: string | null; + roles?: { isPartner: boolean; isAffiliate: boolean } | null; + followers?: { totalCount: number } | null; + stream?: { + id: string; + type: string; + title: string | null; + viewersCount: number | null; + previewImageURL: string | null; + game: { name: string } | null; + } | null; + } | null; +} + +interface PublicStreamerProfileResult { + displayName: string; + avatarUrl: string; + bannerUrl: string; + description: string; + broadcasterType: '' | 'partner' | 'affiliate'; + followerCount: number | null; + stream: PublicStreamInfo | null; +} + +interface PublicStreamInfo { + previewUrl: string; + viewers: number | null; + title: string | null; + game: string | null; +} + +async function fetchPublicStreamerProfile(login: string): Promise { + // Same query also pulls bannerImageURL and the current stream's + // preview + viewer count when live — saves a separate roundtrip. + const data = await fetchPublicTwitchGql( + `query($login: String!) { + user(login: $login) { + id + login + displayName + description + profileImageURL(width: 150) + bannerImageURL + roles { isPartner isAffiliate } + followers { totalCount } + stream { + id + type + title + viewersCount + previewImageURL(width: 640, height: 360) + game { name } + } + } + }`, + { login } + ); + if (!data?.user) return null; + const roles = data.user.roles; + const broadcasterType: '' | 'partner' | 'affiliate' = roles?.isPartner + ? 'partner' + : (roles?.isAffiliate ? 'affiliate' : ''); + const s = data.user.stream; + const stream = (s && s.type === 'live') ? { + previewUrl: s.previewImageURL || '', + viewers: typeof s.viewersCount === 'number' ? s.viewersCount : null, + title: s.title || null, + game: s.game?.name || null + } : null; + return { + displayName: data.user.displayName || login, + avatarUrl: data.user.profileImageURL || '', + bannerUrl: data.user.bannerImageURL || '', + description: data.user.description || '', + broadcasterType, + followerCount: typeof data.user.followers?.totalCount === 'number' ? data.user.followers.totalCount : null, + stream + }; +} + +async function getStreamerProfile(login: string, forceRefresh = false): Promise { + const normalized = normalizeLogin(login); + if (!normalized) return null; + + if (!forceRefresh) { + const cached = getCachedValue(streamerProfileCache, normalized); + if (cached !== undefined) { + runtimeMetrics.cacheHits += 1; + return cached; + } + } + + return await withInFlightDedup(inFlightProfileRequests, normalized, async () => { + runtimeMetrics.cacheMisses += 1; + + // Public GQL is now the SOURCE for everything except some of the + // core text fields when Helix is authenticated — because public + // GQL is the only route that gives us the banner image + current + // stream preview in one shot, and skipping it would mean two + // extra roundtrips. Helix takes precedence for displayName / + // description (those fields are sometimes richer there). + let displayName = normalized; + let avatarUrl = ''; + let bannerUrl = ''; + let description = ''; + let broadcasterType: '' | 'partner' | 'affiliate' = ''; + let streamFromPublic: PublicStreamInfo | null = null; + let followerCountFromPublic: number | null = null; + + const publicProfile = await fetchPublicStreamerProfile(normalized); + if (publicProfile) { + displayName = publicProfile.displayName; + avatarUrl = publicProfile.avatarUrl; + bannerUrl = publicProfile.bannerUrl; + description = publicProfile.description; + broadcasterType = publicProfile.broadcasterType; + followerCountFromPublic = publicProfile.followerCount; + streamFromPublic = publicProfile.stream; + } + + const helixUser = await fetchHelixUserInfo(normalized); + if (helixUser) { + displayName = helixUser.display_name || displayName; + if (helixUser.profile_image_url) avatarUrl = helixUser.profile_image_url; + if (helixUser.description) description = helixUser.description; + const bt = (helixUser.broadcaster_type || '').toLowerCase(); + if (bt === 'partner' || bt === 'affiliate') broadcasterType = bt; + } + + // followerCountFromPublic comes from the public profile query + // above — no separate follower roundtrip needed. + const followerCount = followerCountFromPublic; + + // Derive vod count + last stream from the already-cached VOD list + // when we have an id. No extra network hit. + let vodCount = 0; + let lastStreamAt: string | null = null; + const userId = await getUserId(normalized); + if (userId) { + try { + const vods = await getVODs(userId); + vodCount = vods.length; + // VOD list is sorted by Twitch newest-first; pick element 0. + const newest = vods[0]; + if (newest?.created_at) lastStreamAt = newest.created_at; + } catch (e) { + appendDebugLog('profile-vod-derive-failed', { login: normalized, error: String(e) }); + } + } + + let isLive = false; + let currentTitle: string | null = null; + let currentGame: string | null = null; + let currentStreamPreviewRemoteUrl = ''; + let currentStreamViewers: number | null = null; + + if (streamFromPublic) { + // Public-GQL already told us this user is live and gave us a + // preview frame URL + viewer count + game/title. Don't double- + // call getLiveStreamInfo when we already have a fresh answer. + isLive = true; + currentTitle = streamFromPublic.title; + currentGame = streamFromPublic.game; + currentStreamPreviewRemoteUrl = streamFromPublic.previewUrl; + currentStreamViewers = streamFromPublic.viewers; + } else { + try { + const live = await getLiveStreamInfo(normalized); + if (live) { + isLive = live.isLive; + currentTitle = live.title || null; + currentGame = live.gameName || null; + } + } catch (_) { /* best-effort */ } + } + + // Embed the avatar AND banner bytes as data URLs in parallel. + // Renderer can't reliably fetch Twitch CDN images directly from + // an Electron renderer process, plus the data URL approach skips + // any CSP/referer/CORS quirks. Live preview also goes through + // this path — adds a cache-busting query string so a returning + // user gets a fresh frame each time the profile refreshes. + const livePreviewUrlForFetch = currentStreamPreviewRemoteUrl + ? `${currentStreamPreviewRemoteUrl}${currentStreamPreviewRemoteUrl.includes('?') ? '&' : '?'}_=${Date.now()}` + : ''; + const [avatarDataUrl, bannerDataUrl, livePreviewDataUrl] = await Promise.all([ + avatarUrl ? fetchAvatarAsDataUrl(avatarUrl) : Promise.resolve(''), + bannerUrl ? fetchAvatarAsDataUrl(bannerUrl) : Promise.resolve(''), + livePreviewUrlForFetch ? fetchAvatarAsDataUrl(livePreviewUrlForFetch) : Promise.resolve('') + ]); + + const profile: StreamerProfile = { + login: normalized, + displayName, + avatarUrl: avatarDataUrl || avatarUrl, + bannerUrl: bannerDataUrl || bannerUrl, + description, + broadcasterType, + followerCount, + vodCount, + lastStreamAt, + isLive, + currentTitle, + currentGame, + currentStreamPreviewUrl: livePreviewDataUrl || currentStreamPreviewRemoteUrl, + currentStreamViewers, + twitchUrl: `https://www.twitch.tv/${normalized}`, + fetchedAt: Date.now() + }; + + setCachedValue(streamerProfileCache, normalized, profile, MAX_STREAMER_PROFILE_CACHE_ENTRIES); + return profile; + }); +} + +// ========================================== +// VOD STORYBOARD — animated hover preview +// ========================================== +// Twitch publishes a "storyboard" JSON per VOD with sprite-sheet URLs +// containing N preview thumbnails covering the full length of the +// recording. We pull the JSON via public GQL (seekPreviewsURL), then +// hand the renderer the first high-quality sprite as a data URL plus +// the grid metadata. The renderer animates background-position across +// 4 cells to produce a scrub-preview effect on hover, twitch.tv-style. +interface VodStoryboard { + vodId: string; + spriteDataUrl: string; + cols: number; + rows: number; + cellWidth: number; + cellHeight: number; + framesInSprite: number; +} + +const MAX_VOD_STORYBOARD_CACHE_ENTRIES = 1024; +const vodStoryboardCache = new Map>(); +const inFlightStoryboardRequests = new Map>(); + +interface StoryboardManifestEntry { + count: number; + width: number; + height: number; + cols: number; + rows: number; + images: string[]; + quality: string; + interval: number; +} + +async function getVodStoryboard(vodId: string): Promise { + if (!vodId) return null; + + const cached = getCachedValue(vodStoryboardCache, vodId); + if (cached !== undefined) { + runtimeMetrics.cacheHits += 1; + return cached; + } + + return await withInFlightDedup(inFlightStoryboardRequests, vodId, async () => { + runtimeMetrics.cacheMisses += 1; + + // Step 1: GQL gives us the seekPreviewsURL pointing at a JSON + // manifest. The manifest lists sprite images at multiple quality + // levels; we pick the high-quality first sprite (covers the + // beginning of the VOD with the most detail). + const data = await fetchPublicTwitchGql<{ video: { seekPreviewsURL: string | null } | null }>( + `query($id: ID!) { video(id: $id) { seekPreviewsURL } }`, + { id: vodId } + ); + const manifestUrl = data?.video?.seekPreviewsURL; + if (!manifestUrl) { + // Cache the negative result so a VOD without a storyboard + // (private/unlisted/expired) doesn't get re-queried on every + // subsequent hover. + setCachedValue(vodStoryboardCache, vodId, null, MAX_VOD_STORYBOARD_CACHE_ENTRIES); + return null; + } + + let manifest: StoryboardManifestEntry[] | null = null; + try { + const manifestResp = await axios.get(manifestUrl, { + timeout: 6000, + responseType: 'json', + headers: { 'User-Agent': 'TwitchVODManager/1.0' } + }); + manifest = manifestResp.data; + } catch (e) { + appendDebugLog('storyboard-manifest-failed', { vodId, error: String(e) }); + setCachedValue(vodStoryboardCache, vodId, null, MAX_VOD_STORYBOARD_CACHE_ENTRIES); + return null; + } + + if (!Array.isArray(manifest) || manifest.length === 0) { + setCachedValue(vodStoryboardCache, vodId, null, MAX_VOD_STORYBOARD_CACHE_ENTRIES); + return null; + } + + // Prefer the "high" quality entry — Twitch ships both "low" and + // "high" alongside each other. Falls back to whichever is present. + const entry = manifest.find((m) => m.quality === 'high') || manifest[0]; + if (!entry?.images?.length) { + setCachedValue(vodStoryboardCache, vodId, null, MAX_VOD_STORYBOARD_CACHE_ENTRIES); + return null; + } + + // The manifest URL points at e.g. .../storyboards/2767872722-info.json + // and sprite filenames are relative (e.g. "2767872722-high-0.jpg"). + // Strip the JSON filename to get the base, then append the sprite. + const baseUrl = manifestUrl.replace(/\/[^/]+$/, '/'); + const firstSpriteUrl = baseUrl + entry.images[0]; + + const spriteDataUrl = await fetchAvatarAsDataUrl(firstSpriteUrl); + if (!spriteDataUrl) { + setCachedValue(vodStoryboardCache, vodId, null, MAX_VOD_STORYBOARD_CACHE_ENTRIES); + return null; + } + + const storyboard: VodStoryboard = { + vodId, + spriteDataUrl, + cols: entry.cols, + rows: entry.rows, + cellWidth: entry.width, + cellHeight: entry.height, + framesInSprite: entry.cols * entry.rows + }; + setCachedValue(vodStoryboardCache, vodId, storyboard, MAX_VOD_STORYBOARD_CACHE_ENTRIES); + return storyboard; + }); +} + +async function getClipInfo(clipId: string): Promise { + const cachedClip = getCachedValue(clipInfoCache, clipId); + if (cachedClip !== undefined) { + runtimeMetrics.cacheHits += 1; + return cachedClip; + } + + return await withInFlightDedup(inFlightClipRequests, clipId, async () => { + const refreshedCachedClip = getCachedValue(clipInfoCache, clipId); + if (refreshedCachedClip !== undefined) { + runtimeMetrics.cacheHits += 1; + return refreshedCachedClip; + } + + runtimeMetrics.cacheMisses += 1; + + if (!(await ensureTwitchAuth())) return null; + + const fetchClip = async () => { + return await axios.get('https://api.twitch.tv/helix/clips', { + params: { id: clipId }, + headers: { + 'Client-ID': config.client_id, + 'Authorization': `Bearer ${accessToken}` + }, + timeout: API_TIMEOUT + }); + }; + + try { + const response = await fetchClip(); + const clip = response.data.data[0] || null; + if (clip) { + setCachedValue(clipInfoCache, clipId, clip, MAX_CLIP_INFO_CACHE_ENTRIES); + } + return clip; + } catch (e) { + if (axios.isAxiosError(e) && e.response?.status === 401 && (await ensureTwitchAuth(true))) { + try { + const retryResponse = await fetchClip(); + const clip = retryResponse.data.data[0] || null; + if (clip) { + setCachedValue(clipInfoCache, clipId, clip, MAX_CLIP_INFO_CACHE_ENTRIES); + } + return clip; + } catch (retryError) { + console.error('Error getting clip after relogin:', retryError); + return null; + } + } + + console.error('Error getting clip:', e); + return null; + } + }); +} + +// ========================================== +// VIDEO INFO (for cutter) +// ========================================== +async function getVideoInfo(filePath: string): Promise { + const ffmpegReady = await ensureFfmpegInstalled(); + if (!ffmpegReady) { + appendDebugLog('get-video-info-missing-ffmpeg'); + return null; + } + + return new Promise((resolve) => { + const ffprobe = getFFprobePath(); + const args = [ + '-v', 'quiet', + '-print_format', 'json', + '-show_format', + '-show_streams', + filePath + ]; + + const proc = spawn(ffprobe, args, { windowsHide: true }); + let output = ''; + + proc.stdout?.on('data', (data) => { + output += data.toString(); + }); + + proc.on('close', (code) => { + if (code !== 0) { + resolve(null); + return; + } + + try { + const info = JSON.parse(output); + const videoStream = info.streams?.find((s: any) => s.codec_type === 'video'); + + resolve({ + duration: parseFloat(info.format?.duration || '0'), + width: videoStream?.width || 0, + height: videoStream?.height || 0, + fps: parseFrameRate(videoStream?.r_frame_rate) + }); + } catch { + resolve(null); + } + }); + + proc.on('error', () => resolve(null)); + }); +} + +// ========================================== +// VIDEO CUTTER +// ========================================== +async function extractFrame(filePath: string, timeSeconds: number): Promise { + const ffmpegReady = await ensureFfmpegInstalled(); + if (!ffmpegReady) { + appendDebugLog('extract-frame-missing-ffmpeg'); + return null; + } + + return new Promise((resolve) => { + const ffmpeg = getFFmpegPath(); + const tempFile = path.join(app.getPath('temp'), `frame_${Date.now()}.jpg`); + + const args = [ + '-ss', timeSeconds.toString(), + '-i', filePath, + '-vframes', '1', + '-q:v', '2', + '-y', + tempFile + ]; + + const proc = spawn(ffmpeg, args, { windowsHide: true }); + + proc.on('close', (code) => { + if (code === 0 && fs.existsSync(tempFile)) { + const imageData = fs.readFileSync(tempFile); + const base64 = `data:image/jpeg;base64,${imageData.toString('base64')}`; + fs.unlinkSync(tempFile); + resolve(base64); + } else { + resolve(null); + } + }); + + proc.on('error', () => resolve(null)); + }); +} + +// Concatenates same-codec mp4 files into a single output via ffmpeg's +// concat demuxer. No re-encoding — purely a container stitch, which is +// what we want for resumed-recording parts (same streamlink, same codec +// settings, just split across files). Returns false on any error so the +// caller can keep the original parts. +async function concatVideoFiles(inputFiles: string[], outputFile: string): Promise { + if (inputFiles.length < 2) return false; + const ffmpegReady = await ensureFfmpegInstalled(); + if (!ffmpegReady) return false; + + for (const f of inputFiles) { + if (!fs.existsSync(f)) { + appendDebugLog('concat-missing-part', { missing: f }); + return false; + } + } + + const listFile = path.join(path.dirname(outputFile), `.concat-${Date.now()}.txt`); + try { + // ffmpeg concat demuxer escaping: paths go in single quotes, embedded + // single quotes need '\''. Backslashes are fine on Windows. + const lines = inputFiles + .map((f) => `file '${f.replace(/'/g, "'\\''")}'`) + .join('\n'); + fs.writeFileSync(listFile, lines, 'utf8'); + } catch (e) { + appendDebugLog('concat-listfile-write-failed', String(e)); + return false; + } + + const ffmpeg = getFFmpegPath(); + const args = [ + '-f', 'concat', + '-safe', '0', + '-i', listFile, + '-c', 'copy', + '-y', + outputFile + ]; + + return await new Promise((resolve) => { + const proc = spawn(ffmpeg, args, { windowsHide: true }); + let stderrBuf = ''; + proc.stderr?.on('data', (chunk: Buffer) => { stderrBuf += chunk.toString(); }); + proc.on('close', (code) => { + try { fs.unlinkSync(listFile); } catch { /* ignore */ } + if (code === 0 && fs.existsSync(outputFile) && fs.statSync(outputFile).size > 0) { + appendDebugLog('concat-ok', { output: outputFile, parts: inputFiles.length }); + resolve(true); + } else { + appendDebugLog('concat-failed', { code, stderrTail: stderrBuf.slice(-400) }); + try { + if (fs.existsSync(outputFile)) fs.unlinkSync(outputFile); + } catch { /* ignore */ } + resolve(false); + } + }); + proc.on('error', (err) => { + try { fs.unlinkSync(listFile); } catch { /* ignore */ } + appendDebugLog('concat-spawn-error', String(err)); + resolve(false); + }); + }); +} + +async function cutVideo( + inputFile: string, + outputFile: string, + startTime: number, + endTime: number, + onProgress: (percent: number) => void +): Promise { + const ffmpegReady = await ensureFfmpegInstalled(); + if (!ffmpegReady) { + appendDebugLog('cut-video-missing-ffmpeg'); + return false; + } + + const ffmpeg = getFFmpegPath(); + const duration = Math.max(0.1, endTime - startTime); + + let inputBytes = 0; + try { + inputBytes = fs.statSync(inputFile).size; + } catch { } + + const cutRequiredBytes = Math.max(96 * 1024 * 1024, Math.ceil(inputBytes * 0.75)); + const cutDiskCheck = ensureDiskSpace(path.dirname(outputFile), cutRequiredBytes, 'Video-Cut'); + if (!cutDiskCheck.success) { + appendDebugLog('cut-video-no-disk-space', { + inputFile, + outputFile, + requiredBytes: cutRequiredBytes, + error: cutDiskCheck.error + }); + return false; + } + + const runCutAttempt = async (copyMode: boolean): Promise => { + const args = [ + '-ss', formatDuration(startTime), + '-i', inputFile, + '-t', formatDuration(duration) + ]; + + if (copyMode) { + args.push('-c', 'copy'); + } else { + args.push( + '-c:v', 'libx264', + '-preset', 'veryfast', + '-crf', '20', + '-c:a', 'aac', + '-b:a', '160k', + '-movflags', '+faststart' + ); + } + + args.push('-progress', 'pipe:1', '-y', outputFile); + + appendDebugLog('cut-video-attempt', { copyMode, args }); + + return await new Promise((resolve) => { + const proc = spawn(ffmpeg, args, { windowsHide: true }); + currentEditorProcess = proc; + + proc.stdout?.on('data', (data) => { + const line = data.toString(); + const match = line.match(/out_time_us=(\d+)/); + if (match) { + const currentUs = parseInt(match[1], 10); + const percent = Math.min(100, (currentUs / 1000000) / duration * 100); + onProgress(percent); + } + }); + + proc.on('close', (code) => { + currentEditorProcess = null; + if (code === 0 && fs.existsSync(outputFile)) { + const stats = fs.statSync(outputFile); + if (stats.size <= 256) { + appendDebugLog('cut-video-empty-output', { outputFile, bytes: stats.size }); + resolve(false); + return; + } + resolve(true); + } else { + resolve(false); + } + }); + + proc.on('error', () => { + currentEditorProcess = null; + resolve(false); + }); + }); + }; + + const copySuccess = await runCutAttempt(true); + if (copySuccess) { + return true; + } + + appendDebugLog('cut-video-copy-failed-fallback-reencode', { inputFile, outputFile }); + try { + if (fs.existsSync(outputFile)) fs.unlinkSync(outputFile); + } catch { } + + return await runCutAttempt(false); +} + +// ========================================== +// MERGE VIDEOS +// ========================================== +async function mergeVideos( + inputFiles: string[], + outputFile: string, + onProgress: (percent: number) => void, + totalDurationSec?: number +): Promise { + const ffmpegReady = await ensureFfmpegInstalled(); + if (!ffmpegReady) { + appendDebugLog('merge-videos-missing-ffmpeg'); + return false; + } + + const ffmpeg = getFFmpegPath(); + const concatFile = path.join(app.getPath('temp'), `concat_${Date.now()}.txt`); + const concatContent = inputFiles.map((filePath) => { + const normalized = filePath.replace(/\\/g, '/'); + return `file '${normalized.replace(/'/g, "'\\''")}'`; + }).join('\n'); + fs.writeFileSync(concatFile, concatContent); + + let mergeInputBytes = 0; + for (const filePath of inputFiles) { + try { + mergeInputBytes += fs.statSync(filePath).size; + } catch { + // ignore missing file in estimation + } + } + + const mergeRequiredBytes = Math.max(128 * 1024 * 1024, Math.ceil(mergeInputBytes * 1.1)); + const mergeDiskCheck = ensureDiskSpace(path.dirname(outputFile), mergeRequiredBytes, 'Video-Merge'); + if (!mergeDiskCheck.success) { + appendDebugLog('merge-video-no-disk-space', { + outputFile, + files: inputFiles.length, + requiredBytes: mergeRequiredBytes, + error: mergeDiskCheck.error + }); + try { + fs.unlinkSync(concatFile); + } catch { } + return false; + } + + // Determine total duration for accurate progress + let mergeTotalDurationUs = 0; + if (totalDurationSec && totalDurationSec > 0) { + mergeTotalDurationUs = totalDurationSec * 1_000_000; + } else { + // Fallback: use ffprobe to get total duration of all input files + const ffprobe = getFFprobePath(); + for (const filePath of inputFiles) { + try { + const result = execSync( + `"${ffprobe}" -v quiet -show_entries format=duration -of csv=p=0 "${filePath}"`, + { timeout: 10000, windowsHide: true } + ).toString().trim(); + const dur = parseFloat(result); + if (!isNaN(dur)) { + mergeTotalDurationUs += dur * 1_000_000; + } + } catch { + // If ffprobe fails, fall back to old behavior + } + } + } + + const runMergeAttempt = async (copyMode: boolean): Promise => { + const args = [ + '-f', 'concat', + '-safe', '0', + '-i', concatFile + ]; + + if (copyMode) { + args.push('-c', 'copy'); + } else { + args.push( + '-c:v', 'libx264', + '-preset', 'veryfast', + '-crf', '20', + '-c:a', 'aac', + '-b:a', '160k', + '-movflags', '+faststart' + ); + } + + args.push('-progress', 'pipe:1', '-y', outputFile); + appendDebugLog('merge-video-attempt', { copyMode, argsCount: args.length }); + + return await new Promise((resolve) => { + const proc = spawn(ffmpeg, args, { windowsHide: true }); + currentEditorProcess = proc; + + proc.stdout?.on('data', (data) => { + const line = data.toString(); + const match = line.match(/out_time_us=(\d+)/); + if (match) { + const currentUs = parseInt(match[1], 10); + if (mergeTotalDurationUs > 0) { + onProgress(Math.min(99, (currentUs / mergeTotalDurationUs) * 100)); + } else { + onProgress(Math.min(99, currentUs / 10000000)); + } + } + }); + + proc.on('close', (code) => { + currentEditorProcess = null; + const success = code === 0 && fs.existsSync(outputFile); + if (success) { + onProgress(100); + } + resolve(success); + }); + + proc.on('error', () => { + currentEditorProcess = null; + resolve(false); + }); + }); + }; + + try { + const copySuccess = await runMergeAttempt(true); + if (copySuccess) { + return true; + } + + appendDebugLog('merge-video-copy-failed-fallback-reencode', { outputFile, files: inputFiles.length }); + try { + if (fs.existsSync(outputFile)) fs.unlinkSync(outputFile); + } catch { } + + return await runMergeAttempt(false); + } finally { + try { + fs.unlinkSync(concatFile); + } catch { } + } +} + +// ========================================== +// SPLIT MERGED FILE +// ========================================== +async function splitMergedFile( + inputFile: string, + outputFolder: string, + partDurationSec: number, + totalDurationSec: number, + filenameGenerator: (partNum: number) => string, + onProgress: (currentPart: number, totalParts: number) => void, + itemId: string | null = null +): Promise<{ success: boolean; files: string[] }> { + const ffmpegReady = await ensureFfmpegInstalled(); + if (!ffmpegReady) { + appendDebugLog('split-merged-missing-ffmpeg'); + return { success: false, files: [] }; + } + + const ffmpeg = getFFmpegPath(); + const numParts = Math.ceil(totalDurationSec / partDurationSec); + const splitFiles: string[] = []; + + for (let i = 0; i < numParts; i++) { + if (itemId && cancelledItemIds.has(itemId)) { + return { success: false, files: splitFiles }; + } + + const startSec = i * partDurationSec; + const thisDuration = Math.min(partDurationSec, totalDurationSec - startSec); + const outputFile = ensureUniqueFilename(path.join(outputFolder, filenameGenerator(i + 1)), itemId); + + onProgress(i + 1, numParts); + + const args = [ + '-ss', formatDuration(startSec), + '-i', inputFile, + '-t', formatDuration(thisDuration), + '-c', 'copy', + '-y', outputFile + ]; + + appendDebugLog('split-merged-part', { part: i + 1, total: numParts, startSec, duration: thisDuration }); + + const success = await new Promise((resolve) => { + const proc = spawn(ffmpeg, args, { windowsHide: true }); + currentEditorProcess = proc; + + proc.on('close', (code) => { + currentEditorProcess = null; + resolve(code === 0 && fs.existsSync(outputFile)); + }); + + proc.on('error', () => { + currentEditorProcess = null; + resolve(false); + }); + }); + + if (!success) { + appendDebugLog('split-merged-part-failed', { part: i + 1, outputFile }); + return { success: false, files: splitFiles }; + } + + splitFiles.push(outputFile); + } + + return { success: true, files: splitFiles }; +} + +// ========================================== +// DOWNLOAD FUNCTIONS +// ========================================== +function downloadVODPart( + url: string, + filename: string, + startTime: string | null, + endTime: string | null, + onProgress: (progress: DownloadProgress) => void, + itemId: string, + partNum: number, + totalParts: number, + /** Erwartete Dauer in Sekunden fuer den Progress-Estimate. Wenn endTime + gesetzt ist, ueberschrieben aus dort. Wenn startTime und endTime null + sind (Full-VOD), kann Caller hier die VOD-Gesamtdauer reingeben, + damit der Bar nicht in indeterminate haengt. 0 = unknown. */ + expectedTotalSec: number = 0 +): Promise { + return new Promise((resolve) => { + const streamlinkCmd = getStreamlinkCommand(); + const args = [...streamlinkCmd.prefixArgs, url, getStreamlinkStreamArg(), '-o', filename, '--force']; + if (config.streamlink_disable_ads !== false) { + // Skips Twitch mid-roll ads which would otherwise be embedded + // in the VOD output. Off only if the user explicitly disabled it. + args.push('--twitch-disable-ads'); + } + // HLS-Segment-Resilience: bei vereinzelten CDN-Fehlern weiter retrien, + // statt komplett zu sterben. Twitch hat 2025/26 oefter transiente 403/ + // timeout-Errors auf einzelne HLS-Segments. Default ist 3 — 5 ist ein + // pragmatischer Kompromiss zwischen Resilience und Failing-Fast. + args.push('--stream-segment-attempts', '5'); + args.push('--stream-segment-timeout', '20'); + args.push('--stream-timeout', '120'); + // Streamlink-Plugin retry: bei "stream not found on URL"-Erstabfrage + // einmal nachhaken, bevor wir den ganzen Run failen. + args.push('--retry-streams', '3'); + args.push('--retry-max', '2'); + let lastErrorLine = ''; + const stderrBuffer: string[] = []; + const expectedDurationSeconds = parseClockDurationSeconds(endTime); + let lastStreamlinkPercent = 0; + + if (startTime) { + args.push('--hls-start-offset', startTime); + } + if (endTime) { + args.push('--hls-duration', endTime); + } + + // download-part-start in the debug log captures the same info + // for support / forensics — no need to flood stdout too. + appendDebugLog('download-part-start', { itemId, command: streamlinkCmd.command, filename, args }); + + const proc = spawn(streamlinkCmd.command, args, { windowsHide: true }); + + // Register in per-item tracking map for parallel downloads + // (no longer mirrored on a global — currentEditorProcess is editor-only) + const itemTracking = { process: proc, cancelled: false, startTime: Date.now(), bytes: 0 }; + activeDownloads.set(itemId, itemTracking); + + downloadStartTime = itemTracking.startTime; + downloadedBytes = 0; + let lastBytes = 0; + let lastTime = Date.now(); + + // Monitor file size for progress + const progressInterval = setInterval(() => { + if (fs.existsSync(filename)) { + try { + const stats = fs.statSync(filename); + downloadedBytes = stats.size; + itemTracking.bytes = stats.size; + + const now = Date.now(); + const timeDiff = (now - lastTime) / 1000; + const bytesDiff = downloadedBytes - lastBytes; + const speed = timeDiff > 0 ? bytesDiff / timeDiff : 0; + + runtimeMetrics.lastSpeedBytesPerSec = speed; + if (speed > 0) { + runtimeMetrics.avgSpeedBytesPerSec = runtimeMetrics.avgSpeedBytesPerSec <= 0 + ? speed + : (runtimeMetrics.avgSpeedBytesPerSec * 0.8) + (speed * 0.2); + } + + lastBytes = downloadedBytes; + lastTime = now; + + let etaStr = ''; + if (downloadedBytes > 0) { + const elapsedSec = (Date.now() - (itemTracking?.startTime || Date.now())) / 1000; + if (elapsedSec > 5 && lastStreamlinkPercent > 1) { + // Use streamlink's reported progress for accurate ETA + const remainingSec = (elapsedSec / lastStreamlinkPercent) * (100 - lastStreamlinkPercent); + if (remainingSec > 0 && remainingSec < 86400) { + etaStr = formatETA(remainingSec); + } + } + } + + // Bytes-basierte Schaetzung statt progress=-1, damit die Bar + // determinate bleibt + kontinuierlich waechst. Wenn streamlink + // spaeter eine echte % rausgibt (Path B), wird die ueber den + // bytes-Estimate gelegt (siehe lastStreamlinkPercent-Logik + // im stdout-handler). + // Quelle: endTime (--hls-duration arg) ODER expectedTotalSec + // Param (fuer Full-VOD wo Caller die Dauer kennt). + const expectedDurationSecForEstimate = parseClockDurationSeconds(endTime) || expectedTotalSec; + const expectedBytes = expectedDurationSecForEstimate > 0 ? expectedDurationSecForEstimate * 625_000 : 0; + let progressEstimate: number; + if (lastStreamlinkPercent > 0) { + // Streamlink hat % rausgegeben — vertrau dem (genauer als bytes). + progressEstimate = lastStreamlinkPercent; + } else if (expectedBytes > 0 && downloadedBytes > 0) { + // Bytes-Fallback: cap bei 95% damit der Bar nicht 100% + // vor dem tatsaechlichen Abschluss hinrennt. + progressEstimate = Math.min(95, (downloadedBytes / expectedBytes) * 100); + } else { + // Keine Info -> echtes Unknown, Bar geht in indeterminate. + progressEstimate = -1; + } + + onProgress({ + id: itemId, + progress: progressEstimate, + speed: formatSpeed(speed), + eta: etaStr, + status: tBackend('statusBytesDownloaded', { bytes: formatBytes(downloadedBytes) }), + currentPart: partNum, + totalParts: totalParts, + downloadedBytes: downloadedBytes, + speedBytesPerSec: speed + }); + } catch { } + } + }, 1000); + + const stdoutBuffer: string[] = []; + proc.stdout?.on('data', (data: Buffer) => { + const line = data.toString(); + // Capture non-progress lines auch fuer Diagnose — streamlink-Windows- + // Builds schreiben einige Errors auf stdout statt stderr (z.B. "error: + // No playable streams found on this URL"). Wenn stderr leer bleibt, + // greift der close-handler auf stdout zurueck. + stdoutBuffer.push(line); + if (stdoutBuffer.length > 200) stdoutBuffer.shift(); + const lower = line.toLowerCase(); + if (lower.includes('error:') || lower.includes('warning:')) { + appendDebugLog('download-part-stdout-err', { itemId, message: line.trim() }); + if (lower.includes('error:')) { + // Letzte echte streamlink-Errorzeile, auch wenn auf stdout + const errLine = line.split('\n').map(l => l.trim()).filter(l => l.toLowerCase().includes('error:')).pop(); + if (errLine) lastErrorLine = errLine; + } + } + + // Parse progress + const match = line.match(/(\d+\.\d+)%/); + if (match) { + const percent = parseFloat(match[1]); + lastStreamlinkPercent = percent; + onProgress({ + id: itemId, + progress: percent, + speed: '', + eta: '', + status: `${percent.toFixed(1)}%`, + currentPart: partNum, + totalParts: totalParts + }); + } + }); + + proc.stderr?.on('data', (data: Buffer) => { + const message = data.toString(); + if (message.trim()) { + stderrBuffer.push(message); + // Bounded buffer — wir wollen nicht 100MB stderr in RAM bei einem + // streamlink-loop. 200 chunks reichen fuer normale Diagnose. + if (stderrBuffer.length > 200) stderrBuffer.shift(); + // Letzte echte Errorzeile fuer User-Surface. "[ ... ] log lines" + // ueberspringen, "error: ..." bevorzugen damit nicht ein triviales + // INFO-Statement als User-facing-Fehler landet. + const lines = message.split('\n').map(l => l.trim()).filter(Boolean); + for (const line of lines) { + const lower = line.toLowerCase(); + if (lower.startsWith('error:') || lower.includes('error:')) { + lastErrorLine = line; + } else if (!lastErrorLine && line.length > 0 && !lower.startsWith('[')) { + // Fallback: jede non-bracket non-INFO Zeile + lastErrorLine = line; + } + } + appendDebugLog('download-part-stderr', { itemId, message: message.trim() }); + console.error('Streamlink error:', message); + } + }); + + proc.on('close', async (code) => { + clearInterval(progressInterval); + activeDownloads.delete(itemId); + + if (cancelledItemIds.has(itemId)) { + cancelledItemIds.delete(itemId); + appendDebugLog('download-part-cancelled', { itemId, filename }); + resolve({ success: false, error: tBackend('downloadCancelled') }); + return; + } + + if (code === 0 && fs.existsSync(filename)) { + const stats = fs.statSync(filename); + if (stats.size <= MIN_FILE_BYTES) { + const tooSmall = tBackend('fileTooSmall', { bytes: String(stats.size) }); + appendDebugLog('download-part-failed-small-file', { itemId, filename, bytes: stats.size }); + resolve({ success: false, error: tooSmall }); + return; + } + + const integrityResult = validateDownloadedFileIntegrity(filename, expectedDurationSeconds); + if (!integrityResult.success) { + appendDebugLog('download-part-failed-integrity', { + itemId, + filename, + bytes: stats.size, + error: integrityResult.error + }); + resolve(integrityResult); + return; + } + + runtimeMetrics.downloadedBytesTotal += stats.size; + appendDebugLog('download-part-success', { itemId, filename, bytes: stats.size }); + resolve({ success: true }); + return; + } + + // Volle stderr+stdout-History im Debug-Log fuer Forensik. + // Streamlink-Windows-Builds schreiben Errors gelegentlich auf + // stdout statt stderr ("No playable streams found on this URL" + // war historisch ein stdout-Error). Wir mergen beide Streams + // damit immer SICHTBAR ist was passiert. + const fullStderr = stderrBuffer.join('').trim(); + const fullStdout = stdoutBuffer.join('').trim(); + // Letzte Error-/Warning-Zeile aus beiden Streams suchen, falls + // lastErrorLine noch leer ist (z.B. weil streamlink ohne Output + // mit Code 1 exited — was bei pre-flight-Auth-Fails passiert). + let userFacingError = lastErrorLine; + if (!userFacingError) { + const combined = (fullStderr + '\n' + fullStdout).split('\n').map(l => l.trim()).filter(Boolean); + userFacingError = combined.filter(l => l.toLowerCase().includes('error:')).pop() + || combined.filter(l => !l.startsWith('[')).pop() + || ''; + } + if (!userFacingError) { + userFacingError = tBackend('streamlinkExitCode', { code: String(code ?? -1) }); + } + appendDebugLog('download-part-failed', { + itemId, filename, code, error: userFacingError, + stderrTail: fullStderr.slice(-2000), + stdoutTail: fullStdout.slice(-2000), + }); + resolve({ success: false, error: userFacingError }); + }); + + proc.on('error', (err) => { + clearInterval(progressInterval); + console.error('Process error:', err); + activeDownloads.delete(itemId); + const rawError = String(err); + const errorMessage = rawError.includes('ENOENT') + ? tBackend('streamlinkNotFound') + : rawError; + appendDebugLog('download-part-process-error', { itemId, error: errorMessage, rawError }); + resolve({ success: false, error: errorMessage }); + }); + }); +} + +// ========================================== +// AUTO-RECORD POLLER +// ========================================== +// Tracks the last-known live state of every streamer in +// config.auto_record_streamers. When a streamer transitions from +// offline -> live AND no live recording is already in flight for them, +// we auto-queue a live recording. Polling stops when no streamer has +// auto-record enabled. +const autoRecordLastLiveState = new Map(); +let autoRecordPollTimer: NodeJS.Timeout | null = null; +let autoRecordPollInFlight = false; +let autoRecordLastRunAt = 0; +let autoRecordNextRunAt = 0; +let autoRecordLastTriggerCount = 0; + +function stopAutoRecordPoller(): void { + if (autoRecordPollTimer) { + clearInterval(autoRecordPollTimer); + autoRecordPollTimer = null; + } +} + +function restartAutoRecordPoller(): void { + stopAutoRecordPoller(); + const list = Array.isArray(config.auto_record_streamers) ? config.auto_record_streamers : []; + if (list.length === 0) { + appendDebugLog('auto-record-poller-idle', { reason: 'no streamers' }); + return; + } + const seconds = normalizeAutoRecordPollSeconds(config.auto_record_poll_seconds); + appendDebugLog('auto-record-poller-start', { streamers: list.length, seconds }); + autoRecordPollTimer = setInterval(() => { void runAutoRecordPoll(); }, seconds * 1000); + autoRecordPollTimer.unref?.(); + autoRecordNextRunAt = Date.now() + seconds * 1000; + // Kick off an immediate first poll so a freshly-enabled streamer that's + // already live gets picked up without waiting a full interval. + setTimeout(() => { void runAutoRecordPoll(); }, 1500); +} + +async function runAutoRecordPoll(): Promise { + if (autoRecordPollInFlight) return 0; + autoRecordPollInFlight = true; + let triggered = 0; + try { + const list = Array.isArray(config.auto_record_streamers) ? [...config.auto_record_streamers] : []; + for (const streamer of list) { + // Check if list still contains streamer (config may have changed + // mid-iteration via save-config from the renderer). + if (!config.auto_record_streamers.includes(streamer)) continue; + + const info = await getLiveStreamInfo(streamer); + if (info === null) { + // Couldn't determine live state — skip this streamer this + // round. Don't update lastLiveState so a subsequent successful + // poll can still detect an offline->live transition cleanly. + continue; + } + + const wasLive = autoRecordLastLiveState.get(streamer) === true; + autoRecordLastLiveState.set(streamer, info.isLive); + + if (!info.isLive || wasLive) continue; + + // offline -> live transition. Don't double-record if a live item + // already exists in the queue (e.g. user manually triggered it). + const alreadyRecording = downloadQueue.some((it) => + it.isLive && it.streamer === streamer + && (it.status === 'pending' || it.status === 'downloading') + ); + if (alreadyRecording) { + appendDebugLog('auto-record-skip-already', { streamer }); + continue; + } + + const liveItem: QueueItem = { + id: generateQueueItemId(), + title: info.title || `${streamer} (LIVE)`, + url: `https://www.twitch.tv/${streamer}`, + date: new Date().toISOString(), + streamer, + duration_str: '0s', + status: 'pending', + progress: 0, + isLive: true + }; + downloadQueue.push(liveItem); + saveQueue(downloadQueue); + emitQueueUpdated(); + triggered++; + appendDebugLog('auto-record-triggered', { streamer, title: liveItem.title }); + + if (!isDownloading) { + void processQueue(); + } + } + } catch (e) { + appendDebugLog('auto-record-poll-failed', String(e)); + } finally { + autoRecordPollInFlight = false; + autoRecordLastRunAt = Date.now(); + autoRecordLastTriggerCount = triggered; + const seconds = normalizeAutoRecordPollSeconds(config.auto_record_poll_seconds); + autoRecordNextRunAt = Date.now() + seconds * 1000; + } + return triggered; +} + +// ========================================== +// AUTO-VOD-DOWNLOAD POLLER +// ========================================== +// Periodically scans VOD listings of opted-in streamers and auto-queues +// any VOD that's (a) recent enough to be in scope, (b) not already +// downloaded, and (c) not already in the active queue. Cadence is +// minutes, not seconds — a VOD-listing scan is much heavier than a +// live-status check, and new VODs only appear after a stream ends, so +// minute-level lag is fine. +let autoVodPollTimer: NodeJS.Timeout | null = null; +let autoVodPollInFlight = false; +let autoVodLastRunAt = 0; +let autoVodNextRunAt = 0; +let autoVodLastQueuedCount = 0; + +function stopAutoVodPoller(): void { + if (autoVodPollTimer) { + clearInterval(autoVodPollTimer); + autoVodPollTimer = null; + } +} + +function restartAutoVodPoller(): void { + stopAutoVodPoller(); + const list = Array.isArray(config.auto_vod_download_streamers) ? config.auto_vod_download_streamers : []; + if (list.length === 0) { + appendDebugLog('auto-vod-poller-idle', { reason: 'no streamers' }); + return; + } + const minutes = (() => { + const n = Number(config.auto_vod_download_poll_minutes); + if (!Number.isFinite(n)) return 15; + return Math.max(5, Math.min(360, Math.floor(n))); + })(); + appendDebugLog('auto-vod-poller-start', { streamers: list.length, minutes }); + autoVodPollTimer = setInterval(() => { void runAutoVodPoll(); }, minutes * 60 * 1000); + autoVodPollTimer.unref?.(); + autoVodNextRunAt = Date.now() + minutes * 60 * 1000; + setTimeout(() => { void runAutoVodPoll(); }, 5000); +} + +async function runAutoVodPoll(): Promise { + if (autoVodPollInFlight) return 0; + autoVodPollInFlight = true; + let queuedCount = 0; + try { + const list = Array.isArray(config.auto_vod_download_streamers) ? [...config.auto_vod_download_streamers] : []; + if (list.length === 0) return 0; + + const maxAgeHours = (() => { + const n = Number(config.auto_vod_max_age_hours); + if (!Number.isFinite(n)) return 24; + return Math.max(1, Math.min(720, Math.floor(n))); + })(); + const cutoffMs = Date.now() - maxAgeHours * 3600 * 1000; + + const downloadedSet = new Set(Array.isArray(config.downloaded_vod_ids) ? config.downloaded_vod_ids : []); + const queuedUrls = new Set(downloadQueue.map((it) => it.url)); + + for (const streamer of list) { + if (!config.auto_vod_download_streamers.includes(streamer)) continue; + + const userId = await getUserId(streamer); + if (!userId) { + appendDebugLog('auto-vod-skip-no-user', { streamer }); + continue; + } + + let vods: VOD[] = []; + try { + vods = await getVODs(userId, true); + } catch (e) { + appendDebugLog('auto-vod-list-failed', { streamer, error: String(e) }); + continue; + } + if (!Array.isArray(vods) || vods.length === 0) continue; + + for (const vod of vods) { + if (!vod || !vod.id || !vod.url) continue; + if (downloadedSet.has(vod.id)) continue; + if (queuedUrls.has(vod.url)) continue; + + const createdMs = Date.parse(vod.created_at || ''); + if (!Number.isFinite(createdMs) || createdMs < cutoffMs) continue; + + const queueItem: QueueItem = { + id: generateQueueItemId(), + title: vod.title || `${streamer} VOD ${vod.id}`, + url: vod.url, + date: vod.created_at, + streamer, + duration_str: vod.duration || '', + status: 'pending', + progress: 0 + }; + downloadQueue.push(queueItem); + queuedUrls.add(vod.url); + queuedCount++; + appendDebugLog('auto-vod-queued', { streamer, vodId: vod.id, title: queueItem.title }); + + if (config.discord_notify_vod_auto_queued) { + try { + await sendDiscordWebhook({ + title: 'New VOD auto-queued', + description: `\`${streamer}\` published a new VOD — queued for download.`, + color: 'info', + fields: [ + { name: 'Title', value: queueItem.title, inline: false }, + { name: 'VOD ID', value: String(vod.id), inline: true }, + { name: 'URL', value: vod.url, inline: false } + ] + }); + } catch (_) { /* ignore webhook errors */ } + } + } + } + + saveQueue(downloadQueue); + emitQueueUpdated(); + + if (!isDownloading && downloadQueue.some((it) => it.status === 'pending')) { + void processQueue(); + } + } catch (e) { + appendDebugLog('auto-vod-poll-failed', String(e)); + } finally { + autoVodPollInFlight = false; + autoVodLastRunAt = Date.now(); + autoVodLastQueuedCount = queuedCount; + const minutes = (() => { + const n = Number(config.auto_vod_download_poll_minutes); + if (!Number.isFinite(n)) return 15; + return Math.max(5, Math.min(360, Math.floor(n))); + })(); + autoVodNextRunAt = Date.now() + minutes * 60 * 1000; + if (queuedCount > 0 && mainWindow) { + mainWindow.webContents.send('auto-vod-scan-completed', { queuedCount }); + } + } + return queuedCount; +} + +// ========================================== +// LIVE STATUS BATCH POLLER — for the sidebar live indicators +// ========================================== +// Background poller that asks "which of these streamers are live right +// now?" for every streamer in the user's list, in a single GQL roundtrip +// (per chunk of 50). Results are stamped into liveStatusByLogin and +// pushed to the renderer so the sidebar gets a red pulsing dot next to +// anyone currently broadcasting. Independent from the auto-record +// poller — that one only watches a small subset and needs title/game, +// this one just needs the boolean and covers everyone. +const liveStatusByLogin = new Map(); +let liveStatusPollTimer: NodeJS.Timeout | null = null; +let liveStatusPollInFlight = false; +const LIVE_STATUS_POLL_INTERVAL_MS = 60_000; +const LIVE_STATUS_BATCH_CHUNK_SIZE = 50; + +async function fetchLiveStatusBatch(logins: string[]): Promise> { + const result = new Map(); + if (logins.length === 0) return result; + + for (let i = 0; i < logins.length; i += LIVE_STATUS_BATCH_CHUNK_SIZE) { + const chunk = logins.slice(i, i + LIVE_STATUS_BATCH_CHUNK_SIZE); + const vars: Record = {}; + const varDecls: string[] = []; + const aliases: string[] = []; + chunk.forEach((login, idx) => { + const varName = `l${idx}`; + vars[varName] = login; + varDecls.push(`$${varName}:String!`); + aliases.push(`u${idx}:user(login:$${varName}){login stream{type}}`); + }); + const query = `query(${varDecls.join(',')}){${aliases.join(' ')}}`; + try { + const data = await fetchPublicTwitchGql>( + query, vars + ); + if (!data) continue; + for (const key of Object.keys(data)) { + const user = data[key]; + if (!user || !user.login) continue; + result.set(normalizeLogin(user.login), user.stream?.type === 'live'); + } + } catch (e) { + appendDebugLog('live-status-batch-failed', { chunkStart: i, error: String(e) }); + } + } + return result; +} + +async function runLiveStatusBatchPoll(): Promise { + if (liveStatusPollInFlight) return; + liveStatusPollInFlight = true; + try { + const logins = ((config.streamers as string[]) || []) + .map((s) => normalizeLogin(s)) + .filter((s): s is string => Boolean(s)); + + const changes: Array<{ login: string; isLive: boolean }> = []; + const watchedSet = new Set(logins); + + // Always run the eviction pass FIRST — entries left over from a + // streamer that's no longer in the watch list must go regardless + // of whether we're about to fetch fresh data. Previously this + // ran inside the fetch branch only, so removing the last + // streamer left ghost entries in liveStatusByLogin until the + // next add. + for (const oldLogin of Array.from(liveStatusByLogin.keys())) { + if (!watchedSet.has(oldLogin)) { + liveStatusByLogin.delete(oldLogin); + changes.push({ login: oldLogin, isLive: false }); + } + } + + if (logins.length > 0) { + const fresh = await fetchLiveStatusBatch(logins); + for (const [login, isLive] of fresh.entries()) { + const prev = liveStatusByLogin.get(login); + if (prev !== isLive) changes.push({ login, isLive }); + liveStatusByLogin.set(login, isLive); + } + } + + if (mainWindow && changes.length > 0) { + // Renderer only consumes `changes` — initial state comes via + // the get-live-status-snapshot IPC at boot. Don't ship the + // full map on every tick (was ~1.5KB JSON per 60s with zero + // consumer-side use). Also skip the broadcast entirely when + // nothing actually changed. + mainWindow.webContents.send('live-status-batch-update', { changes }); + } + } catch (e) { + appendDebugLog('live-status-poll-failed', String(e)); + } finally { + liveStatusPollInFlight = false; + } +} + +function stopLiveStatusPoller(): void { + if (liveStatusPollTimer) { + clearInterval(liveStatusPollTimer); + liveStatusPollTimer = null; + } +} + +function restartLiveStatusPoller(): void { + stopLiveStatusPoller(); + liveStatusPollTimer = setInterval(() => { void runLiveStatusBatchPoll(); }, LIVE_STATUS_POLL_INTERVAL_MS); + liveStatusPollTimer.unref?.(); + setTimeout(() => { void runLiveStatusBatchPoll(); }, 1500); +} + +// ========================================== +// CHAT REPLAY DOWNLOAD +// ========================================== +// Twitch retains chat replay alongside the VOD itself — same 7-60 day TTL. +// Anyone archiving the video usually wants the chat too. fetchVodChatReplay +// pulls the entire chat for a VOD via the public GQL endpoint, paginated +// via edge cursors (Twitch returns ~100 comments per page). +interface ChatReplayMessage { + id: string; + offset: number; // contentOffsetSeconds — when in the VOD + createdAt: string; // ISO timestamp + user: string; // display name + login: string; // login (lowercase) + color: string; // user chat color + text: string; // assembled message text +} + +interface ChatReplayResult { + messages: ChatReplayMessage[]; + truncated: boolean; + pages: number; +} + +async function fetchVodChatReplay( + videoId: string, + onProgress?: (count: number) => void, + cancelCheck?: () => boolean +): Promise { + const messages: ChatReplayMessage[] = []; + let cursor: string | null = null; + let pages = 0; + let truncated = false; + // Hard cap to keep one runaway stream from filling memory. 200 pages = + // ~20k messages which covers typical 6-hour streams. Above that we + // stop and mark truncated. + const MAX_PAGES = 500; + + type CommentNode = { + id: string; + contentOffsetSeconds: number; + createdAt: string; + message?: { fragments?: Array<{ text?: string }>; userColor?: string }; + commenter?: { displayName?: string; login?: string }; + }; + type CommentEdge = { node: CommentNode; cursor: string }; + type CommentsPage = { + video: { comments: { edges: CommentEdge[]; pageInfo: { hasNextPage: boolean } } } | null; + }; + + const query = 'query($videoID:ID!,$cursor:Cursor){video(id:$videoID){comments(contentOffsetSeconds:0,cursor:$cursor){edges{node{id contentOffsetSeconds createdAt message{fragments{text} userColor} commenter{displayName login}} cursor} pageInfo{hasNextPage}}}}'; + + while (pages < MAX_PAGES) { + if (cancelCheck && cancelCheck()) { + truncated = true; + break; + } + const data: CommentsPage | null = await fetchPublicTwitchGql(query, { + videoID: videoId, + cursor + }); + if (!data || !data.video || !data.video.comments) break; + + const edges: CommentEdge[] = Array.isArray(data.video.comments.edges) ? data.video.comments.edges : []; + for (const edge of edges) { + const node = edge.node; + const fragments = node.message?.fragments || []; + const text = fragments.map((f: { text?: string }) => (typeof f.text === 'string' ? f.text : '')).join(''); + messages.push({ + id: node.id, + offset: Number(node.contentOffsetSeconds) || 0, + createdAt: node.createdAt || '', + user: node.commenter?.displayName || '', + login: node.commenter?.login || '', + color: node.message?.userColor || '', + text + }); + } + + pages += 1; + if (onProgress) onProgress(messages.length); + + const last: CommentEdge | undefined = edges[edges.length - 1]; + if (!data.video.comments.pageInfo.hasNextPage || !last) break; + cursor = last.cursor; + } + + if (pages >= MAX_PAGES) truncated = true; + return { messages, truncated, pages }; +} + +function chatReplayPathFor(vodFilePath: string): string { + // Strip the final extension and append .chat.json so the chat file + // lives next to the video and is easy to find. + const ext = path.extname(vodFilePath); + const base = ext ? vodFilePath.slice(0, -ext.length) : vodFilePath; + return `${base}.chat.json`; +} + +// ========================================== +// AUTO-CLEANUP +// ========================================== +// Targets old recording artifacts (.mp4/.ts/.mkv plus their sibling +// .chat.json/.chat.jsonl) older than auto_cleanup_days. Two scopes — +// live_only (only files inside a streamer/live/ subfolder, set-and- +// forget for auto-record users) or all (everything under the streamer +// folders). Two actions — delete or archive (move to a parallel +// archived/{streamer}/{YYYY-MM}/ tree). Archive is the safer default. +// Sibling chat files travel with the video so we don't end up with +// an orphan transcript. +interface CleanupCandidate { + videoPath: string; + sidecarPaths: string[]; + streamer: string; + bytes: number; + ageDays: number; +} +interface CleanupReport { + enabled: boolean; + dryRun: boolean; + cutoffDays: number; + target: 'live_only' | 'all'; + action: 'delete' | 'archive'; + scannedAt: string; + candidates: number; + processed: number; + failed: number; + bytesFreed: number; + failures: Array<{ path: string; error: string }>; +} + +const VIDEO_FILE_REGEX = /\.(mp4|ts|mkv|mov|avi)$/i; + +function findCleanupCandidates(cutoffDays: number, target: 'live_only' | 'all'): CleanupCandidate[] { + const out: CleanupCandidate[] = []; + const root = config.download_path; + if (!root || !fs.existsSync(root)) return out; + const cutoffMs = Date.now() - cutoffDays * 24 * 60 * 60 * 1000; + const knownStreamers = new Set(((config.streamers as string[]) || []).map((s) => s.toLowerCase())); + + let topEntries: fs.Dirent[]; + try { + topEntries = fs.readdirSync(root, { withFileTypes: true }); + } catch { + return out; + } + + const visit = (dir: string, streamer: string, mustBeUnderLive: boolean): void => { + let entries: fs.Dirent[]; + try { + entries = fs.readdirSync(dir, { withFileTypes: true }); + } catch { + return; + } + for (const entry of entries) { + const full = path.join(dir, entry.name); + if (entry.isDirectory()) { + // Never walk back into the archived/ tree we own. + if (entry.name === 'archived') continue; + const enteringLive = entry.name === 'live'; + visit(full, streamer, mustBeUnderLive && !enteringLive); + continue; + } + if (!entry.isFile()) continue; + if (!VIDEO_FILE_REGEX.test(entry.name)) continue; + if (mustBeUnderLive) continue; // live_only mode + we're not under live/ + + let stat: fs.Stats; + try { + stat = fs.statSync(full); + } catch { + continue; + } + if (stat.mtimeMs > cutoffMs) continue; + + // Find sibling chat files (same basename, .chat.json / .chat.jsonl) + const ext = path.extname(full); + const base = ext ? full.slice(0, -ext.length) : full; + const sidecars: string[] = []; + for (const sidecarExt of ['.chat.json', '.chat.jsonl']) { + const candidate = base + sidecarExt; + if (fs.existsSync(candidate)) sidecars.push(candidate); + } + + out.push({ + videoPath: full, + sidecarPaths: sidecars, + streamer, + bytes: stat.size, + ageDays: Math.floor((Date.now() - stat.mtimeMs) / (24 * 60 * 60 * 1000)) + }); + } + }; + + for (const top of topEntries) { + if (!top.isDirectory()) continue; + if (top.name === 'archived') continue; // never recurse into the archive tree + const lowered = top.name.toLowerCase(); + const isKnown = knownStreamers.has(lowered) || top.name === 'Clips'; + if (!isKnown) continue; + const folderPath = path.join(root, top.name); + // For live_only mode, we descend with mustBeUnderLive=true; the + // visit() call flips it to false the moment we enter a "live" + // subfolder. For "all" mode, mustBeUnderLive is false from the + // top so every video matches. + visit(folderPath, top.name, target === 'live_only'); + } + + return out; +} + +function archivePathForCleanup(streamer: string, originalPath: string, mtimeMs: number): string { + const root = config.download_path; + const date = new Date(mtimeMs); + const monthKey = `${date.getFullYear()}-${(date.getMonth() + 1).toString().padStart(2, '0')}`; + const dir = path.join(root, 'archived', streamer, monthKey); + fs.mkdirSync(dir, { recursive: true }); + return ensureUniqueFilename(path.join(dir, path.basename(originalPath)), null); +} + +function runStorageCleanup(opts: { dryRun: boolean }): CleanupReport { + const report: CleanupReport = { + enabled: config.auto_cleanup_enabled === true, + dryRun: opts.dryRun, + cutoffDays: Number(config.auto_cleanup_days) || 30, + target: config.auto_cleanup_target === 'all' ? 'all' : 'live_only', + action: config.auto_cleanup_action === 'delete' ? 'delete' : 'archive', + scannedAt: new Date().toISOString(), + candidates: 0, + processed: 0, + failed: 0, + bytesFreed: 0, + failures: [] + }; + + const candidates = findCleanupCandidates(report.cutoffDays, report.target); + report.candidates = candidates.length; + if (opts.dryRun) { + for (const c of candidates) { + report.bytesFreed += c.bytes; + for (const sc of c.sidecarPaths) { + try { report.bytesFreed += fs.statSync(sc).size; } catch { /* ignore */ } + } + } + appendDebugLog('storage-cleanup-dry-run', { candidates: report.candidates, bytes: report.bytesFreed }); + return report; + } + + for (const c of candidates) { + const allPaths = [c.videoPath, ...c.sidecarPaths]; + try { + if (report.action === 'delete') { + for (const p of allPaths) { + let bytes = 0; + try { bytes = fs.statSync(p).size; } catch { /* ignore */ } + fs.unlinkSync(p); + report.bytesFreed += bytes; + } + } else { + // Archive: keep the same basename, group by streamer + month. + const stat = fs.statSync(c.videoPath); + const archived = archivePathForCleanup(c.streamer, c.videoPath, stat.mtimeMs); + fs.renameSync(c.videoPath, archived); + report.bytesFreed += stat.size; + // Move sidecars to the same archive folder. + const archDir = path.dirname(archived); + for (const sc of c.sidecarPaths) { + try { + const dest = ensureUniqueFilename(path.join(archDir, path.basename(sc)), null); + fs.renameSync(sc, dest); + } catch (err) { + report.failures.push({ path: sc, error: String(err) }); + } + } + } + report.processed += 1; + } catch (err) { + report.failed += 1; + report.failures.push({ path: c.videoPath, error: String(err) }); + } + } + + appendDebugLog('storage-cleanup-run', { + candidates: report.candidates, + processed: report.processed, + failed: report.failed, + bytes: report.bytesFreed, + action: report.action, + target: report.target + }); + return report; +} + +let autoCleanupTimer: NodeJS.Timeout | null = null; +let lastAutoCleanupAt = 0; + +function stopAutoCleanupTimer(): void { + if (autoCleanupTimer) { + clearInterval(autoCleanupTimer); + autoCleanupTimer = null; + } +} + +function restartAutoCleanupTimer(): void { + stopAutoCleanupTimer(); + if (!config.auto_cleanup_enabled) return; + // Run every 6 hours while the app is running. Skip the first cycle if + // the previous run was less than 6h ago to avoid hammering on every + // settings save. + const SIX_HOURS_MS = 6 * 60 * 60 * 1000; + autoCleanupTimer = setInterval(() => { + if (Date.now() - lastAutoCleanupAt < SIX_HOURS_MS) return; + lastAutoCleanupAt = Date.now(); + try { runStorageCleanup({ dryRun: false }); } catch (e) { appendDebugLog('auto-cleanup-failed', String(e)); } + }, SIX_HOURS_MS); + autoCleanupTimer.unref?.(); + + // First run is delayed 60s so it doesn't compete with startup IO. + setTimeout(() => { + if (!config.auto_cleanup_enabled) return; + if (Date.now() - lastAutoCleanupAt < 60 * 1000) return; + lastAutoCleanupAt = Date.now(); + try { runStorageCleanup({ dryRun: false }); } catch (e) { appendDebugLog('auto-cleanup-failed', String(e)); } + }, 60 * 1000); +} + +// ========================================== +// STORAGE STATS +// ========================================== +// Walks the download folder once on demand and reports per-streamer disk +// usage so the user can see which streamers are eating their archive +// budget. Only enumerates direct subfolders that match a known streamer +// name (from config.streamers) plus a special "Clips" bucket. Refusing +// to recurse the entire filesystem means a user with a huge unrelated +// download_path doesn't pay for it here. +interface StreamerStorageEntry { + name: string; + fileCount: number; + totalBytes: number; + liveBytes: number; + chatBytes: number; + folderPath: string; +} +interface StorageStatsResult { + downloadPath: string; + rootExists: boolean; + freeBytes: number | null; + totalFiles: number; + totalBytes: number; + streamers: StreamerStorageEntry[]; + extras: StreamerStorageEntry[]; + scannedAt: string; +} + +function walkFolderForStats(folderPath: string): { files: number; bytes: number; liveBytes: number; chatBytes: number } { + const result = { files: 0, bytes: 0, liveBytes: 0, chatBytes: 0 }; + let entries: fs.Dirent[]; + try { + entries = fs.readdirSync(folderPath, { withFileTypes: true }); + } catch { + return result; + } + for (const entry of entries) { + const full = path.join(folderPath, entry.name); + try { + if (entry.isDirectory()) { + const sub = walkFolderForStats(full); + result.files += sub.files; + result.bytes += sub.bytes; + if (entry.name === 'live') { + result.liveBytes += sub.bytes; + } + } else if (entry.isFile()) { + const st = fs.statSync(full); + result.files += 1; + result.bytes += st.size; + if (/\.chat\.json(l)?$/i.test(entry.name)) { + result.chatBytes += st.size; + } + } + } catch { + // Symlink / permissions blip — skip the entry, continue. + } + } + return result; +} + +function computeStorageStats(): StorageStatsResult { + const root = config.download_path; + const result: StorageStatsResult = { + downloadPath: root, + rootExists: false, + freeBytes: null, + totalFiles: 0, + totalBytes: 0, + streamers: [], + extras: [], + scannedAt: new Date().toISOString() + }; + + if (!root || !fs.existsSync(root)) return result; + result.rootExists = true; + result.freeBytes = getFreeDiskBytes(root); + + const knownStreamers = new Set( + ((config.streamers as string[]) || []).map((s) => s.toLowerCase()) + ); + + let topEntries: fs.Dirent[]; + try { + topEntries = fs.readdirSync(root, { withFileTypes: true }); + } catch { + return result; + } + + for (const entry of topEntries) { + if (!entry.isDirectory()) continue; + const full = path.join(root, entry.name); + const safeName = entry.name.replace(/[^a-zA-Z0-9_-]/g, ''); + const isKnownStreamer = knownStreamers.has(safeName.toLowerCase()); + // Treat Clips/ + anything that matches known streamers as a tracked + // bucket; everything else (random user folders) lives in `extras`. + const sub = walkFolderForStats(full); + const stats: StreamerStorageEntry = { + name: entry.name, + fileCount: sub.files, + totalBytes: sub.bytes, + liveBytes: sub.liveBytes, + chatBytes: sub.chatBytes, + folderPath: full + }; + if (isKnownStreamer || entry.name === 'Clips') { + result.streamers.push(stats); + } else { + result.extras.push(stats); + } + result.totalFiles += sub.files; + result.totalBytes += sub.bytes; + } + + // Largest first — that's what the user wants to see. + result.streamers.sort((a, b) => b.totalBytes - a.totalBytes); + result.extras.sort((a, b) => b.totalBytes - a.totalBytes); + return result; +} + +// ========================================== +// ARCHIVE STATS — DASHBOARD AGGREGATION +// ========================================== +interface ArchiveStatsTopStreamer { + streamer: string; + bytes: number; + fileCount: number; + liveBytes: number; + vodBytes: number; + chatBytes: number; +} +interface ArchiveStatsDay { date: string; count: number; bytes: number } +interface ArchiveStatsBucket { label: string; count: number; bytes: number } +interface ArchiveStats { + totalFiles: number; + totalBytes: number; + liveCount: number; + liveBytes: number; + vodCount: number; + vodBytes: number; + chatCount: number; + chatBytes: number; + eventsCount: number; + streamerCount: number; + avgRecordingSizeBytes: number; + topStreamers: ArchiveStatsTopStreamer[]; + dailyActivity: ArchiveStatsDay[]; + sizeBuckets: ArchiveStatsBucket[]; + scannedAt: string; + downloadPath: string; + rootExists: boolean; +} + +const SIZE_BUCKETS: Array<{ label: string; min: number; max: number }> = [ + { label: '< 100 MB', min: 0, max: 100 * 1024 * 1024 }, + { label: '100 MB - 500 MB', min: 100 * 1024 * 1024, max: 500 * 1024 * 1024 }, + { label: '500 MB - 1 GB', min: 500 * 1024 * 1024, max: 1024 * 1024 * 1024 }, + { label: '1 GB - 5 GB', min: 1024 * 1024 * 1024, max: 5 * 1024 * 1024 * 1024 }, + { label: '5 GB - 10 GB', min: 5 * 1024 * 1024 * 1024, max: 10 * 1024 * 1024 * 1024 }, + { label: '> 10 GB', min: 10 * 1024 * 1024 * 1024, max: Number.POSITIVE_INFINITY } +]; + +type ArchiveFileType = 'live' | 'vod' | 'chat' | 'events' | 'other'; + +function classifyArchiveFile(relativePath: string): ArchiveFileType { + if (/\.chat\.jsonl?$/i.test(relativePath)) return 'chat'; + if (/\.events\.jsonl$/i.test(relativePath)) return 'events'; + const norm = relativePath.replace(/\\/g, '/').toLowerCase(); + if (norm.startsWith('live/')) return 'live'; + if (/\.(mp4|mkv|ts|m4v)$/i.test(relativePath)) return 'vod'; + return 'other'; +} + +function extractFilenameDate(name: string): string | null { + const m = /(\d{4})-(\d{2})-(\d{2})/.exec(name); + if (!m) return null; + return `${m[1]}-${m[2]}-${m[3]}`; +} + +function bucketIndexForSize(bytes: number): number { + for (let i = 0; i < SIZE_BUCKETS.length; i++) { + if (bytes < SIZE_BUCKETS[i].max) return i; + } + return SIZE_BUCKETS.length - 1; +} + +interface ArchiveFileRecord { size: number; mtimeMs: number; type: ArchiveFileType; date: string } + +function walkForArchiveStats( + folderPath: string, + relPrefix: string, + accum: { files: ArchiveFileRecord[] } +): void { + let entries: fs.Dirent[]; + try { + entries = fs.readdirSync(folderPath, { withFileTypes: true }); + } catch { + return; + } + for (const entry of entries) { + const full = path.join(folderPath, entry.name); + const rel = relPrefix ? `${relPrefix}/${entry.name}` : entry.name; + try { + if (entry.isDirectory()) { + walkForArchiveStats(full, rel, accum); + } else if (entry.isFile()) { + const st = fs.statSync(full); + const type = classifyArchiveFile(rel); + const dateFromName = extractFilenameDate(entry.name); + const date = dateFromName || new Date(st.mtimeMs).toISOString().slice(0, 10); + accum.files.push({ size: st.size, mtimeMs: st.mtimeMs, type, date }); + } + } catch { /* permission blip — skip */ } + } +} + +// Search a single file matches the live query. Empty query matches all. +// streamerFolder is the top-level directory under root (which we equate +// with the channel name); relativePath is everything below that. +interface ArchiveSearchFilter { + query: string; + type: 'all' | 'live' | 'vod' | 'chat' | 'events'; + streamer: string; + sinceMs: number | null; + untilMs: number | null; + sort: 'date_desc' | 'date_asc' | 'size_desc' | 'size_asc' | 'name_asc'; + limit: number; +} + +interface ArchiveSearchHit { + fullPath: string; + fileName: string; + streamer: string; + type: ArchiveFileType; + size: number; + mtimeMs: number; + chatPath: string | null; + eventsPath: string | null; +} + +interface ArchiveSearchResult { + totalScanned: number; + matchCount: number; + truncated: boolean; + hits: ArchiveSearchHit[]; + scannedAt: string; + rootExists: boolean; +} + +function matchSearchFilter( + streamerFolder: string, + relativePath: string, + fileName: string, + fileSize: number, + mtimeMs: number, + type: ArchiveFileType, + filter: ArchiveSearchFilter +): boolean { + if (filter.type !== 'all' && filter.type !== type) return false; + if (filter.streamer && streamerFolder.toLowerCase() !== filter.streamer.toLowerCase()) return false; + if (filter.sinceMs !== null && mtimeMs < filter.sinceMs) return false; + if (filter.untilMs !== null && mtimeMs > filter.untilMs) return false; + if (filter.query) { + const q = filter.query.toLowerCase(); + const hay = `${fileName} ${streamerFolder} ${relativePath}`.toLowerCase(); + if (!hay.includes(q)) return false; + } + return true; +} + +function searchArchive(filter: ArchiveSearchFilter): ArchiveSearchResult { + const root = config.download_path; + const result: ArchiveSearchResult = { + totalScanned: 0, + matchCount: 0, + truncated: false, + hits: [], + scannedAt: new Date().toISOString(), + rootExists: false + }; + if (!root || !fs.existsSync(root)) return result; + result.rootExists = true; + + const maxHits = Math.max(10, Math.min(2000, Math.floor(filter.limit) || 200)); + + let topEntries: fs.Dirent[]; + try { + topEntries = fs.readdirSync(root, { withFileTypes: true }); + } catch { + return result; + } + + // To attach chat/events sibling paths to a recording hit, we collect + // every file in a streamer's tree first, then make a second pass to + // pair up companions by stripping the .mp4 base. + for (const entry of topEntries) { + if (!entry.isDirectory()) continue; + const streamerFolder = entry.name; + const streamerRoot = path.join(root, streamerFolder); + const filesInTree: Array<{ fullPath: string; rel: string; name: string; size: number; mtimeMs: number; type: ArchiveFileType }> = []; + const accum: { files: ArchiveFileRecord[] } = { files: [] }; + // We re-walk here instead of reusing walkForArchiveStats because + // we need the full path + rel path on each file, not just the + // type/size aggregates. The cost is one redundant tree walk per + // search; acceptable for an interactive search. + const walkWithPaths = (folderPath: string, relPrefix: string): void => { + let entries2: fs.Dirent[]; + try { + entries2 = fs.readdirSync(folderPath, { withFileTypes: true }); + } catch { return; } + for (const e2 of entries2) { + const full = path.join(folderPath, e2.name); + const rel = relPrefix ? `${relPrefix}/${e2.name}` : e2.name; + try { + if (e2.isDirectory()) { + walkWithPaths(full, rel); + } else if (e2.isFile()) { + const st = fs.statSync(full); + const type = classifyArchiveFile(rel); + filesInTree.push({ fullPath: full, rel, name: e2.name, size: st.size, mtimeMs: st.mtimeMs, type }); + } + } catch { /* skip */ } + } + }; + walkWithPaths(streamerRoot, ''); + + if (filesInTree.length === 0) continue; + result.totalScanned += filesInTree.length; + + // Build a quick lookup so a recording file can attach its sibling + // .chat.* and .events.jsonl by stripping the .mp4/.mkv extension. + const companionByBase = new Map(); + for (const f of filesInTree) { + if (f.type !== 'chat' && f.type !== 'events') continue; + // Strip companion suffix to get the base name shared with the + // recording: foo.mp4 + foo.chat.jsonl + foo.events.jsonl. + const base = f.fullPath.replace(/\.chat\.jsonl?$/i, '').replace(/\.events\.jsonl$/i, ''); + const existing = companionByBase.get(base) || { chat: null, events: null }; + if (f.type === 'chat') existing.chat = f.fullPath; + else if (f.type === 'events') existing.events = f.fullPath; + companionByBase.set(base, existing); + } + + for (const f of filesInTree) { + // We only surface recordings (live/vod) as search hits — chat + // and events files attach as companions and don't appear as + // standalone rows. Users searching for chat usually want the + // recording it belongs to anyway. + if (f.type !== 'live' && f.type !== 'vod') continue; + if (!matchSearchFilter(streamerFolder, f.rel, f.name, f.size, f.mtimeMs, f.type, filter)) continue; + + const recordingBase = f.fullPath.replace(/\.(mp4|mkv|ts|m4v)$/i, ''); + const companions = companionByBase.get(recordingBase) || { chat: null, events: null }; + + result.hits.push({ + fullPath: f.fullPath, + fileName: f.name, + streamer: streamerFolder, + type: f.type, + size: f.size, + mtimeMs: f.mtimeMs, + chatPath: companions.chat, + eventsPath: companions.events + }); + result.matchCount++; + } + } + + // Sort then truncate. We sort the FULL match set (not the truncated + // one) so the user gets the genuinely largest/newest results, not + // arbitrary order. + const cmp: Record number> = { + date_desc: (a, b) => b.mtimeMs - a.mtimeMs, + date_asc: (a, b) => a.mtimeMs - b.mtimeMs, + size_desc: (a, b) => b.size - a.size, + size_asc: (a, b) => a.size - b.size, + name_asc: (a, b) => a.fileName.localeCompare(b.fileName) + }; + result.hits.sort(cmp[filter.sort] || cmp.date_desc); + if (result.hits.length > maxHits) { + result.truncated = true; + result.hits = result.hits.slice(0, maxHits); + } + + return result; +} + +function computeArchiveStats(): ArchiveStats { + const root = config.download_path; + const stats: ArchiveStats = { + totalFiles: 0, + totalBytes: 0, + liveCount: 0, + liveBytes: 0, + vodCount: 0, + vodBytes: 0, + chatCount: 0, + chatBytes: 0, + eventsCount: 0, + streamerCount: 0, + avgRecordingSizeBytes: 0, + topStreamers: [], + dailyActivity: [], + sizeBuckets: SIZE_BUCKETS.map((b) => ({ label: b.label, count: 0, bytes: 0 })), + scannedAt: new Date().toISOString(), + downloadPath: root || '', + rootExists: false + }; + if (!root || !fs.existsSync(root)) return stats; + stats.rootExists = true; + + let topEntries: fs.Dirent[]; + try { + topEntries = fs.readdirSync(root, { withFileTypes: true }); + } catch { + return stats; + } + + const perStreamer = new Map(); + const dailyMap = new Map(); + let recordingCount = 0; + let recordingBytes = 0; + + for (const entry of topEntries) { + if (!entry.isDirectory()) continue; + const streamerFolder = entry.name; + const full = path.join(root, streamerFolder); + const accum: { files: ArchiveFileRecord[] } = { files: [] }; + walkForArchiveStats(full, '', accum); + if (accum.files.length === 0) continue; + + const ts: ArchiveStatsTopStreamer = { + streamer: streamerFolder, + bytes: 0, + fileCount: 0, + liveBytes: 0, + vodBytes: 0, + chatBytes: 0 + }; + + for (const f of accum.files) { + stats.totalFiles++; + stats.totalBytes += f.size; + ts.fileCount++; + ts.bytes += f.size; + + if (f.type === 'live') { + stats.liveCount++; + stats.liveBytes += f.size; + ts.liveBytes += f.size; + recordingCount++; + recordingBytes += f.size; + stats.sizeBuckets[bucketIndexForSize(f.size)].count++; + stats.sizeBuckets[bucketIndexForSize(f.size)].bytes += f.size; + } else if (f.type === 'vod') { + stats.vodCount++; + stats.vodBytes += f.size; + ts.vodBytes += f.size; + recordingCount++; + recordingBytes += f.size; + stats.sizeBuckets[bucketIndexForSize(f.size)].count++; + stats.sizeBuckets[bucketIndexForSize(f.size)].bytes += f.size; + } else if (f.type === 'chat') { + stats.chatCount++; + stats.chatBytes += f.size; + ts.chatBytes += f.size; + } else if (f.type === 'events') { + stats.eventsCount++; + } + + if (f.type === 'live' || f.type === 'vod') { + const cur = dailyMap.get(f.date) || { date: f.date, count: 0, bytes: 0 }; + cur.count++; + cur.bytes += f.size; + dailyMap.set(f.date, cur); + } + } + + perStreamer.set(streamerFolder, ts); + } + + stats.streamerCount = perStreamer.size; + stats.avgRecordingSizeBytes = recordingCount > 0 ? Math.round(recordingBytes / recordingCount) : 0; + stats.topStreamers = Array.from(perStreamer.values()) + .sort((a, b) => b.bytes - a.bytes) + .slice(0, 10); + + const today = new Date(); + today.setHours(0, 0, 0, 0); + const days: ArchiveStatsDay[] = []; + for (let i = 29; i >= 0; i--) { + const d = new Date(today); + d.setDate(d.getDate() - i); + const key = d.toISOString().slice(0, 10); + days.push(dailyMap.get(key) || { date: key, count: 0, bytes: 0 }); + } + stats.dailyActivity = days; + + return stats; +} + +// ========================================== +// DISCORD WEBHOOK NOTIFICATIONS +// ========================================== +// Fire-and-forget webhook for "stream went live", "recording finished", +// "VOD download complete". Useful when the user runs the app on a +// dedicated archival machine and isn't checking it directly. +type DiscordEmbedColor = 'live' | 'success' | 'info'; +const DISCORD_EMBED_COLORS: Record = { + live: 0xE91916, // red — recording started + success: 0x00C853, // green — completed cleanly + info: 0x9146FF // twitch purple — neutral +}; + +function isAcceptableDiscordWebhook(url: string): boolean { + const trimmed = (url || '').trim(); + if (!trimmed) return false; + return /^https:\/\/(?:[a-z]+\.)?discord(?:app)?\.com\/api\/webhooks\//i.test(trimmed); +} + +async function sendDiscordWebhook(payload: { + title: string; + description: string; + color: DiscordEmbedColor; + fields?: Array<{ name: string; value: string; inline?: boolean }>; +}): Promise { + const url = (config.discord_webhook_url || '').trim(); + if (!isAcceptableDiscordWebhook(url)) return; + + const body = { + username: 'Twitch VOD Manager', + embeds: [ + { + title: payload.title.slice(0, 256), + description: payload.description.slice(0, 4096), + color: DISCORD_EMBED_COLORS[payload.color], + fields: (payload.fields || []).slice(0, 25).map((f) => ({ + name: (f.name || '').slice(0, 256), + value: (f.value || '').slice(0, 1024), + inline: f.inline === true + })), + timestamp: new Date().toISOString() + } + ] + }; + + try { + await axios.post(url, body, { timeout: 8000, headers: { 'Content-Type': 'application/json' } }); + appendDebugLog('discord-webhook-ok', { title: payload.title, color: payload.color }); + } catch (e) { + appendDebugLog('discord-webhook-failed', { title: payload.title, error: String(e) }); + } +} + +// ========================================== +// LIVE RECORDING EVENTS LOG +// ========================================== +// Sibling .events.jsonl file alongside each live recording. Tracks +// recording start/end + Twitch metadata changes (title / game) that +// happen while the stream is being captured. Useful when seeking +// inside a long archived stream — tells you "at minute 142 he switched +// from Just Chatting to Counter-Strike". Independent of chat capture +// (lives even if capture_live_chat is off) and uses JSON Lines for +// the same crash-safety reason. +interface LiveEventTracker { + itemId: string; + streamer: string; + eventsPath: string; + fileHandle: number | null; + startedAt: number; // Date.now() when recording started + lastTitle: string; + lastGame: string; + closing: boolean; +} + +const liveEventTrackers = new Map(); +let liveEventsPollTimer: NodeJS.Timeout | null = null; + +function eventsLogPathFor(videoPath: string): string { + const ext = path.extname(videoPath); + const base = ext ? videoPath.slice(0, -ext.length) : videoPath; + return `${base}.events.jsonl`; +} + +function appendEventLine(tracker: LiveEventTracker, payload: Record): void { + if (tracker.fileHandle === null) return; + const line = JSON.stringify({ t: new Date().toISOString(), ...payload }) + '\n'; + try { + fs.writeSync(tracker.fileHandle, line); + } catch (e) { + appendDebugLog('events-log-write-failed', { itemId: tracker.itemId, error: String(e) }); + } +} + +function startLiveEventsTracker(itemId: string, streamer: string, videoPath: string, initialTitle: string, initialGame: string): LiveEventTracker | null { + const eventsPath = eventsLogPathFor(videoPath); + let fd: number; + try { + fd = fs.openSync(eventsPath, 'w'); + } catch (e) { + appendDebugLog('events-log-open-failed', { itemId, eventsPath, error: String(e) }); + return null; + } + + const tracker: LiveEventTracker = { + itemId, + streamer, + eventsPath, + fileHandle: fd, + startedAt: Date.now(), + lastTitle: initialTitle, + lastGame: initialGame, + closing: false + }; + + appendEventLine(tracker, { + type: 'recording_start', + streamer, + title: initialTitle, + game: initialGame + }); + + liveEventTrackers.set(itemId, tracker); + ensureLiveEventsPollTimer(); + return tracker; +} + +function stopLiveEventsTracker(itemId: string, finalNote?: { success: boolean; durationMs: number; error?: string }): void { + const tracker = liveEventTrackers.get(itemId); + if (!tracker || tracker.closing) return; + tracker.closing = true; + + appendEventLine(tracker, { + type: 'recording_end', + durationSeconds: finalNote ? Math.floor(finalNote.durationMs / 1000) : Math.floor((Date.now() - tracker.startedAt) / 1000), + success: finalNote?.success === true, + error: finalNote?.error || '' + }); + + if (tracker.fileHandle !== null) { + try { fs.closeSync(tracker.fileHandle); } catch { /* ignore */ } + tracker.fileHandle = null; + } + liveEventTrackers.delete(itemId); + + if (liveEventTrackers.size === 0 && liveEventsPollTimer) { + clearInterval(liveEventsPollTimer); + liveEventsPollTimer = null; + } +} + +function ensureLiveEventsPollTimer(): void { + if (liveEventsPollTimer) return; + // Same cadence as auto-record polling; metadata changes don't need + // sub-minute resolution and we want to keep API load bounded. + liveEventsPollTimer = setInterval(() => { void pollLiveEventsForChanges(); }, 60 * 1000); + liveEventsPollTimer.unref?.(); +} + +async function pollLiveEventsForChanges(): Promise { + if (liveEventTrackers.size === 0) return; + for (const tracker of liveEventTrackers.values()) { + if (tracker.closing) continue; + const info = await getLiveStreamInfo(tracker.streamer); + if (!info || !info.isLive) continue; + const currentTitle = info.title || ''; + const currentGame = info.gameName || ''; + + if (currentTitle !== tracker.lastTitle) { + appendEventLine(tracker, { + type: 'title_change', + from: tracker.lastTitle, + to: currentTitle + }); + tracker.lastTitle = currentTitle; + } + if (currentGame !== tracker.lastGame) { + appendEventLine(tracker, { + type: 'game_change', + from: tracker.lastGame, + to: currentGame + }); + tracker.lastGame = currentGame; + // Also fire a webhook ping if the user wants it. Game changes + // matter more than title micro-tweaks, so we only ping for game. + if (config.discord_notify_live_start) { + void sendDiscordWebhook({ + title: `Game change: ${tracker.streamer}`, + description: `Now playing **${currentGame || 'unknown'}**`, + color: 'info', + fields: [ + { name: 'Title', value: currentTitle || '-', inline: false } + ] + }); + } + } + } +} + +// ========================================== +// LIVE CHAT CAPTURE (during live recording) +// ========================================== +// Companion to fetchVodChatReplay: while a stream is being recorded live, +// open an anonymous IRC connection to Twitch chat and append every message +// to a sibling .chat.jsonl file. Format is JSON Lines (one JSON object per +// line) so a partial / killed write still parses correctly — important +// because live recordings can run for many hours and we don't want to +// keep the full chat in memory. +interface LiveChatSession { + streamer: string; + outputPath: string; + socket: TLSSocket; + fileHandle: number | null; + closing: boolean; + messageCount: number; + buffer: string; +} + +const TWITCH_IRC_HOST = 'irc.chat.twitch.tv'; +const TWITCH_IRC_PORT = 6697; + +function liveChatPathFor(videoPath: string): string { + const ext = path.extname(videoPath); + const base = ext ? videoPath.slice(0, -ext.length) : videoPath; + return `${base}.chat.jsonl`; +} + +function startLiveChatCapture(streamer: string, outputPath: string): LiveChatSession | null { + const channelName = normalizeLogin(streamer); + if (!channelName) return null; + + let fd: number; + try { + fd = fs.openSync(outputPath, 'w'); + } catch (e) { + appendDebugLog('chat-capture-open-failed', { streamer: channelName, outputPath, error: String(e) }); + return null; + } + + const session: LiveChatSession = { + streamer: channelName, + outputPath, + socket: tlsConnect({ host: TWITCH_IRC_HOST, port: TWITCH_IRC_PORT, servername: TWITCH_IRC_HOST }), + fileHandle: fd, + closing: false, + messageCount: 0, + buffer: '' + }; + + // Write a header line so the file is self-describing even if zero + // messages arrive (e.g. silent stream, immediate disconnect). + const header = { + type: 'header', + streamer: channelName, + startedAt: new Date().toISOString(), + format: 'twitch-vod-manager-chat-jsonl-v1' + }; + try { fs.writeSync(fd, JSON.stringify(header) + '\n'); } catch { /* ignore */ } + + session.socket.on('secureConnect', () => { + // Anonymous Twitch IRC: any nick prefixed with "justinfan" is + // accepted without a password. Random suffix avoids collisions. + const nick = `justinfan${Math.floor(Math.random() * 100000)}`; + try { + session.socket.write('CAP REQ :twitch.tv/tags twitch.tv/commands\r\n'); + session.socket.write(`NICK ${nick}\r\n`); + session.socket.write(`JOIN #${channelName}\r\n`); + } catch (e) { + appendDebugLog('chat-capture-handshake-failed', { streamer: channelName, error: String(e) }); + } + appendDebugLog('chat-capture-connected', { streamer: channelName, nick }); + }); + + session.socket.on('data', (chunk: Buffer) => { + session.buffer += chunk.toString('utf-8'); + const lines = session.buffer.split('\r\n'); + session.buffer = lines.pop() || ''; + for (const line of lines) { + handleIrcLine(session, line); + } + }); + + session.socket.on('error', (err: Error) => { + appendDebugLog('chat-capture-socket-error', { streamer: channelName, error: String(err) }); + }); + + session.socket.on('close', () => { + if (!session.closing) { + appendDebugLog('chat-capture-disconnected', { streamer: channelName, messages: session.messageCount }); + } + if (session.fileHandle !== null) { + try { fs.closeSync(session.fileHandle); } catch { /* ignore */ } + session.fileHandle = null; + } + }); + + return session; +} + +function handleIrcLine(session: LiveChatSession, line: string): void { + if (!line) return; + if (line.startsWith('PING')) { + try { session.socket.write('PONG' + line.slice(4) + '\r\n'); } catch { /* ignore */ } + return; + } + + let rest = line; + let tagsStr = ''; + if (rest.startsWith('@')) { + const sp = rest.indexOf(' '); + if (sp < 0) return; + tagsStr = rest.slice(1, sp); + rest = rest.slice(sp + 1); + } + let prefix = ''; + if (rest.startsWith(':')) { + const sp = rest.indexOf(' '); + if (sp < 0) return; + prefix = rest.slice(1, sp); + rest = rest.slice(sp + 1); + } + const cmdSp = rest.indexOf(' '); + const command = cmdSp < 0 ? rest : rest.slice(0, cmdSp); + const params = cmdSp < 0 ? '' : rest.slice(cmdSp + 1); + + if (command !== 'PRIVMSG' && command !== 'USERNOTICE' && command !== 'CLEARCHAT' && command !== 'CLEARMSG') return; + + const colonIdx = params.indexOf(' :'); + const text = colonIdx >= 0 ? params.slice(colonIdx + 2) : ''; + + const tags: Record = {}; + if (tagsStr) { + for (const pair of tagsStr.split(';')) { + const eq = pair.indexOf('='); + if (eq < 0) continue; + tags[pair.slice(0, eq)] = pair.slice(eq + 1); + } + } + + const login = (prefix.split('!')[0] || tags['login'] || '').toLowerCase(); + const message = { + t: new Date().toISOString(), + type: command === 'PRIVMSG' ? 'msg' : (command === 'USERNOTICE' ? 'notice' : command.toLowerCase()), + u: tags['display-name'] || login, + login, + color: tags['color'] || '', + msg: text, + badges: tags['badges'] || '', + bits: tags['bits'] || '', + msgId: tags['msg-id'] || '', + systemMsg: (tags['system-msg'] || '').replace(/\\s/g, ' ') + }; + + if (session.fileHandle === null) return; + try { + fs.writeSync(session.fileHandle, JSON.stringify(message) + '\n'); + session.messageCount++; + } catch (e) { + appendDebugLog('chat-capture-write-failed', { error: String(e) }); + } +} + +function stopLiveChatCapture(session: LiveChatSession): void { + if (session.closing) return; + session.closing = true; + appendDebugLog('chat-capture-stopping', { streamer: session.streamer, messages: session.messageCount }); + try { session.socket.write(`PART #${session.streamer}\r\nQUIT\r\n`); } catch { /* ignore */ } + try { session.socket.end(); } catch { /* ignore */ } + setTimeout(() => { + try { session.socket.destroy(); } catch { /* ignore */ } + }, 500); +} + +async function downloadLiveStream( + item: QueueItem, + onProgress: (progress: DownloadProgress) => void +): Promise { + const streamlinkReady = await ensureStreamlinkInstalled(); + if (!streamlinkReady) { + return { success: false, error: tBackend('streamlinkAutoInstallFailed') }; + } + + onProgress({ + id: item.id, + progress: -1, + speed: '', + eta: '', + status: tBackend('statusDownloadStarted'), + currentPart: 0, + totalParts: 0 + }); + + const safeStreamer = (item.streamer || 'live').replace(/[^a-zA-Z0-9_-]/g, ''); + const now = new Date(); + const dateStr = `${now.getFullYear()}-${(now.getMonth() + 1).toString().padStart(2, '0')}-${now.getDate().toString().padStart(2, '0')}`; + const timeStr = `${now.getHours().toString().padStart(2, '0')}-${now.getMinutes().toString().padStart(2, '0')}-${now.getSeconds().toString().padStart(2, '0')}`; + const folder = path.join(config.download_path, safeStreamer, 'live'); + fs.mkdirSync(folder, { recursive: true }); + + const baseFilename = ensureUniqueFilename( + path.join(folder, `${safeStreamer}_LIVE_${dateStr}_${timeStr}.mp4`), + item.id + ); + + // Optional: anonymous IRC chat capture for the duration of the + // recording. Sibling .chat.jsonl file. We start it BEFORE streamlink + // so the very first chat lines after JOIN aren't dropped, and stop it + // AFTER streamlink exits so trailing messages (e.g. "stream offline" + // user reactions) are still captured. Chat + events span the whole + // multi-part recording (chat is an independent IRC connection, events + // is an independent poller), so they stay alive across resume cycles. + let chatSession: LiveChatSession | null = null; + if (config.capture_live_chat) { + const chatPath = liveChatPathFor(baseFilename); + chatSession = startLiveChatCapture(item.streamer, chatPath); + } + + let eventsTracker: LiveEventTracker | null = null; + if (config.log_stream_events) { + let initialTitle = ''; + let initialGame = ''; + try { + const info = await getLiveStreamInfo(item.streamer); + if (info) { + initialTitle = info.title || ''; + initialGame = info.gameName || ''; + } + } catch { /* ignore */ } + eventsTracker = startLiveEventsTracker(item.id, item.streamer, baseFilename, initialTitle, initialGame); + } + + if (config.discord_notify_live_start) { + void sendDiscordWebhook({ + title: `Recording started: ${item.streamer}`, + description: item.title || `${item.streamer} is live`, + color: 'live', + fields: [ + { name: 'URL', value: item.url, inline: false }, + { name: 'Output', value: path.basename(baseFilename), inline: false } + ] + }); + } + + const recordingStartedAt = Date.now(); + const BYTES_FRESH_MS = 30_000; + const MIN_HEALTHY_PART_MS = 30_000; + const RESUME_WAIT_MS = 10_000; + const MAX_RESUME_ATTEMPTS = 5; + + // Total-recording byte tracking. Each resumed part starts streamlink + // fresh, so its byte counter resets to 0; we keep accumulatedBytes + // across parts so the meta line shows the TOTAL recorded size, not + // just the current part. Same for elapsed — recordingStartedAt is the + // overall start, not per-part. + let accumulatedBytes = 0; + let currentPartBytes = 0; + let lastBytesValue = 0; + let lastBytesAdvancedAt = 0; + let lastEmittedProgress: DownloadProgress | null = null; + + const computeHealth = (): 'ok' | 'stale' | 'unknown' => { + if (lastBytesAdvancedAt === 0) return 'unknown'; + return (Date.now() - lastBytesAdvancedAt) <= BYTES_FRESH_MS ? 'ok' : 'stale'; + }; + + const wrappedProgress = (p: DownloadProgress): void => { + const bytes = Number(p.downloadedBytes) || 0; + if (bytes > lastBytesValue) { + lastBytesValue = bytes; + lastBytesAdvancedAt = Date.now(); + } + currentPartBytes = bytes; + const totalBytes = accumulatedBytes + currentPartBytes; + const elapsed = Math.max(1, Math.floor((Date.now() - recordingStartedAt) / 1000)); + const avgBitrateMbps = (totalBytes * 8) / elapsed / 1_000_000; + const parts: string[] = [formatDuration(elapsed)]; + if (totalBytes > 0) parts.push(formatBytes(totalBytes)); + if (avgBitrateMbps > 0) parts.push(`${avgBitrateMbps.toFixed(1)} Mbps`); + const next = { + ...p, + speed: '', + eta: '', + status: parts.join(' · '), + recordingHealth: computeHealth() + }; + lastEmittedProgress = next; + onProgress(next); + }; + + // Health-tick: re-emit the most recent progress every 10s so the + // renderer's health badge updates even when streamlink is silent. + // Without this, a streamlink hung on a buffer-stall would keep showing + // 'ok' until the next real byte event. + const healthTick = setInterval(() => { + if (!lastEmittedProgress) return; + const updated: DownloadProgress = { ...lastEmittedProgress, recordingHealth: computeHealth() }; + lastEmittedProgress = updated; + onProgress(updated); + }, 10_000); + healthTick.unref?.(); + + const outputs: string[] = []; + let partNumber = 1; + let resumeCount = 0; + let lastPartResult: DownloadResult = { success: false, error: tBackend('unknownDownloadError') }; + + try { + // Resume loop. Each iteration runs streamlink once. On clean exit, + // we re-check whether the stream is still live on Twitch's side; + // if yes, the exit was an interruption (network blip, segment + // discontinuity, etc.) — start a new part and append. If the + // stream really ended, break and finalize. + while (true) { + const partFilename = partNumber === 1 + ? baseFilename + : ensureUniqueFilename( + baseFilename.replace(/\.mp4$/i, `_part${partNumber}.mp4`), + item.id + ); + + // Reset per-part counters — streamlink is fresh, byte counter + // restarts at zero. lastBytesAdvancedAt stays at zero until + // the first segment arrives, which correctly flips the health + // dot to 'unknown' during the resume gap. + lastBytesValue = 0; + lastBytesAdvancedAt = 0; + currentPartBytes = 0; + + const partStartedAt = Date.now(); + appendDebugLog('recording-part-start', { itemId: item.id, partNumber, filename: path.basename(partFilename) }); + + lastPartResult = await downloadVODPart(item.url, partFilename, null, null, wrappedProgress, item.id, partNumber, partNumber); + + // Accumulate this part's final bytes into the running total so + // the next part's meta line continues from the correct figure. + let partFinalBytes = 0; + if (fs.existsSync(partFilename)) { + try { + partFinalBytes = fs.statSync(partFilename).size || 0; + } catch { /* ignore */ } + } + if (partFinalBytes > 0) { + outputs.push(partFilename); + accumulatedBytes += partFinalBytes; + } else { + // Streamlink produced no bytes — likely permission or auth + // failure. Skip resume because retrying will hit the same + // wall. The error from lastPartResult will surface upstream. + appendDebugLog('recording-part-zero-bytes', { itemId: item.id, partNumber }); + break; + } + + // Resume decision tree. + if (cancelledItemIds.has(item.id) || !isDownloading || pauseRequested) { + appendDebugLog('recording-resume-cancelled', { itemId: item.id, partNumber, reason: pauseRequested ? 'pause' : 'cancel' }); + break; + } + if (!config.auto_resume_live_recording) { + appendDebugLog('recording-resume-disabled', { itemId: item.id }); + break; + } + if (resumeCount >= MAX_RESUME_ATTEMPTS) { + appendDebugLog('recording-resume-max-attempts', { itemId: item.id, max: MAX_RESUME_ATTEMPTS }); + break; + } + // Don't resume on suspiciously short parts — that pattern points + // at a config issue (bad URL, auth-required stream, streamlink + // missing plugin) where retrying will just loop and burn API + // quota. + const partDurationMs = Date.now() - partStartedAt; + if (partDurationMs < MIN_HEALTHY_PART_MS) { + appendDebugLog('recording-resume-skip-short', { itemId: item.id, partNumber, durationMs: partDurationMs }); + break; + } + + // Only resume if Twitch still says the stream is live. If the + // streamer actually ended their broadcast, we accept the part + // we have and call the recording done. + let stillLive = false; + try { + const info = await getLiveStreamInfo(item.streamer); + stillLive = info?.isLive === true; + } catch { + // Unknown liveness — err on the side of NOT resuming to + // avoid infinite-loop on network-out conditions where we + // can't even reach Twitch to check. The user can always + // restart manually. + stillLive = false; + } + if (!stillLive) { + appendDebugLog('recording-finished-stream-offline', { itemId: item.id, parts: partNumber }); + break; + } + + appendDebugLog('recording-resume-attempt', { itemId: item.id, previousPart: partNumber, attempt: resumeCount + 1 }); + if (eventsTracker) { + appendEventLine(eventsTracker, { type: 'recording_resume', part: partNumber + 1 }); + } + resumeCount++; + partNumber++; + await sleep(RESUME_WAIT_MS); + } + } finally { + clearInterval(healthTick); + } + + if (chatSession) { + stopLiveChatCapture(chatSession); + } + if (eventsTracker) { + stopLiveEventsTracker(item.id, { + success: outputs.length > 0, + durationMs: Date.now() - recordingStartedAt, + error: outputs.length === 0 ? lastPartResult.error : undefined + }); + } + + if (config.discord_notify_live_end) { + const durationSec = Math.max(0, Math.floor((Date.now() - recordingStartedAt) / 1000)); + const sizeBytes = accumulatedBytes; + const success = outputs.length > 0; + void sendDiscordWebhook({ + title: success ? `Recording finished: ${item.streamer}` : `Recording failed: ${item.streamer}`, + description: item.title || `${item.streamer}`, + color: success ? 'success' : 'info', + fields: [ + { name: 'Duration', value: formatDuration(durationSec), inline: true }, + { name: 'Size', value: formatBytes(sizeBytes), inline: true }, + { name: 'Parts', value: String(outputs.length || 1), inline: true }, + { name: 'Chat captured', value: chatSession ? `${chatSession.messageCount} messages` : 'no', inline: true }, + { name: 'Output', value: path.basename(baseFilename), inline: false } + ] + }); + } + + if (outputs.length === 0) return lastPartResult; + + // Auto-merge resumed parts. Only attempt when (a) the user opted in, + // (b) there's actually something to merge, and (c) the parts are all + // present on disk. Failure is non-fatal — we keep the parts so the + // user still has working files even if ffmpeg trips on a corrupted + // segment header. + let finalRecordings = outputs.slice(); + if (config.auto_merge_resumed_parts && outputs.length > 1) { + const mergedOutput = ensureUniqueFilename( + baseFilename.replace(/\.mp4$/i, '_merged.mp4'), + item.id + ); + const mergeOk = await concatVideoFiles(outputs, mergedOutput); + if (mergeOk) { + if (config.delete_parts_after_merge) { + for (const partPath of outputs) { + try { fs.unlinkSync(partPath); } catch (e) { + appendDebugLog('merge-part-delete-failed', { path: partPath, error: String(e) }); + } + } + finalRecordings = [mergedOutput]; + } else { + finalRecordings = [mergedOutput, ...outputs]; + } + appendDebugLog('merge-resumed-parts-ok', { merged: mergedOutput, partsKept: !config.delete_parts_after_merge }); + } else { + appendDebugLog('merge-resumed-parts-failed-keeping-parts'); + } + } + + if (chatSession && fs.existsSync(chatSession.outputPath)) { + finalRecordings.push(chatSession.outputPath); + } + if (eventsTracker && fs.existsSync(eventsTracker.eventsPath)) { + finalRecordings.push(eventsTracker.eventsPath); + } + return { success: true, outputFiles: finalRecordings }; +} + +async function downloadVOD( + item: QueueItem, + onProgress: (progress: DownloadProgress) => void +): Promise { + // Live-recording branch: URL is the channel page, no VOD id, no time + // window. Streamlink runs until the stream ends, then we treat the + // whole capture as a single output file. + if (item.isLive) { + return await downloadLiveStream(item, onProgress); + } + + const vodId = parseVodId(item.url); + if (!isLikelyVodUrl(item.url) || !vodId) { + return { + success: false, + error: tBackend('invalidVodUrl') + }; + } + + const streamlinkCmd = getStreamlinkCommand(); + const streamlinkVersionArgs = [...streamlinkCmd.prefixArgs, '--version']; + const streamlinkAlreadyVerified = isVerifiedStreamlinkCommand(streamlinkCmd.command, streamlinkVersionArgs); + + if (!streamlinkAlreadyVerified) { + onProgress({ + id: item.id, + progress: -1, + speed: '', + eta: '', + status: tBackend('statusCheckingTools'), + currentPart: 0, + totalParts: 0 + }); + } + + const streamlinkReady = await ensureStreamlinkInstalled(); + if (!streamlinkReady) { + return { + success: false, + error: tBackend('streamlinkAutoInstallFailed') + }; + } + + onProgress({ + id: item.id, + progress: -1, + speed: '', + eta: '', + status: tBackend('statusDownloadStarted'), + currentPart: 0, + totalParts: 0 + }); + + const streamer = item.streamer.replace(/[^a-zA-Z0-9_-]/g, ''); + const date = new Date(item.date); + const dateStr = `${date.getDate().toString().padStart(2, '0')}.${(date.getMonth() + 1).toString().padStart(2, '0')}.${date.getFullYear()}`; + + const folder = path.join(config.download_path, streamer, dateStr); + fs.mkdirSync(folder, { recursive: true }); + + const totalDuration = parseDuration(item.duration_str); + + const requiredBytesEstimate = estimateRequiredDownloadBytes(item); + const diskSpaceCheck = ensureDiskSpace(folder, requiredBytesEstimate, 'Download'); + if (!diskSpaceCheck.success) { + return diskSpaceCheck; + } + + const makeTemplateFilename = ( + template: string, + templateFallback: string, + partNum: number, + trimStartSec: number, + trimLengthSec: number + ): string => { + const relativeName = renderClipFilenameTemplate({ + template: normalizeFilenameTemplate(template, templateFallback), + title: item.title, + vodId, + channel: item.streamer, + date, + part: partNum, + partPadded: partNum.toString().padStart(2, '0'), + trimStartSec, + trimEndSec: trimStartSec + trimLengthSec, + trimLengthSec, + fullLengthSec: totalDuration + }); + + return path.join(folder, relativeName); + }; + + // Custom Clip - download specific time range + if (item.customClip) { + const clip = item.customClip; + const partDuration = config.part_minutes * 60; + + // Helper to generate filename based on format + const makeClipFilename = (partNum: number, startOffset: number, clipLengthSec: number): string => { + if (clip.filenameFormat === 'template') { + return makeTemplateFilename( + clip.filenameTemplate || config.filename_template_clip, + DEFAULT_FILENAME_TEMPLATE_CLIP, + partNum, + startOffset, + clipLengthSec + ); + } + + if (clip.filenameFormat === 'timestamp') { + const h = Math.floor(startOffset / 3600); + const m = Math.floor((startOffset % 3600) / 60); + const s = Math.floor(startOffset % 60); + const timeStr = `${h.toString().padStart(2, '0')}-${m.toString().padStart(2, '0')}-${s.toString().padStart(2, '0')}`; + return path.join(folder, `${dateStr}_CLIP_${timeStr}_${partNum}.mp4`); + } + + if (clip.filenameFormat === 'parts') { + // Mirrors the global filename_template_parts default: + // `{date}_Part{part_padded}.mp4` -> e.g. 08.05.2026_Part07.mp4 + return path.join(folder, `${dateStr}_Part${partNum.toString().padStart(2, '0')}.mp4`); + } + + return path.join(folder, `${dateStr}_${partNum}.mp4`); + }; + + // If clip is longer than part duration, split into parts + if (clip.durationSec > partDuration) { + const numParts = Math.ceil(clip.durationSec / partDuration); + const downloadedFiles: string[] = []; + + for (let i = 0; i < numParts; i++) { + if (cancelledItemIds.has(item.id)) break; + + const partNum = clip.startPart + i; + const startOffset = clip.startSec + (i * partDuration); + const remainingDuration = clip.durationSec - (i * partDuration); + const thisDuration = Math.min(partDuration, remainingDuration); + + const partFilename = ensureUniqueFilename(makeClipFilename(partNum, startOffset, thisDuration), item.id); + + const result = await downloadVODPart( + item.url, + partFilename, + formatDuration(startOffset), + formatDuration(thisDuration), + onProgress, + item.id, + i + 1, + numParts + ); + + if (!result.success) return result; + downloadedFiles.push(partFilename); + } + + return { + success: downloadedFiles.length === numParts, + error: downloadedFiles.length === numParts ? undefined : tBackend('notAllClipPartsDownloaded'), + outputFiles: downloadedFiles.length === numParts ? [...downloadedFiles] : undefined + }; + } else { + // Single clip file + const filename = ensureUniqueFilename(makeClipFilename(clip.startPart, clip.startSec, clip.durationSec), item.id); + const result = await downloadVODPart( + item.url, + filename, + formatDuration(clip.startSec), + formatDuration(clip.durationSec), + onProgress, + item.id, + 1, + 1 + ); + return result.success ? { ...result, outputFiles: [filename] } : result; + } + } + + // Check download mode + if (config.download_mode === 'full' || totalDuration <= config.part_minutes * 60) { + // Full download — totalDuration als expectedTotalSec damit der Bar + // determinate-progress aus bytes/duration schaetzen kann (statt in + // indeterminate-Animation zu haengen). + const filename = ensureUniqueFilename(makeTemplateFilename( + config.filename_template_vod, + DEFAULT_FILENAME_TEMPLATE_VOD, + 1, + 0, + totalDuration + ), item.id); + const result = await downloadVODPart(item.url, filename, null, null, onProgress, item.id, 1, 1, totalDuration); + return result.success ? { ...result, outputFiles: [filename] } : result; + } else { + // Part-based download — wrappt onProgress mit einem Aggregator, der + // pro Part den letzten bekannten %-Wert haelt und einen weighted + // overallProgress (0-100%) zurueck an die UI emittiert. Ohne den + // Wrapper sah die UI nur "Part X bei Y%" und der Bar sprang bei + // Part-Wechsel von 100% zurueck auf 0%. + const partDuration = config.part_minutes * 60; + const numParts = Math.ceil(totalDuration / partDuration); + const downloadedFiles: string[] = []; + const partProgresses: number[] = Array(numParts).fill(0); + + for (let i = 0; i < numParts; i++) { + if (cancelledItemIds.has(item.id)) break; + + const startSec = i * partDuration; + const endSec = Math.min((i + 1) * partDuration, totalDuration); + const duration = endSec - startSec; + + const partFilename = ensureUniqueFilename(makeTemplateFilename( + config.filename_template_parts, + DEFAULT_FILENAME_TEMPLATE_PARTS, + i + 1, + startSec, + duration + ), item.id); + + const result = await downloadVODPart( + item.url, + partFilename, + formatDuration(startSec), + formatDuration(duration), + (progress) => { + // Per-part %-Update — clampen, NaN/negativ filtern + if (Number.isFinite(progress.progress) && progress.progress > 0 && progress.progress <= 100) { + partProgresses[i] = Math.max(partProgresses[i], progress.progress); + } + // Overall: avg ueber alle Parts (parts haben gleiche + // Dauer per Definition, also avg = weighted avg) + const overall = partProgresses.reduce((s, p) => s + p, 0) / numParts; + onProgress({ + ...progress, + progress: overall, + currentPart: i + 1, + totalParts: numParts + }); + }, + item.id, + i + 1, + numParts, + duration + ); + + if (!result.success) { + return result; + } + + partProgresses[i] = 100; + downloadedFiles.push(partFilename); + } + + return { + success: downloadedFiles.length === numParts, + error: downloadedFiles.length === numParts ? undefined : tBackend('notAllPartsDownloaded'), + outputFiles: downloadedFiles.length === numParts ? [...downloadedFiles] : undefined + }; + } +} + +// ========================================== +// MERGE GROUP DOWNLOAD PIPELINE +// ========================================== +async function processDownloadMergeGroup( + item: QueueItem, + onProgress: (progress: DownloadProgress) => void +): Promise { + const mg = item.mergeGroup!; + const totalDurationSec = mg.totalDurationSec || mg.items.reduce((sum, i) => sum + parseDuration(i.duration_str), 0); + mg.totalDurationSec = totalDurationSec; + + // ---- PHASE 1: DOWNLOADING ---- + if (mg.mergePhase === 'downloading') { + const streamlinkReady = await ensureStreamlinkInstalled(); + if (!streamlinkReady) { + return { success: false, error: tBackend('streamlinkMissing') }; + } + + const ffmpegReady = await ensureFfmpegInstalled(); + if (!ffmpegReady) { + return { success: false, error: tBackend('ffmpegMissing') }; + } + + const streamer = mg.items[0].streamer.replace(/[^a-zA-Z0-9_-]/g, ''); + const date = new Date(mg.items[0].date); + const dateStr = `${date.getDate().toString().padStart(2, '0')}.${(date.getMonth() + 1).toString().padStart(2, '0')}.${date.getFullYear()}`; + const folder = path.join(config.download_path, streamer, dateStr); + fs.mkdirSync(folder, { recursive: true }); + + // Disk space pre-check: 3x total estimated size + const estimatedBytes = mg.items.reduce((sum, i) => { + const dur = parseDuration(i.duration_str); + return sum + Math.ceil(dur * 500_000); // ~500KB/s estimate + }, 0); + const requiredBytes = Math.max(256 * 1024 * 1024, estimatedBytes * 3); + const diskCheck = ensureDiskSpace(folder, requiredBytes, 'Merge-Group-Download'); + if (!diskCheck.success) { + return diskCheck; + } + + for (let i = 0; i < mg.items.length; i++) { + if (cancelledItemIds.has(item.id)) { + return { success: false, error: tBackend('downloadCancelled') }; + } + + // Skip already downloaded files (retry recovery) + if (mg.downloadedFiles[i] && fs.existsSync(mg.downloadedFiles[i])) { + appendDebugLog('merge-group-skip-existing', { index: i, file: mg.downloadedFiles[i] }); + continue; + } + + // Reset stale per-item cancel state (global cancel already checked above) + cancelledItemIds.delete(item.id); + mg.currentItemIndex = i; + mg.mergePhase = 'downloading'; + saveQueue(downloadQueue); + + const vodItem = mg.items[i]; + const tmpFilename = ensureUniqueFilename(path.join(folder, `merge_tmp_${i}_${Date.now()}.mp4`), item.id); + + // Calculate progress weighting per VOD + const vodDuration = parseDuration(vodItem.duration_str); + const vodWeight = vodDuration / totalDurationSec; + const priorWeight = mg.items.slice(0, i).reduce((s, v) => s + parseDuration(v.duration_str), 0) / totalDurationSec; + + // Geschaetzte Bytes pro Part fuer den Fallback-Progress: Twitch- + // VOD Bitrate ~5 Mbit/s = ~625 KB/s. Wenn streamlink-stdout keine + // %-Lines emittiert (HLS ohne known total), nutzen wir + // downloadedBytes / estimatedTotalBytes als rough progress. Cap + // bei 95% damit der Bar nie 100% vorm tatsaechlichen Done erreicht. + const estimatedTotalBytes = Math.max(1, vodDuration * 625_000); + + // Persistente per-part vodProgress. Quelle 1: streamlink stdout % + // (genau). Quelle 2: downloadedBytes / estimated (Fallback wenn + // % nicht reportet wird). Ohne den Fallback haengte der Bar auf + // dem indeterminate-Pattern (animierte 35%-Box) waehrend tatsaechlich + // schon ein paar 100 MB unten waren — User sieht das als "fest mittig + // links" weil die Animation schnell ist und nur Snapshots zeigen. + let lastVodProgress = 0; + const result = await downloadVODPart( + vodItem.url, + tmpFilename, + null, // startTime: null = full VOD + null, // endTime: null = full VOD + (progress) => { + if (progress.progress > 0 && progress.progress <= 100) { + lastVodProgress = progress.progress; + } else if (progress.downloadedBytes && progress.downloadedBytes > 0) { + // Fallback: bytes-basierte Schaetzung. Streamlink-stdout-% + // bleibt bevorzugt; bytes-Fallback wird nur genutzt wenn + // noch nie ein echter % rein kam (lastVodProgress noch 0). + if (lastVodProgress === 0) { + const bytePct = Math.min(95, (progress.downloadedBytes / estimatedTotalBytes) * 100); + lastVodProgress = bytePct; + } + } + // Weighted progress: download phase = 0-70% + const overallProgress = (priorWeight + vodWeight * (lastVodProgress / 100)) * 70; + onProgress({ + ...progress, + id: item.id, + progress: overallProgress, + status: `${getMergeGroupPhaseText('downloading')} ${i + 1}/${mg.items.length} — ${progress.status}`, + currentPart: i + 1, + totalParts: mg.items.length + }); + }, + item.id, + i + 1, + mg.items.length + ); + + if (!result.success) { + return result; + } + + mg.downloadedFiles[i] = tmpFilename; + saveQueue(downloadQueue); + } + } + + // ---- PHASE 2: MERGING ---- + mg.mergePhase = 'merging'; + saveQueue(downloadQueue); + emitQueueUpdated(); + + // Check all downloaded files exist (retry recovery) + for (let i = 0; i < mg.items.length; i++) { + if (!mg.downloadedFiles[i] || !fs.existsSync(mg.downloadedFiles[i])) { + mg.mergePhase = 'downloading'; + return { success: false, error: tBackend('mergeGroupFileMissing', { index: i + 1 }) }; + } + } + + if (!mg.mergedFile || !fs.existsSync(mg.mergedFile)) { + const streamer = mg.items[0].streamer.replace(/[^a-zA-Z0-9_-]/g, ''); + const date = new Date(mg.items[0].date); + const dateStr = `${date.getDate().toString().padStart(2, '0')}.${(date.getMonth() + 1).toString().padStart(2, '0')}.${date.getFullYear()}`; + const folder = path.join(config.download_path, streamer, dateStr); + const mergedFilePath = path.join(folder, `merged_${Date.now()}.mp4`); + + // Get files in correct order (explicit sort by index — do NOT rely on Object.values ordering) + const sortedFiles = Object.keys(mg.downloadedFiles) + .sort((a, b) => Number(a) - Number(b)) + .map(k => mg.downloadedFiles[Number(k)]); + + const mergeSuccess = await mergeVideos( + sortedFiles, + mergedFilePath, + (percent) => { + const overallProgress = 70 + (percent / 100) * 20; // merge = 70-90% + onProgress({ + id: item.id, + progress: overallProgress, + speed: '', + eta: '', + status: getMergeGroupPhaseText('merging'), + currentPart: 0, + totalParts: 0 + }); + }, + totalDurationSec + ); + + if (!mergeSuccess) { + return { success: false, error: tBackend('ffmpegMergeFailed') }; + } + + mg.mergedFile = mergedFilePath; + saveQueue(downloadQueue); + } + + // ---- PHASE 3: SPLITTING ---- + mg.mergePhase = 'splitting'; + saveQueue(downloadQueue); + emitQueueUpdated(); + + if (cancelledItemIds.has(item.id)) { + return { success: false, error: tBackend('downloadCancelled') }; + } + + const partDuration = config.part_minutes * 60; + const streamer = mg.items[0].streamer.replace(/[^a-zA-Z0-9_-]/g, ''); + const date = new Date(mg.items[0].date); + const dateStr = `${date.getDate().toString().padStart(2, '0')}.${(date.getMonth() + 1).toString().padStart(2, '0')}.${date.getFullYear()}`; + const folder = path.join(config.download_path, streamer, dateStr); + const vodId = parseVodId(mg.items[0].url) || 'merged'; + + const splitResult = await splitMergedFile( + mg.mergedFile!, + folder, + partDuration, + totalDurationSec, + (partNum: number) => { + const startSec = (partNum - 1) * partDuration; + const thisDuration = Math.min(partDuration, totalDurationSec - startSec); + return renderClipFilenameTemplate({ + template: normalizeFilenameTemplate(config.filename_template_parts, DEFAULT_FILENAME_TEMPLATE_PARTS), + title: mg.items[0].title, + vodId, + channel: mg.items[0].streamer, + date, + part: partNum, + partPadded: partNum.toString().padStart(2, '0'), + trimStartSec: startSec, + trimEndSec: startSec + thisDuration, + trimLengthSec: thisDuration, + fullLengthSec: totalDurationSec + }); + }, + (currentPart, totalParts) => { + const overallProgress = 90 + ((currentPart - 1) / totalParts) * 10; // split = 90-100% + onProgress({ + id: item.id, + progress: overallProgress, + speed: '', + eta: '', + status: `${getMergeGroupPhaseText('splitting')} ${currentPart}/${totalParts}...`, + currentPart, + totalParts + }); + }, + item.id + ); + + if (!splitResult.success) { + // Clean up any partial split files + for (const partFile of splitResult.files) { + try { if (fs.existsSync(partFile)) fs.unlinkSync(partFile); } catch { } + } + return { success: false, error: tBackend('ffmpegSplitFailed') }; + } + + mg.splitFiles = splitResult.files; + + // ---- PHASE 4: CLEANUP ---- + mg.mergePhase = 'cleanup'; + saveQueue(downloadQueue); + + // Delete individual downloads + for (const key of Object.keys(mg.downloadedFiles)) { + const filePath = mg.downloadedFiles[Number(key)]; + try { + if (fs.existsSync(filePath)) fs.unlinkSync(filePath); + } catch { } + } + + // Delete merged file + if (mg.mergedFile) { + try { + if (fs.existsSync(mg.mergedFile)) fs.unlinkSync(mg.mergedFile); + } catch { } + } + + mg.mergePhase = 'done'; + appendDebugLog('merge-group-complete', { + itemId: item.id, + parts: splitResult.files.length, + totalDurationSec + }); + + return { success: true, outputFiles: [...splitResult.files] }; +} + +async function processOneQueueItem(item: QueueItem): Promise { + appendDebugLog('queue-item-start', { + itemId: item.id, + title: item.title, + url: item.url, + smartScore: config.smart_queue_scheduler ? getQueuePriorityScore(item) : 0 + }); + + runtimeMetrics.downloadsStarted += 1; + runtimeMetrics.activeItemId = item.id; + runtimeMetrics.activeItemTitle = item.title; + activeQueueItemId = item.id; + + cancelledItemIds.delete(item.id); + item.status = 'downloading'; + saveQueue(downloadQueue); + emitQueueUpdated(); + + item.last_error = ''; + + try { + let finalResult: DownloadResult = { success: false, error: tBackend('unknownDownloadError') }; + const maxAttempts = getRetryAttemptLimit(); + + for (let attempt = 1; attempt <= maxAttempts; attempt++) { + appendDebugLog('queue-item-attempt', { itemId: item.id, attempt, max: maxAttempts }); + + const result = item.mergeGroup + ? await processDownloadMergeGroup(item, (progress) => { + mainWindow?.webContents.send('download-progress', progress); + recordDownloadProgress(progress); + }) + : await downloadVOD(item, (progress) => { + mainWindow?.webContents.send('download-progress', progress); + recordDownloadProgress(progress); + }); + + if (result.success) { + finalResult = result; + break; + } + + finalResult = result; + + if (!isDownloading || cancelledItemIds.has(item.id) || pauseRequested) { + finalResult = { success: false, error: pauseRequested ? tBackend('downloadPaused') : tBackend('downloadCancelled') }; + break; + } + + const errorClass = classifyDownloadError(result.error || ''); + runtimeMetrics.lastErrorClass = errorClass; + + if (errorClass === 'tooling' || errorClass === 'validation') { + appendDebugLog('queue-item-no-retry', { + itemId: item.id, + errorClass, + error: result.error || 'unknown' + }); + break; + } + + if (attempt < maxAttempts) { + const retryDelaySeconds = getRetryDelaySeconds(errorClass, attempt); + runtimeMetrics.retriesScheduled += 1; + runtimeMetrics.lastRetryDelaySeconds = retryDelaySeconds; + + item.last_error = tBackend('attemptFailed', { attempt, max: maxAttempts, errorClass, error: result.error || tBackend('unknownDownloadError') }); + mainWindow?.webContents.send('download-progress', { + id: item.id, + progress: -1, + speed: '', + eta: '', + status: tBackend('retryingIn', { seconds: retryDelaySeconds, errorClass }), + currentPart: item.currentPart, + totalParts: item.totalParts + } as DownloadProgress); + saveQueue(downloadQueue); + emitQueueUpdated(); + await sleep(retryDelaySeconds * 1000); + } else { + runtimeMetrics.retriesExhausted += 1; + } + } + + if (!hasQueueItemId(item.id)) { + appendDebugLog('queue-item-finished-removed', { itemId: item.id }); + return; + } + + const wasPaused = pauseRequested || (finalResult.error || '').includes('pausiert'); + item.status = finalResult.success ? 'completed' : (wasPaused ? 'paused' : 'error'); + item.progress = finalResult.success ? 100 : item.progress; + item.last_error = finalResult.success || wasPaused ? '' : (finalResult.error || tBackend('unknownDownloadError')); + + if (finalResult.success && Array.isArray(finalResult.outputFiles) && finalResult.outputFiles.length > 0) { + // Attach the produced file paths so the renderer can offer + // "Open file" / "Show in folder" actions on completed items, + // surviving a queue persistence round-trip. + item.outputFiles = [...finalResult.outputFiles]; + } + + // Discord webhook for non-live VOD completion. Live recordings + // already get their own end-of-recording webhook in downloadLiveStream. + if (finalResult.success && !item.isLive && config.discord_notify_vod_complete) { + const totalBytes = (item.outputFiles || []).reduce((sum, f) => { + try { return sum + (fs.statSync(f).size || 0); } catch { return sum; } + }, 0); + void sendDiscordWebhook({ + title: `VOD download complete: ${item.streamer}`, + description: item.title || item.url, + color: 'success', + fields: [ + { name: 'Files', value: String((item.outputFiles || []).length), inline: true }, + { name: 'Size', value: formatBytes(totalBytes), inline: true } + ] + }); + } + + // Per-VOD completion notification (separate from the queue-end + // notification fired at the end of processQueue). Off by default + // because users with long queues would get spammed. + if (finalResult.success && config.notify_on_each_completion) { + try { + if (Notification.isSupported()) { + const itemNotification = new Notification({ + title: 'Twitch VOD Manager', + body: `${item.title || item.url}` + }); + const firstFile = item.outputFiles?.[0]; + itemNotification.on('click', () => { + try { + if (mainWindow) { + if (mainWindow.isMinimized()) mainWindow.restore(); + mainWindow.focus(); + } + // Click on a per-item notification opens the + // file directly when we know it; falls back to + // the download folder otherwise. + if (firstFile && fs.existsSync(firstFile)) { + shell.showItemInFolder(firstFile); + } else if (config.download_path && fs.existsSync(config.download_path)) { + void shell.openPath(config.download_path); + } + } catch (e) { + appendDebugLog('per-item-notification-click-failed', String(e)); + } + }); + itemNotification.show(); + } + } catch { /* notifications optional */ } + } + + if (finalResult.success) { + // Record the VOD ID so the renderer can mark this VOD as + // already-downloaded the next time the user browses the + // streamer's archive. Merge groups don't have a single VOD + // ID — record each component instead. + if (item.mergeGroup?.items?.length) { + for (const m of item.mergeGroup.items) { + const id = parseVodId(m.url); + if (id) recordDownloadedVodId(id); + } + } else { + const id = parseVodId(item.url); + if (id) recordDownloadedVodId(id); + } + + // Optional chat-replay download. Only for non-live, non-merge + // VODs that have a parseable VOD id and produced at least one + // output file. Saved as {video_basename}.chat.json next to the + // video. Truncation is logged but not fatal. + if (config.download_chat_replay && !item.isLive && !item.mergeGroup) { + const vodIdForChat = parseVodId(item.url); + const firstOutput = item.outputFiles?.[0]; + if (vodIdForChat && firstOutput) { + try { + mainWindow?.webContents.send('download-progress', { + id: item.id, + progress: 100, + speed: '', + eta: '', + status: tBackend('statusFetchingChatReplay'), + currentPart: 0, + totalParts: 0 + } as DownloadProgress); + + const replay = await fetchVodChatReplay(vodIdForChat, (count) => { + mainWindow?.webContents.send('download-progress', { + id: item.id, + progress: 100, + speed: '', + eta: '', + status: tBackend('statusChatMessagesFetched', { count: String(count) }), + currentPart: 0, + totalParts: 0 + } as DownloadProgress); + }, () => cancelledItemIds.has(item.id)); + + const chatPath = chatReplayPathFor(firstOutput); + const payload = { + videoId: vodIdForChat, + videoUrl: item.url, + streamer: item.streamer, + title: item.title, + fetchedAt: new Date().toISOString(), + messageCount: replay.messages.length, + truncated: replay.truncated, + pages: replay.pages, + messages: replay.messages + }; + writeFileAtomicSync(chatPath, JSON.stringify(payload, null, 2)); + appendDebugLog('chat-replay-saved', { + itemId: item.id, + videoId: vodIdForChat, + messages: replay.messages.length, + pages: replay.pages, + truncated: replay.truncated, + path: chatPath + }); + if (Array.isArray(item.outputFiles)) { + item.outputFiles = [...item.outputFiles, chatPath]; + } + } catch (e) { + // Non-fatal: video download still succeeded. + appendDebugLog('chat-replay-failed', { itemId: item.id, error: String(e) }); + } + } + } + } + + if (finalResult.success) { + runtimeMetrics.downloadsCompleted += 1; + } else if (!wasPaused) { + runtimeMetrics.downloadsFailed += 1; + } + + appendDebugLog('queue-item-finished', { + itemId: item.id, + status: item.status, + error: item.last_error + }); + + saveQueue(downloadQueue); + emitQueueUpdated(); + } finally { + activeDownloads.delete(item.id); + cancelledItemIds.delete(item.id); + // Release only THIS item's claimed filenames (other parallel downloads keep their claims) + releaseClaimedFilenamesForItem(item.id); + clearDownloadProgress(item.id); + } +} + +async function processQueue(): Promise { + if (isDownloading || !downloadQueue.some((item) => item.status === 'pending')) return; + + appendDebugLog('queue-start', { + items: downloadQueue.length, + smartScheduler: config.smart_queue_scheduler, + performanceMode: config.performance_mode, + parallelDownloads: config.parallel_downloads || 1 + }); + + isDownloading = true; + pauseRequested = false; + cancelledItemIds.clear(); + mainWindow?.webContents.send('download-started'); + emitQueueUpdated(); + + const maxSlots = Math.min(Math.max(1, config.parallel_downloads || 1), 2); + const activePromises = new Map>(); + + while (isDownloading && !pauseRequested) { + // Clean up finished promises + for (const [id] of activePromises) { + const queueItem = downloadQueue.find(i => i.id === id); + if (!queueItem || queueItem.status !== 'downloading') { + activePromises.delete(id); + } + } + + // Fill available slots + while (activePromises.size < maxSlots && !pauseRequested) { + const item = pickNextPendingQueueItem(); + if (!item) break; + + const itemPromise = processOneQueueItem(item); + activePromises.set(item.id, itemPromise); + } + + if (activePromises.size === 0) break; + + // Wait for any one download to finish before re-checking + await Promise.race([...activePromises.values()]); + } + + // Wait for all remaining active downloads to complete + if (activePromises.size > 0) { + await Promise.allSettled([...activePromises.values()]); + } + + isDownloading = false; + pauseRequested = false; + runtimeMetrics.activeItemId = null; + runtimeMetrics.activeItemTitle = null; + activeQueueItemId = null; + activeDownloads.clear(); + cancelledItemIds.clear(); + + saveQueue(downloadQueue); + emitQueueUpdated(); + mainWindow?.webContents.send('download-finished'); + try { + if (Notification.isSupported()) { + const completed = downloadQueue.filter(i => i.status === 'completed').length; + const failed = downloadQueue.filter(i => i.status === 'error').length; + const notification = new Notification({ + title: 'Twitch VOD Manager', + body: failed > 0 + ? `${completed} Downloads fertig, ${failed} fehlgeschlagen` + : `${completed} Downloads abgeschlossen` + }); + // Click brings the app to the foreground AND opens the download + // folder so the user can immediately see the output files. + notification.on('click', () => { + try { + if (mainWindow) { + if (mainWindow.isMinimized()) mainWindow.restore(); + mainWindow.focus(); + } + if (config.download_path && fs.existsSync(config.download_path)) { + void shell.openPath(config.download_path); + } + } catch (e) { + appendDebugLog('notification-click-failed', String(e)); + } + }); + notification.show(); + } + } catch { } + appendDebugLog('queue-finished', { items: downloadQueue.length }); +} + +// ========================================== +// WINDOW CREATION +// ========================================== +function createWindow(): void { + nativeTheme.themeSource = config.theme === 'light' ? 'light' : 'dark'; + + mainWindow = new BrowserWindow({ + width: 1400, + height: 900, + minWidth: 1200, + minHeight: 700, + title: `Twitch VOD Manager [v${APP_VERSION}]`, + backgroundColor: '#0e0e10', + autoHideMenuBar: true, + webPreferences: { + nodeIntegration: false, + contextIsolation: true, + preload: path.join(__dirname, 'preload.js') + } + }); + + if (process.platform !== 'darwin') { + mainWindow.removeMenu(); + } + + mainWindow.loadFile(path.join(__dirname, '../src/index.html')); + + mainWindow.webContents.on('did-finish-load', () => { + emitQueueUpdated(true); + if (isDownloading) { + mainWindow?.webContents.send('download-started'); + } + + if (autoUpdateReadyToInstall && downloadedUpdateVersion) { + mainWindow?.webContents.send('update-downloaded', buildUpdateInfoPayload(downloadedUpdateVersion)); + } + + // Auto-resume: if the user opted in AND the persisted queue has + // pending entries, kick off processing after a short delay so the + // UI has time to render and the user can still pause if they want. + if (config.auto_resume_queue_on_startup && !isDownloading) { + const hasPending = downloadQueue.some((it) => it.status === 'pending'); + if (hasPending) { + appendDebugLog('auto-resume-queue-scheduled', { pending: downloadQueue.filter((it) => it.status === 'pending').length }); + setTimeout(() => { + if (config.auto_resume_queue_on_startup && !isDownloading + && downloadQueue.some((it) => it.status === 'pending')) { + void processQueue(); + } + }, 5000); + } + } + }); + + mainWindow.on('closed', () => { + mainWindow = null; + }); + + // Setup auto-updater after window is ready + setTimeout(() => { + setupAutoUpdater(); + }, 3000); +} + +// ========================================== +// AUTO-UPDATER (electron-updater) +// ========================================== +function hasNewerKnownUpdateThanDownloaded(): boolean { + if (!latestKnownUpdateVersion || !downloadedUpdateVersion) { + return false; + } + + return isNewerUpdateVersion(latestKnownUpdateVersion, downloadedUpdateVersion); +} + +function normalizeReleaseVersionCandidate(value: unknown): string | undefined { + if (typeof value !== 'string') { + return undefined; + } + + const trimmed = value.trim(); + if (!trimmed) { + return undefined; + } + + return normalizeUpdateVersion(trimmed) || trimmed.replace(/^v/i, ''); +} + +function cacheLatestReleaseUpdateInfo(releaseData: any): void { + if (!releaseData || typeof releaseData !== 'object') { + return; + } + + const tagName = typeof releaseData.tag_name === 'string' ? releaseData.tag_name.trim() : ''; + const version = normalizeReleaseVersionCandidate(tagName) + || normalizeReleaseVersionCandidate(releaseData.name); + const releaseName = typeof releaseData.name === 'string' ? releaseData.name.trim() : ''; + const releaseNotes = typeof releaseData.body === 'string' ? releaseData.body : ''; + const releaseDate = typeof releaseData.published_at === 'string' + ? releaseData.published_at + : (typeof releaseData.created_at === 'string' ? releaseData.created_at : undefined); + + latestReleaseUpdateInfo = { + tagName: tagName || undefined, + version, + releaseDate, + releaseName: releaseName || undefined, + releaseNotes: releaseNotes.trim() ? releaseNotes : undefined + }; +} + +function buildUpdateInfoPayload(version: string, releaseDate?: string): { + version: string; + releaseDate?: string; + releaseName?: string; + releaseNotes?: string; +} { + const normalizedVersion = normalizeReleaseVersionCandidate(version) || version; + const cachedVersion = latestReleaseUpdateInfo?.version + ? (normalizeReleaseVersionCandidate(latestReleaseUpdateInfo.version) || latestReleaseUpdateInfo.version) + : undefined; + const hasMatchingReleaseInfo = !cachedVersion || cachedVersion === normalizedVersion; + + return { + version: normalizedVersion, + releaseDate: releaseDate || (hasMatchingReleaseInfo ? latestReleaseUpdateInfo?.releaseDate : undefined), + releaseName: hasMatchingReleaseInfo ? latestReleaseUpdateInfo?.releaseName : undefined, + releaseNotes: hasMatchingReleaseInfo ? latestReleaseUpdateInfo?.releaseNotes : undefined + }; +} + +async function requestUpdateCheck(source: UpdateCheckSource, force = false): Promise<{ started: boolean; reason?: string }> { + if (autoUpdateCheckInProgress) { + return { started: false, reason: 'in-progress' }; + } + + const now = Date.now(); + if (!force && lastAutoUpdateCheckAt > 0 && (now - lastAutoUpdateCheckAt) < AUTO_UPDATE_MIN_CHECK_GAP_MS) { + return { started: false, reason: 'throttled' }; + } + + autoUpdateCheckInProgress = true; + lastAutoUpdateCheckAt = now; + appendDebugLog('update-check-start', { source }); + + try { + try { + const githubReleaseResponse = await axios.get(GITHUB_RELEASES_API_LATEST_URL, { + timeout: 5000, + headers: { + 'Accept': 'application/json', + 'User-Agent': 'Twitch-VOD-Manager' + } + }); + cacheLatestReleaseUpdateInfo(githubReleaseResponse.data); + const tagName = latestReleaseUpdateInfo?.tagName || githubReleaseResponse.data?.tag_name; + if (tagName) { + autoUpdater.setFeedURL({ + provider: 'generic', + url: `${GITHUB_RELEASES_DOWNLOAD_BASE_URL}/${tagName}` + }); + appendDebugLog('github-feed-url-set', { tagName, owner: GITHUB_REPO_OWNER, repo: GITHUB_REPO_NAME }); + } + } catch (apiErr) { + appendDebugLog('github-api-failed', String(apiErr)); + } + + let timeoutHandle: NodeJS.Timeout | null = null; + try { + await Promise.race([ + autoUpdater.checkForUpdates(), + new Promise((_, reject) => { + timeoutHandle = setTimeout(() => { + reject(new Error(`Update check timed out after ${AUTO_UPDATE_CHECK_TIMEOUT_MS}ms`)); + }, AUTO_UPDATE_CHECK_TIMEOUT_MS); + }) + ]); + } finally { + if (timeoutHandle) { + clearTimeout(timeoutHandle); + timeoutHandle = null; + } + } + + return { started: true }; + } catch (err) { + appendDebugLog('update-check-failed', { source, error: String(err) }); + console.error('Update check failed:', err); + return { started: false, reason: 'error' }; + } finally { + autoUpdateCheckInProgress = false; + } +} + +async function requestUpdateDownload(source: UpdateDownloadSource): Promise<{ started: boolean; reason?: string }> { + if (autoUpdateReadyToInstall && !hasNewerKnownUpdateThanDownloaded()) { + return { started: false, reason: 'ready-to-install' }; + } + + if (autoUpdateDownloadInProgress) { + return { started: false, reason: 'in-progress' }; + } + + autoUpdateDownloadInProgress = true; + appendDebugLog('update-download-start', { source }); + + try { + await autoUpdater.downloadUpdate(); + return { started: true }; + } catch (err) { + appendDebugLog('update-download-failed', { source, error: String(err) }); + console.error('Download failed:', err); + return { started: false, reason: 'error' }; + } finally { + autoUpdateDownloadInProgress = false; + } +} + +function stopAutoUpdatePolling(): void { + if (autoUpdateCheckTimer) { + clearInterval(autoUpdateCheckTimer); + autoUpdateCheckTimer = null; + } + + if (autoUpdateStartupTimer) { + clearTimeout(autoUpdateStartupTimer); + autoUpdateStartupTimer = null; + } +} + +function startAutoUpdatePolling(): void { + if (!autoUpdateCheckTimer) { + autoUpdateCheckTimer = setInterval(() => { + void requestUpdateCheck('interval'); + }, AUTO_UPDATE_CHECK_INTERVAL_MS); + + autoUpdateCheckTimer.unref?.(); + } + + if (autoUpdateStartupTimer) { + clearTimeout(autoUpdateStartupTimer); + autoUpdateStartupTimer = null; + } + + autoUpdateStartupTimer = setTimeout(() => { + autoUpdateStartupTimer = null; + void requestUpdateCheck('startup', true); + }, AUTO_UPDATE_STARTUP_CHECK_DELAY_MS); +} + +function setupAutoUpdater() { + if (autoUpdaterInitialized) { + startAutoUpdatePolling(); + return; + } + + autoUpdaterInitialized = true; + autoUpdater.autoDownload = false; + autoUpdater.autoInstallOnAppQuit = true; + autoUpdater.autoRunAppAfterInstall = true; + + autoUpdater.on('checking-for-update', () => { + appendDebugLog('auto-updater-checking'); + mainWindow?.webContents.send('update-checking'); + }); + + autoUpdater.on('update-available', (info) => { + const incomingVersion = normalizeUpdateVersion(info.version); + const displayVersion = incomingVersion || info.version; + + if (latestKnownUpdateVersion && compareUpdateVersions(incomingVersion, latestKnownUpdateVersion) < 0) { + appendDebugLog('update-available-ignored-older', { + incomingVersion: displayVersion, + knownVersion: latestKnownUpdateVersion + }); + return; + } + + latestKnownUpdateVersion = incomingVersion || latestKnownUpdateVersion; + + const hasAlreadyDownloadedThisVersion = Boolean( + autoUpdateReadyToInstall && + downloadedUpdateVersion && + compareUpdateVersions(downloadedUpdateVersion, incomingVersion) === 0 + ); + + appendDebugLog('auto-updater-update-available', { version: displayVersion }); + if (!hasAlreadyDownloadedThisVersion) { + autoUpdateReadyToInstall = false; + } + + autoUpdateDownloadInProgress = false; + + if (hasAlreadyDownloadedThisVersion) { + if (mainWindow) { + mainWindow.webContents.send('update-downloaded', buildUpdateInfoPayload(displayVersion, info.releaseDate)); + } + return; + } + + if (mainWindow) { + mainWindow.webContents.send('update-available', buildUpdateInfoPayload(displayVersion, info.releaseDate)); + } + + if (AUTO_UPDATE_AUTO_DOWNLOAD) { + void requestUpdateDownload('auto'); + } + }); + + autoUpdater.on('update-not-available', () => { + appendDebugLog('auto-updater-update-not-available'); + mainWindow?.webContents.send('update-not-available'); + }); + + autoUpdater.on('download-progress', (progress) => { + // No per-tick stdout — the autoUpdater fires this ~10x/sec during + // an in-flight download. The renderer banner is the user-visible + // surface; appendDebugLog already captures phase transitions. + if (mainWindow) { + mainWindow.webContents.send('update-download-progress', { + percent: progress.percent, + bytesPerSecond: progress.bytesPerSecond, + transferred: progress.transferred, + total: progress.total + }); + } + }); + + autoUpdater.on('update-downloaded', (info) => { + const downloadedVersion = normalizeUpdateVersion(info.version) || info.version; + appendDebugLog('auto-updater-update-downloaded', { version: downloadedVersion }); + autoUpdateReadyToInstall = true; + autoUpdateDownloadInProgress = false; + downloadedUpdateVersion = downloadedVersion; + if (!latestKnownUpdateVersion || compareUpdateVersions(downloadedVersion, latestKnownUpdateVersion) > 0) { + latestKnownUpdateVersion = downloadedVersion; + } + if (mainWindow) { + mainWindow.webContents.send('update-downloaded', buildUpdateInfoPayload(downloadedVersion, info.releaseDate)); + } + }); + + autoUpdater.on('error', (err) => { + autoUpdateCheckInProgress = false; + autoUpdateDownloadInProgress = false; + const message = String(err); + appendDebugLog('auto-updater-error', message); + mainWindow?.webContents.send('update-error', { message }); + console.error('Auto-updater error:', err); + }); + + startAutoUpdatePolling(); +} + +// ========================================== +// IPC HANDLERS +// ========================================== +ipcMain.handle('get-config', () => config); + +ipcMain.handle('get-automation-status', () => ({ + autoRecord: { + watching: Array.isArray(config.auto_record_streamers) ? config.auto_record_streamers.length : 0, + lastRunAt: autoRecordLastRunAt, + nextRunAt: autoRecordNextRunAt, + lastTriggeredCount: autoRecordLastTriggerCount, + inFlight: autoRecordPollInFlight + }, + autoVod: { + watching: Array.isArray(config.auto_vod_download_streamers) ? config.auto_vod_download_streamers.length : 0, + lastRunAt: autoVodLastRunAt, + nextRunAt: autoVodNextRunAt, + lastQueuedCount: autoVodLastQueuedCount, + inFlight: autoVodPollInFlight + } +})); + +ipcMain.handle('trigger-auto-record-scan', async () => { + const triggered = await runAutoRecordPoll(); + return { triggered }; +}); + +ipcMain.handle('trigger-auto-vod-scan', async () => { + const queuedCount = await runAutoVodPoll(); + return { queuedCount }; +}); + +ipcMain.handle('save-config', (_, newConfig: Partial) => { + const previousClientId = config.client_id; + const previousClientSecret = config.client_secret; + const previousCacheMinutes = config.metadata_cache_minutes; + const previousPersistQueueOnRestart = config.persist_queue_on_restart; + const previousTheme = config.theme; + const previousAutoRecordList = JSON.stringify(config.auto_record_streamers || []); + const previousAutoRecordSeconds = config.auto_record_poll_seconds; + const previousAutoVodList = JSON.stringify(config.auto_vod_download_streamers || []); + const previousAutoVodMinutes = config.auto_vod_download_poll_minutes; + const previousStreamerList = JSON.stringify(config.streamers || []); + + config = normalizeConfigTemplates({ ...config, ...newConfig }); + + if (config.client_id !== previousClientId || config.client_secret !== previousClientSecret) { + accessToken = null; + twitchLoginInFlight = null; + } + + if (config.metadata_cache_minutes !== previousCacheMinutes) { + clearMetadataCaches(); + } + + if (config.theme !== previousTheme) { + nativeTheme.themeSource = config.theme === 'light' ? 'light' : 'dark'; + } + + saveConfig(config); + + if (config.persist_queue_on_restart === false) { + pendingQueueSnapshot = null; + if (queueSaveTimer) { + clearTimeout(queueSaveTimer); + queueSaveTimer = null; + } + clearQueueFileFromDisk(); + } else if (previousPersistQueueOnRestart === false) { + saveQueue(downloadQueue, true); + } + + // Restart auto-record poller if its inputs changed (added/removed + // streamers or interval changed). Drop transition state for any + // streamer no longer being watched so re-enabling them later doesn't + // suppress an immediate first-poll trigger. + const newAutoRecordList = JSON.stringify(config.auto_record_streamers || []); + if (newAutoRecordList !== previousAutoRecordList || config.auto_record_poll_seconds !== previousAutoRecordSeconds) { + const watched = new Set(config.auto_record_streamers || []); + for (const k of Array.from(autoRecordLastLiveState.keys())) { + if (!watched.has(k)) autoRecordLastLiveState.delete(k); + } + restartAutoRecordPoller(); + } + + // Same dance for the auto-VOD poller — independent cadence from + // auto-record because VOD listings are heavier to fetch. + const newAutoVodList = JSON.stringify(config.auto_vod_download_streamers || []); + if (newAutoVodList !== previousAutoVodList || config.auto_vod_download_poll_minutes !== previousAutoVodMinutes) { + restartAutoVodPoller(); + } + + // Live-status batch poller — fire an immediate refresh when the + // streamer list itself changes (added/removed) so the sidebar dots + // update instantly instead of waiting for the next 60s tick. + const newStreamerList = JSON.stringify(config.streamers || []); + if (newStreamerList !== previousStreamerList) { + restartLiveStatusPoller(); + } + + // Restart cleanup timer when the toggle flips; harmless to call when + // unchanged because restartAutoCleanupTimer just resets the interval. + restartAutoCleanupTimer(); + + return config; +}); + +ipcMain.handle('login', async () => { + return await twitchLogin(); +}); + +ipcMain.handle('get-user-id', async (_, username: string) => { + return await getUserId(username); +}); + +ipcMain.handle('get-vods', async (_, userId: string, forceRefresh: boolean = false) => { + return await getVODs(userId, forceRefresh); +}); + +ipcMain.handle('get-queue', () => downloadQueue); + +ipcMain.handle('start-live-recording', async (_, streamerName: string) => { + if (typeof streamerName !== 'string' || !streamerName) { + return { success: false, error: 'Invalid streamer name' }; + } + const login = normalizeLogin(streamerName); + if (!login) return { success: false, error: 'Invalid streamer name' }; + + const liveInfo = await getLiveStreamInfo(login); + if (liveInfo === null) { + return { success: false, error: 'Could not check live status. Try again.' }; + } + if (!liveInfo.isLive) { + return { success: false, error: 'OFFLINE', streamer: login }; + } + + const channelUrl = `https://www.twitch.tv/${login}`; + const liveItem: QueueItem = { + id: generateQueueItemId(), + title: liveInfo.title || `${login} (LIVE)`, + url: channelUrl, + date: new Date().toISOString(), + streamer: login, + duration_str: '0s', // unknown — stream is in progress + status: 'pending', + progress: 0, + isLive: true + }; + + // Duplicate guard — refuse to start a second live recording of the + // same channel while one is already active or pending. + const dup = downloadQueue.some((it) => it.isLive && it.streamer === login + && (it.status === 'pending' || it.status === 'downloading')); + if (dup) { + return { success: false, error: 'ALREADY_RECORDING', streamer: login }; + } + + downloadQueue.push(liveItem); + saveQueue(downloadQueue); + emitQueueUpdated(); + if (!isDownloading) void processQueue(); + appendDebugLog('live-recording-queued', { streamer: login, title: liveItem.title }); + return { success: true, streamer: login, title: liveInfo.title || login }; +}); + +ipcMain.handle('add-to-queue', (_, item: Omit) => { + if (config.prevent_duplicate_downloads && hasActiveDuplicate(item)) { + runtimeMetrics.duplicateSkips += 1; + mainWindow?.webContents.send('queue-duplicate-skipped', { + title: item.title, + streamer: item.streamer, + url: item.url + }); + appendDebugLog('queue-item-duplicate-skipped', { + title: item.title, + url: item.url, + streamer: item.streamer + }); + return downloadQueue; + } + + const queueItem: QueueItem = { + ...item, + id: generateQueueItemId(), + status: 'pending', + progress: 0 + }; + downloadQueue.push(queueItem); + saveQueue(downloadQueue); + emitQueueUpdated(); + return downloadQueue; +}); + +ipcMain.handle('remove-from-queue', (_, id: string) => { + const wasActiveItem = activeQueueItemId === id || activeDownloads.has(id); + + if (wasActiveItem) { + cancelledItemIds.add(id); + const tracking = activeDownloads.get(id); + if (tracking?.process) { + tracking.process.kill(); + } + activeDownloads.delete(id); + activeQueueItemId = null; + runtimeMetrics.activeItemId = null; + runtimeMetrics.activeItemTitle = null; + appendDebugLog('queue-item-removed-active-cancelled', { id }); + } + + // Clean up merge-group temp files (must run for any merge group, not just active) + const removedItem = downloadQueue.find(item => item.id === id); + if (removedItem?.mergeGroup) { + const mg = removedItem.mergeGroup; + for (const key of Object.keys(mg.downloadedFiles)) { + try { if (fs.existsSync(mg.downloadedFiles[Number(key)])) fs.unlinkSync(mg.downloadedFiles[Number(key)]); } catch { } + } + if (mg.mergedFile) { + try { if (fs.existsSync(mg.mergedFile)) fs.unlinkSync(mg.mergedFile); } catch { } + } + } + + downloadQueue = downloadQueue.filter(item => item.id !== id); + saveQueue(downloadQueue); + emitQueueUpdated(); + return downloadQueue; +}); + +ipcMain.handle('clear-completed', () => { + downloadQueue = downloadQueue.filter(item => item.status !== 'completed'); + saveQueue(downloadQueue); + emitQueueUpdated(); + return downloadQueue; +}); + +ipcMain.handle('reorder-queue', (_, orderIds: string[]) => { + const order = new Map(orderIds.map((id, idx) => [id, idx])); + const withOrder = [...downloadQueue].sort((a, b) => { + const ai = order.has(a.id) ? (order.get(a.id) as number) : Number.MAX_SAFE_INTEGER; + const bi = order.has(b.id) ? (order.get(b.id) as number) : Number.MAX_SAFE_INTEGER; + return ai - bi; + }); + + downloadQueue = withOrder; + saveQueue(downloadQueue); + emitQueueUpdated(); + return downloadQueue; +}); + +ipcMain.handle('retry-failed-downloads', () => { + downloadQueue = downloadQueue.map((item) => { + if (item.status !== 'error') return item; + + return { + ...item, + status: 'pending', + progress: 0, + last_error: '' + }; + }); + + saveQueue(downloadQueue); + emitQueueUpdated(); + + if (!isDownloading) { + void processQueue(); + } + + return downloadQueue; +}); + +ipcMain.handle('retry-queue-item', (_, id: string) => { + if (typeof id !== 'string' || !id) return downloadQueue; + const idx = downloadQueue.findIndex((it) => it.id === id); + if (idx < 0) return downloadQueue; + const item = downloadQueue[idx]; + if (item.status !== 'error') return downloadQueue; + + downloadQueue[idx] = { + ...item, + status: 'pending', + progress: 0, + last_error: '' + }; + + saveQueue(downloadQueue); + emitQueueUpdated(); + appendDebugLog('queue-item-retry-single', { id, title: item.title }); + + if (!isDownloading) { + void processQueue(); + } + + return downloadQueue; +}); + +ipcMain.handle('create-merge-group', (_, itemIds: string[]) => { + const selectedItems = downloadQueue.filter(item => itemIds.includes(item.id)); + + if (selectedItems.length < 2) { + return downloadQueue; + } + + // Validate all are pending + if (selectedItems.some(item => item.status !== 'pending')) { + return downloadQueue; + } + + // Preserve user-defined order from renderer (itemIds array order) + const sorted = itemIds + .map(id => selectedItems.find(item => item.id === id)) + .filter((item): item is QueueItem => item !== undefined); + + // Calculate total duration + const totalDurationSec = sorted.reduce((sum, item) => sum + parseDuration(item.duration_str), 0); + const totalDurationStr = (() => { + const h = Math.floor(totalDurationSec / 3600); + const m = Math.floor((totalDurationSec % 3600) / 60); + const s = totalDurationSec % 60; + const parts: string[] = []; + if (h > 0) parts.push(`${h}h`); + if (m > 0) parts.push(`${m}m`); + if (s > 0 || parts.length === 0) parts.push(`${s}s`); + return parts.join(''); + })(); + + // Generate title (language-aware) + const first = sorted[0]; + const isEnglish = config.language === 'en'; + const title = sorted.length === 2 + ? `Merge: ${first.title} + ${sorted[1].title}` + : `Merge: ${first.title} + ${sorted.length - 1} ${isEnglish ? 'more' : 'weitere'}`; + + // Build merge group + const mergeGroup: MergeGroup = { + items: sorted.map(item => ({ + url: item.url, + title: item.title, + date: item.date, + streamer: item.streamer, + duration_str: item.duration_str + })), + mergePhase: 'downloading', + currentItemIndex: 0, + downloadedFiles: {}, + totalDurationSec + }; + + // Create merged queue item + const mergedItem: QueueItem = { + id: generateQueueItemId(), + title, + url: first.url, + date: first.date, + streamer: first.streamer, + duration_str: totalDurationStr, + status: 'pending', + progress: 0, + mergeGroup + }; + + // Find position of first selected item + const firstIndex = downloadQueue.findIndex(item => itemIds.includes(item.id)); + + // Remove selected items and insert merged item at first position + downloadQueue = downloadQueue.filter(item => !itemIds.includes(item.id)); + downloadQueue.splice(firstIndex >= 0 ? Math.min(firstIndex, downloadQueue.length) : downloadQueue.length, 0, mergedItem); + + saveQueue(downloadQueue); + emitQueueUpdated(); + return downloadQueue; +}); + +ipcMain.handle('start-download', async () => { + downloadQueue = downloadQueue.map((item) => item.status === 'paused' ? { ...item, status: 'pending' } : item); + + const hasPendingItems = downloadQueue.some(item => item.status === 'pending'); + if (!hasPendingItems) { + emitQueueUpdated(); + return false; + } + + saveQueue(downloadQueue); + emitQueueUpdated(); + + if (!isDownloading) { + void processQueue(); + } + return true; +}); + +ipcMain.handle('pause-download', () => { + if (!isDownloading) return false; + + pauseRequested = true; + // Kill queue downloads only — cutter/merger/splitter use currentEditorProcess + // and aren't affected by pause-download. Per-item cancel state lives in + // cancelledItemIds — every active item gets added below. + for (const [id, tracking] of activeDownloads) { + cancelledItemIds.add(id); + if (tracking.process) { + tracking.process.kill(); + } + } + return true; +}); + +ipcMain.handle('cancel-download', () => { + isDownloading = false; + pauseRequested = false; + // Kill queue downloads only — see pause-download note above. + for (const [id, tracking] of activeDownloads) { + cancelledItemIds.add(id); + if (tracking.process) { + tracking.process.kill(); + } + } + return true; +}); + +ipcMain.handle('select-folder', async () => { + const result = await dialog.showOpenDialog(mainWindow!, { + properties: ['openDirectory'] + }); + return result.filePaths[0] || null; +}); + +ipcMain.handle('select-video-file', async () => { + const result = await dialog.showOpenDialog(mainWindow!, { + properties: ['openFile'], + filters: [ + { name: 'Video Files', extensions: ['mp4', 'mkv', 'ts', 'mov', 'avi'] } + ] + }); + return result.filePaths[0] || null; +}); + +ipcMain.handle('open-folder', (_, folderPath: string) => { + if (fs.existsSync(folderPath)) { + shell.openPath(folderPath); + } +}); + +// Extensions that shell.openPath would happily execute via the system +// default. Calc.exe via XSS smuggling is the canonical example; this +// list blocks the obvious vectors. Media/text/image extensions are +// still fine — shell.openPath opens them in the OS's default viewer. +const OPEN_FILE_BLOCKED_EXTENSIONS = new Set([ + '.exe', '.bat', '.cmd', '.com', '.ps1', '.vbs', '.vbe', + '.js', '.jse', '.wsf', '.wsh', '.scr', '.msi', '.msp', + '.lnk', '.cpl', '.reg', '.hta', '.jar', '.application' +]); + +ipcMain.handle('open-file', async (_, filePath: string): Promise => { + if (typeof filePath !== 'string' || !filePath) return false; + if (!fs.existsSync(filePath)) return false; + const ext = path.extname(filePath).toLowerCase(); + if (OPEN_FILE_BLOCKED_EXTENSIONS.has(ext)) { + appendDebugLog('open-file-rejected-extension', { ext, path: filePath.slice(0, 200) }); + return false; + } + const result = await shell.openPath(filePath); + // shell.openPath returns '' on success, an error string on failure. + return result === ''; +}); + +ipcMain.handle('show-in-folder', (_, filePath: string): boolean => { + if (typeof filePath !== 'string' || !filePath) return false; + if (!fs.existsSync(filePath)) return false; + shell.showItemInFolder(filePath); + return true; +}); + +ipcMain.handle('get-version', () => APP_VERSION); + +ipcMain.handle('check-update', async () => { + try { + setupAutoUpdater(); + const result = await requestUpdateCheck('manual', true); + if (result.reason === 'error') { + return { error: true }; + } + + return result.started + ? { checking: true } + : { checking: true, skipped: result.reason }; + } catch (err) { + console.error('Update check failed:', err); + return { error: true }; + } +}); + +ipcMain.handle('download-update', async () => { + try { + setupAutoUpdater(); + const result = await requestUpdateDownload('manual'); + if (result.reason === 'error') { + return { error: true }; + } + + return result.started + ? { downloading: true } + : { downloading: true, skipped: result.reason }; + } catch (err) { + console.error('Download failed:', err); + return { error: true }; + } +}); + +ipcMain.handle('install-update', () => { + autoUpdater.quitAndInstall(true, true); +}); + +ipcMain.handle('open-external', async (_, url: string) => { + // Only allow https / http URLs — never let the renderer push a + // file://, javascript:, or shell:-style URL through to the OS + // shell.openExternal handler. The renderer is contextIsolated + + // nodeIntegration: false, but an XSS through (e.g.) a streamer name + // smuggling a payload into a template would otherwise hand the + // attacker shell.openExternal which on Windows happily resolves + // file:///C:/Windows/System32/calc.exe. + if (typeof url !== 'string') return; + const trimmed = url.trim(); + if (!/^https?:\/\//i.test(trimmed)) { + appendDebugLog('open-external-rejected', { url: trimmed.slice(0, 200) }); + return; + } + await shell.openExternal(trimmed); +}); + +// Tracks active standalone clip downloads so cancel-download / window-all-closed +// can kill them. Separate from activeDownloads (queue) because clip downloads +// don't go through the queue scheduler. +const activeClipProcesses = new Map(); + +ipcMain.handle('download-clip', async (_, clipUrl: string) => { + let clipId = ''; + const match1 = clipUrl.match(/clips\.twitch\.tv\/([A-Za-z0-9_-]+)/); + const match2 = clipUrl.match(/twitch\.tv\/[^/]+\/clip\/([A-Za-z0-9_-]+)/); + + if (match1) clipId = match1[1]; + else if (match2) clipId = match2[1]; + else return { success: false, error: tBackend('invalidClipUrl') }; + + const clipInfo = await getClipInfo(clipId); + if (!clipInfo) return { success: false, error: tBackend('clipNotFound') }; + + // Sanitize broadcaster_name for path safety — Twitch returns the display + // name which can contain unicode, spaces, or punctuation that breaks + // path joining on some Windows configurations. + const safeBroadcaster = sanitizeFilenamePart( + typeof clipInfo.broadcaster_name === 'string' ? clipInfo.broadcaster_name : '', + 'unknown' + ); + const folder = path.join(config.download_path, 'Clips', safeBroadcaster); + fs.mkdirSync(folder, { recursive: true }); + + const clipDiskCheck = ensureDiskSpace(folder, 128 * 1024 * 1024, 'Clip-Download'); + if (!clipDiskCheck.success) { + return { success: false, error: clipDiskCheck.error || tBackend('diskSpaceShortGeneric') }; + } + + const rawTitle = typeof clipInfo.title === 'string' ? clipInfo.title : ''; + const safeTitle = (rawTitle.replace(/[^a-zA-Z0-9_\- ]/g, '').trim().substring(0, 50)) || 'clip'; + // Use ensureUniqueFilename so retrying a clip with the same title doesn't + // overwrite the previous download. itemId is the clipId — if the user + // cancels via cancel-download, that's the handle. + const filename = ensureUniqueFilename(path.join(folder, `${safeTitle}.mp4`), clipId); + + return new Promise<{ success: boolean; error?: string; filename?: string }>((resolve) => { + const streamlinkCmd = getStreamlinkCommand(); + const proc = spawn(streamlinkCmd.command, [ + ...streamlinkCmd.prefixArgs, + `https://clips.twitch.tv/${clipId}`, + getStreamlinkStreamArg(), + '-o', filename, + '--force' + ], { windowsHide: true }); + + activeClipProcesses.set(clipId, proc); + appendDebugLog('clip-download-start', { clipId, broadcaster: safeBroadcaster, filename }); + + proc.on('close', (code) => { + activeClipProcesses.delete(clipId); + releaseClaimedFilenamesForItem(clipId); + + if (code !== 0 || !fs.existsSync(filename)) { + appendDebugLog('clip-download-failed', { clipId, code }); + resolve({ success: false, error: tBackend('downloadFailedExitCode', { code: String(code ?? -1) }) }); + return; + } + + // Integrity: clips are short but should still be at least a few KB + // and parse as a video stream via ffprobe. Empty/zero-byte files + // were previously reported as "success" because exit code was 0. + const stats = fs.statSync(filename); + if (stats.size < 16 * 1024) { + try { fs.unlinkSync(filename); } catch { } + appendDebugLog('clip-download-too-small', { clipId, bytes: stats.size }); + resolve({ success: false, error: tBackend('clipFileTooSmall', { bytes: String(stats.size) }) }); + return; + } + + const integrity = validateDownloadedFileIntegrity(filename, null); + if (!integrity.success) { + try { fs.unlinkSync(filename); } catch { } + appendDebugLog('clip-download-integrity-failed', { clipId, error: integrity.error }); + resolve({ success: false, error: integrity.error || tBackend('integrityFailedGeneric') }); + return; + } + + appendDebugLog('clip-download-success', { clipId, bytes: stats.size, filename }); + resolve({ success: true, filename }); + }); + + proc.on('error', () => { + activeClipProcesses.delete(clipId); + releaseClaimedFilenamesForItem(clipId); + resolve({ success: false, error: tBackend('streamlinkNotFound') }); + }); + }); +}); + +ipcMain.handle('run-preflight', async (_, autoFix: boolean = false) => { + return await runPreflight(autoFix); +}); + +ipcMain.handle('get-debug-log', async (_, lines: number = 200) => { + // Cap so a misbehaving renderer (or future feature) cannot ask the + // main process to slice millions of lines from a multi-MB log. + const safeLines = Number.isFinite(lines) ? Math.max(1, Math.min(5000, Math.floor(lines))) : 200; + return readDebugLog(safeLines); +}); + +ipcMain.handle('open-debug-log-file', (): boolean => { + if (!fs.existsSync(DEBUG_LOG_FILE)) return false; + shell.showItemInFolder(DEBUG_LOG_FILE); + return true; +}); + +ipcMain.handle('get-archive-stats', (): ArchiveStats => { + return computeArchiveStats(); +}); + +ipcMain.handle('get-streamer-profile', async (_, login: string, forceRefresh?: boolean): Promise => { + return await getStreamerProfile(login, forceRefresh === true); +}); + +ipcMain.handle('get-vod-storyboard', async (_, vodId: string): Promise => { + return await getVodStoryboard(vodId); +}); + +ipcMain.handle('get-live-status-snapshot', (): Record => { + const snap: Record = {}; + for (const [k, v] of liveStatusByLogin.entries()) snap[k] = v; + return snap; +}); + +ipcMain.handle('search-archive', (_, filter: Partial): ArchiveSearchResult => { + const normalized: ArchiveSearchFilter = { + query: typeof filter?.query === 'string' ? filter.query.trim() : '', + type: (['all', 'live', 'vod', 'chat', 'events'] as const).includes(filter?.type as 'all' | 'live' | 'vod' | 'chat' | 'events') + ? filter!.type as 'all' | 'live' | 'vod' | 'chat' | 'events' + : 'all', + streamer: typeof filter?.streamer === 'string' ? filter.streamer.trim() : '', + sinceMs: Number.isFinite(filter?.sinceMs as number) ? Number(filter?.sinceMs) : null, + untilMs: Number.isFinite(filter?.untilMs as number) ? Number(filter?.untilMs) : null, + sort: (['date_desc', 'date_asc', 'size_desc', 'size_asc', 'name_asc'] as const).includes(filter?.sort as 'date_desc') + ? filter!.sort as 'date_desc' | 'date_asc' | 'size_desc' | 'size_asc' | 'name_asc' + : 'date_desc', + limit: Number.isFinite(filter?.limit as number) ? Number(filter?.limit) : 200 + }; + return searchArchive(normalized); +}); + +ipcMain.handle('get-storage-stats', (): StorageStatsResult => { + return computeStorageStats(); +}); + +ipcMain.handle('run-storage-cleanup', (_, options?: { dryRun?: boolean }): CleanupReport => { + return runStorageCleanup({ dryRun: options?.dryRun === true }); +}); + +// Read a chat-replay (.chat.json) or live-chat (.chat.jsonl) file and +// return a normalized message list the renderer can display directly. +// Caps at 50k messages to stop a runaway file from killing the renderer. +ipcMain.handle('read-chat-file', (_, filePath: string): { success: boolean; error?: string; format?: 'replay' | 'live'; messages?: Array>; truncated?: boolean; total?: number } => { + if (typeof filePath !== 'string' || !filePath) return { success: false, error: 'No path' }; + if (!fs.existsSync(filePath)) return { success: false, error: 'File not found' }; + + const MAX_MESSAGES = 50000; + try { + const raw = fs.readFileSync(filePath, 'utf-8'); + if (filePath.toLowerCase().endsWith('.jsonl')) { + // JSON Lines (live chat): one object per line, first line may be header + const messages: Array> = []; + let truncated = false; + const lines = raw.split('\n'); + let total = 0; + for (const line of lines) { + const trimmed = line.trim(); + if (!trimmed) continue; + try { + const obj = JSON.parse(trimmed); + if (obj && typeof obj === 'object' && obj.type !== 'header') { + total++; + if (messages.length < MAX_MESSAGES) messages.push(obj); + else truncated = true; + } + } catch { /* skip bad lines */ } + } + return { success: true, format: 'live', messages, truncated, total }; + } + + // .chat.json (VOD replay) — single object with messages array + const parsed = JSON.parse(raw); + if (!parsed || typeof parsed !== 'object' || !Array.isArray(parsed.messages)) { + return { success: false, error: 'Unsupported chat file format' }; + } + const total = parsed.messages.length; + const messages = parsed.messages.length > MAX_MESSAGES + ? parsed.messages.slice(0, MAX_MESSAGES) + : parsed.messages; + return { + success: true, + format: 'replay', + messages, + truncated: total > MAX_MESSAGES, + total + }; + } catch (e) { + return { success: false, error: String(e) }; + } +}); + +ipcMain.handle('check-folder-writable', (_, folderPath: string): boolean => { + if (typeof folderPath !== 'string' || !folderPath) return false; + return isDownloadPathWritable(folderPath); +}); + +ipcMain.handle('is-downloading', () => isDownloading); + +ipcMain.handle('get-runtime-metrics', () => getRuntimeMetricsSnapshot()); + +ipcMain.handle('export-runtime-metrics', async () => { + try { + const timestamp = new Date().toISOString().replace(/[:.]/g, '-'); + const defaultName = `runtime-metrics-${timestamp}.json`; + const preferredDir = fs.existsSync(config.download_path) ? config.download_path : app.getPath('desktop'); + + const dialogResult = await dialog.showSaveDialog(mainWindow!, { + defaultPath: path.join(preferredDir, defaultName), + filters: [{ name: 'JSON', extensions: ['json'] }] + }); + + if (dialogResult.canceled || !dialogResult.filePath) { + return { success: false, cancelled: true }; + } + + const snapshot = getRuntimeMetricsSnapshot(); + // Atomic write: same fsync+rename pattern used for config/queue + // (cycle 1) so a power loss mid-export can't leave a half-written + // metrics file at the user's chosen path. + writeFileAtomicSync(dialogResult.filePath, JSON.stringify(snapshot, null, 2)); + return { success: true, filePath: dialogResult.filePath }; + } catch (e) { + appendDebugLog('runtime-metrics-export-failed', String(e)); + return { success: false, error: String(e) }; + } +}); + +ipcMain.handle('mark-vod-downloaded', (_, vodId: string, mark: boolean): { success: boolean } => { + if (typeof vodId !== 'string' || !vodId) return { success: false }; + if (!Array.isArray(config.downloaded_vod_ids)) config.downloaded_vod_ids = []; + const has = config.downloaded_vod_ids.includes(vodId); + if (mark && !has) { + config.downloaded_vod_ids.push(vodId); + } else if (!mark && has) { + config.downloaded_vod_ids = config.downloaded_vod_ids.filter((id) => id !== vodId); + } else { + return { success: true }; + } + saveConfig(config); + appendDebugLog('mark-vod-downloaded', { vodId, mark }); + return { success: true }; +}); + +ipcMain.handle('reset-downloaded-vod-ids', () => { + const count = Array.isArray(config.downloaded_vod_ids) ? config.downloaded_vod_ids.length : 0; + config.downloaded_vod_ids = []; + saveConfig(config); + appendDebugLog('reset-downloaded-vod-ids', { previousCount: count }); + return { success: true, removedCount: count }; +}); + +ipcMain.handle('export-config', async () => { + try { + const timestamp = new Date().toISOString().replace(/[:.]/g, '-'); + const defaultName = `twitch-vod-manager-config-${timestamp}.json`; + const preferredDir = fs.existsSync(config.download_path) ? config.download_path : app.getPath('desktop'); + + const dialogResult = await dialog.showSaveDialog(mainWindow!, { + defaultPath: path.join(preferredDir, defaultName), + filters: [{ name: 'JSON', extensions: ['json'] }] + }); + + if (dialogResult.canceled || !dialogResult.filePath) { + return { success: false, cancelled: true }; + } + + // Strip the secrets from the export — Client Secret should not + // travel as plain text across machines / cloud sync. The user + // re-enters it on the new machine after import. + const exportable = { + ...config, + client_secret: '', + __exportVersion: 1, + __exportedAt: new Date().toISOString() + }; + writeFileAtomicSync(dialogResult.filePath, JSON.stringify(exportable, null, 2)); + return { success: true, filePath: dialogResult.filePath }; + } catch (e) { + appendDebugLog('config-export-failed', String(e)); + return { success: false, error: String(e) }; + } +}); + +ipcMain.handle('import-config', async () => { + try { + const dialogResult = await dialog.showOpenDialog(mainWindow!, { + properties: ['openFile'], + filters: [{ name: 'JSON', extensions: ['json'] }] + }); + if (dialogResult.canceled || !dialogResult.filePaths[0]) { + return { success: false, cancelled: true }; + } + + const importPath = dialogResult.filePaths[0]; + const raw = fs.readFileSync(importPath, 'utf-8'); + const parsed = JSON.parse(raw); + if (!isPlainObject(parsed)) { + return { success: false, error: 'Imported file is not a JSON object.' }; + } + + // Merge over current config so unknown / missing keys keep their + // existing values. Then run normalizeConfigTemplates so any + // out-of-range field falls back to defaults. + const merged = normalizeConfigTemplates({ ...config, ...parsed } as Config); + + // Preserve the existing client_secret if the import stripped it + // (export does this on purpose) — the user shouldn't lose creds. + if (!merged.client_secret && config.client_secret) { + merged.client_secret = config.client_secret; + } + + config = merged; + saveConfig(config); + appendDebugLog('config-import-applied', { source: importPath }); + return { success: true, filePath: importPath }; + } catch (e) { + appendDebugLog('config-import-failed', String(e)); + return { success: false, error: String(e) }; + } +}); + +// Video Cutter IPC +ipcMain.handle('get-video-info', async (_, filePath: string) => { + return await getVideoInfo(filePath); +}); + +ipcMain.handle('extract-frame', async (_, filePath: string, timeSeconds: number) => { + return await extractFrame(filePath, timeSeconds); +}); + +ipcMain.handle('cut-video', async (_, inputFile: string, startTime: number, endTime: number) => { + const dir = path.dirname(inputFile); + const baseName = path.basename(inputFile, path.extname(inputFile)); + const timestamp = new Date().toISOString().replace(/[:.]/g, '-').substring(11, 19); + const outputFile = path.join(dir, `${baseName}_cut_${timestamp}.mp4`); + + let lastProgress = 0; + const success = await cutVideo(inputFile, outputFile, startTime, endTime, (percent) => { + lastProgress = percent; + mainWindow?.webContents.send('cut-progress', percent); + }); + + return { success, outputFile: success ? outputFile : null }; +}); + +// Merge IPC +ipcMain.handle('merge-videos', async (_, inputFiles: string[], outputFile: string) => { + const success = await mergeVideos(inputFiles, outputFile, (percent) => { + mainWindow?.webContents.send('merge-progress', percent); + }); + + return { success, outputFile: success ? outputFile : null }; +}); + +ipcMain.handle('select-multiple-videos', async () => { + const result = await dialog.showOpenDialog(mainWindow!, { + properties: ['openFile', 'multiSelections'], + filters: [ + { name: 'Video Files', extensions: ['mp4', 'mkv', 'ts', 'mov', 'avi'] } + ] + }); + return result.filePaths; +}); + +ipcMain.handle('save-video-dialog', async (_, defaultName: string) => { + const result = await dialog.showSaveDialog(mainWindow!, { + defaultPath: defaultName, + filters: [ + { name: 'MP4 Video', extensions: ['mp4'] } + ] + }); + return result.filePath || null; +}); + +// ========================================== +// APP LIFECYCLE +// ========================================== +// Long-lived SQLite-Handle (Plan 04b+ Voraussetzung). Wird in app.whenReady +// geoeffnet, in shutdownCleanup geschlossen. getAppDb() returnt null wenn +// Open fehlgeschlagen ist (Native-Build-Probleme) — Caller mussen das pruefen. +let appDb: DbHandle | null = null; +export function getAppDb(): DbHandle | null { return appDb; } + +app.whenReady().then(() => { + app.setAppUserModelId('com.twitch.vodmanager'); + refreshBundledToolPaths(true); + startMetadataCacheCleanup(); + startDebugLogFlushTimer(); + + // SQLite-Open + Shadow-Migration. Long-lived handle in appDb (siehe oben). + // Lazy require, damit Native-Build-Fehler den App-Start nicht verhindern. + try { + const { openDatabase } = require('./main/infra/db'); + const { migrateJsonToSqlite } = require('./main/domain/migrator'); + const dbPath = path.join(APPDATA_DIR, 'app.db'); + appDb = openDatabase(dbPath); + const result = migrateJsonToSqlite({ db: appDb, appDataDir: APPDATA_DIR }); + appendDebugLog('sqlite-migrator', result); + } catch (e) { + appendDebugLog('sqlite-open-failed', { + error: e instanceof Error ? e.message : String(e), + }); + appDb = null; + } + + restartAutoRecordPoller(); + restartAutoVodPoller(); + restartLiveStatusPoller(); + restartAutoCleanupTimer(); + createWindow(); + appendDebugLog('startup-tools-check-skipped', 'Deferred to first use'); + + app.on('activate', () => { + if (BrowserWindow.getAllWindows().length === 0) { + createWindow(); + } + }); +}); + +// Both window-all-closed and before-quit ran nearly identical cleanup blocks +// before, with slight drift (only window-all-closed killed children, only +// window-all-closed did anything platform-specific). Consolidating them into +// a single idempotent helper means any future tweak (e.g. flushing a new +// debug stream) lands once and applies on every quit path. +let shutdownCleanupDone = false; + +function shutdownCleanup(reason: 'window-all-closed' | 'before-quit'): void { + if (shutdownCleanupDone) return; + shutdownCleanupDone = true; + + appendDebugLog('shutdown-cleanup', { reason }); + + stopMetadataCacheCleanup(); + cleanupMetadataCaches('shutdown'); + stopAutoUpdatePolling(); + stopAutoRecordPoller(); + stopAutoVodPoller(); + stopLiveStatusPoller(); + stopAutoCleanupTimer(); + + // Kill all active children: queue downloads, standalone clip downloads, + // and any in-flight cutter/merger/splitter ffmpeg. before-quit used to + // skip this entirely; window-all-closed did it but only via direct + // kill() (no try/catch around the queue process kill). + for (const [, tracking] of activeDownloads) { + if (tracking.process) { + try { tracking.process.kill(); } catch { /* already exited */ } + } + } + activeDownloads.clear(); + + for (const [, proc] of activeClipProcesses) { + try { proc.kill(); } catch { /* already exited */ } + } + activeClipProcesses.clear(); + + if (currentEditorProcess) { + try { currentEditorProcess.kill(); } catch { /* already exited */ } + currentEditorProcess = null; + } + + saveConfig(config); + flushQueueSave(); + + // SQLite-Handle schliessen, falls geoeffnet — WAL-Checkpoint passiert beim + // close, sodass beim naechsten Start keine .wal/.shm orphans bleiben. + if (appDb) { + try { appDb.close(); } catch { /* already closed */ } + appDb = null; + } + + // Flush debug log AFTER persisting state so any errors saving config / + // queue land in the log before the timer is gone. + stopDebugLogFlushTimer(true); +} + +app.on('window-all-closed', () => { + shutdownCleanup('window-all-closed'); + if (process.platform !== 'darwin') { + app.quit(); + } +}); + +app.on('before-quit', () => { + shutdownCleanup('before-quit'); +}); diff --git a/src/main/domain/.gitkeep b/src/main/domain/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/src/main/domain/archive-files-store.test.ts b/src/main/domain/archive-files-store.test.ts new file mode 100644 index 0000000..eb91a42 --- /dev/null +++ b/src/main/domain/archive-files-store.test.ts @@ -0,0 +1,106 @@ +import { test, expect, describe, beforeEach, afterEach } from 'vitest'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { openDatabase, type DbHandle } from '../infra/db'; +import { createArchiveFilesStore, type ArchiveFilesStore } from './archive-files-store'; + +let tmpDir: string; +let db: DbHandle; +let store: ArchiveFilesStore; +beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'archive-')); + db = openDatabase(path.join(tmpDir, 'app.db')); + store = createArchiveFilesStore(db); +}); +afterEach(() => { + db.close(); + try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch { /* ignore */ } +}); + +describe('createArchiveFilesStore', () => { + test('upsert + get roundtrip', () => { + const rec = store.upsert({ + path: 'C:/vods/foo/2026-05-11.mp4', + streamerLogin: 'Foo', + sizeBytes: 1024 * 1024 * 100, + durationSeconds: 3600, + createdAt: 1700000000, + verified: true, + }); + expect(rec.path).toBe('C:/vods/foo/2026-05-11.mp4'); + expect(rec.streamerLogin).toBe('foo'); + expect(rec.sizeBytes).toBe(1024 * 1024 * 100); + expect(rec.verified).toBe(true); + + const fetched = store.get('C:/vods/foo/2026-05-11.mp4'); + expect(fetched?.streamerLogin).toBe('foo'); + }); + + test('upsert same path updates instead of duplicating', () => { + store.upsert({ path: '/x', streamerLogin: 'a', sizeBytes: 100 }); + store.upsert({ path: '/x', streamerLogin: 'a', sizeBytes: 200 }); + const list = store.list(); + expect(list).toHaveLength(1); + expect(list[0].sizeBytes).toBe(200); + }); + + test('list returns all, ordered by created_at DESC NULLS LAST', () => { + store.upsert({ path: '/older', streamerLogin: 'a', createdAt: 1000 }); + store.upsert({ path: '/newer', streamerLogin: 'a', createdAt: 2000 }); + store.upsert({ path: '/no-date', streamerLogin: 'a' }); + const list = store.list(); + expect(list.map(r => r.path)).toEqual(['/newer', '/older', '/no-date']); + }); + + test('list(streamerLogin) filters and normalizes', () => { + store.upsert({ path: '/a1', streamerLogin: 'alice' }); + store.upsert({ path: '/a2', streamerLogin: 'Alice' }); // normalized to alice + store.upsert({ path: '/b1', streamerLogin: 'bob' }); + const aliceFiles = store.list('@Alice'); + expect(aliceFiles).toHaveLength(2); + }); + + test('setVerified toggles the flag', () => { + store.upsert({ path: '/v', verified: false }); + store.setVerified('/v', true); + expect(store.get('/v')?.verified).toBe(true); + store.setVerified('/v', false); + expect(store.get('/v')?.verified).toBe(false); + }); + + test('delete removes the record', () => { + store.upsert({ path: '/d', streamerLogin: 'x' }); + store.delete('/d'); + expect(store.get('/d')).toBeNull(); + }); + + test('summaryByStreamer aggregates counts and total bytes', () => { + store.upsert({ path: '/a1', streamerLogin: 'alice', sizeBytes: 100 }); + store.upsert({ path: '/a2', streamerLogin: 'alice', sizeBytes: 200 }); + store.upsert({ path: '/b1', streamerLogin: 'bob', sizeBytes: 50 }); + store.upsert({ path: '/orphan', sizeBytes: 999 }); // no streamer — excluded + + const summary = store.summaryByStreamer(); + // Sorted by total DESC: alice (300), bob (50) + expect(summary).toHaveLength(2); + expect(summary[0]).toEqual({ streamerLogin: 'alice', fileCount: 2, totalBytes: 300 }); + expect(summary[1]).toEqual({ streamerLogin: 'bob', fileCount: 1, totalBytes: 50 }); + }); + + test('totalBytes sums across everything', () => { + store.upsert({ path: '/1', sizeBytes: 100 }); + store.upsert({ path: '/2', sizeBytes: 200 }); + store.upsert({ path: '/3', sizeBytes: 300, streamerLogin: 'a' }); + store.upsert({ path: '/4' }); // null bytes — coalesced to 0 + expect(store.totalBytes()).toBe(600); + }); + + test('get returns null for missing path', () => { + expect(store.get('/nope')).toBeNull(); + }); + + test('totalBytes on empty table = 0', () => { + expect(store.totalBytes()).toBe(0); + }); +}); diff --git a/src/main/domain/archive-files-store.ts b/src/main/domain/archive-files-store.ts new file mode 100644 index 0000000..e9b4fdc --- /dev/null +++ b/src/main/domain/archive-files-store.ts @@ -0,0 +1,138 @@ +import type { DbHandle } from '../infra/db'; +import { normalizeLogin } from './config-normalize'; + +export interface ArchiveFileRecord { + path: string; + streamerLogin: string | null; + sizeBytes: number | null; + durationSeconds: number | null; + createdAt: number | null; + verified: boolean; +} + +export interface ArchiveFileWriteInput { + path: string; + streamerLogin?: string; + sizeBytes?: number; + durationSeconds?: number; + createdAt?: number; + verified?: boolean; +} + +export interface ArchiveStreamerSummary { + streamerLogin: string; + fileCount: number; + totalBytes: number; +} + +export interface ArchiveFilesStore { + upsert(input: ArchiveFileWriteInput): ArchiveFileRecord; + get(path: string): ArchiveFileRecord | null; + list(streamerLogin?: string): ArchiveFileRecord[]; + setVerified(path: string, verified: boolean): void; + delete(path: string): void; + summaryByStreamer(): ArchiveStreamerSummary[]; + totalBytes(): number; +} + +interface ArchiveRow { + path: string; + streamer_login: string | null; + size_bytes: number | null; + duration_seconds: number | null; + created_at: number | null; + verified: number; +} + +function rowToRecord(row: ArchiveRow): ArchiveFileRecord { + return { + path: row.path, + streamerLogin: row.streamer_login, + sizeBytes: row.size_bytes, + durationSeconds: row.duration_seconds, + createdAt: row.created_at, + verified: row.verified === 1, + }; +} + +export function createArchiveFilesStore(db: DbHandle): ArchiveFilesStore { + return { + upsert(input) { + const streamerLogin = input.streamerLogin + ? normalizeLogin(input.streamerLogin) + : null; + const verified = input.verified ? 1 : 0; + db.run( + `INSERT INTO archive_files(path, streamer_login, size_bytes, duration_seconds, created_at, verified) + VALUES (?, ?, ?, ?, ?, ?) + ON CONFLICT(path) DO UPDATE SET + streamer_login = excluded.streamer_login, + size_bytes = excluded.size_bytes, + duration_seconds = excluded.duration_seconds, + created_at = excluded.created_at, + verified = excluded.verified`, + [ + input.path, + streamerLogin, + input.sizeBytes ?? null, + input.durationSeconds ?? null, + input.createdAt ?? null, + verified, + ] + ); + const row = db.get('SELECT * FROM archive_files WHERE path = ?', [input.path]); + if (!row) throw new Error(`archive-files-store: upsert lookup failed for ${input.path}`); + return rowToRecord(row); + }, + + get(p) { + const row = db.get('SELECT * FROM archive_files WHERE path = ?', [p]); + return row ? rowToRecord(row) : null; + }, + + list(streamerLogin) { + const rows = streamerLogin + ? db.all( + 'SELECT * FROM archive_files WHERE streamer_login = ? ORDER BY created_at DESC NULLS LAST, path', + [normalizeLogin(streamerLogin)] + ) + : db.all('SELECT * FROM archive_files ORDER BY created_at DESC NULLS LAST, path'); + return rows.map(rowToRecord); + }, + + setVerified(p, verified) { + db.run( + 'UPDATE archive_files SET verified = ? WHERE path = ?', + [verified ? 1 : 0, p] + ); + }, + + delete(p) { + db.run('DELETE FROM archive_files WHERE path = ?', [p]); + }, + + summaryByStreamer() { + const rows = db.all<{ streamer_login: string | null; cnt: number; total: number | null }>( + `SELECT streamer_login, COUNT(*) AS cnt, COALESCE(SUM(size_bytes), 0) AS total + FROM archive_files + WHERE streamer_login IS NOT NULL + GROUP BY streamer_login + ORDER BY total DESC` + ); + return rows + .filter((r): r is { streamer_login: string; cnt: number; total: number | null } => r.streamer_login !== null) + .map(r => ({ + streamerLogin: r.streamer_login, + fileCount: r.cnt, + totalBytes: r.total ?? 0, + })); + }, + + totalBytes() { + const row = db.get<{ total: number | null }>( + 'SELECT COALESCE(SUM(size_bytes), 0) AS total FROM archive_files' + ); + return row?.total ?? 0; + }, + }; +} diff --git a/src/main/domain/chunk-index-store.test.ts b/src/main/domain/chunk-index-store.test.ts new file mode 100644 index 0000000..28c2063 --- /dev/null +++ b/src/main/domain/chunk-index-store.test.ts @@ -0,0 +1,88 @@ +import { test, expect, describe, beforeEach, afterEach } from 'vitest'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { openDatabase, type DbHandle } from '../infra/db'; +import { createChunkIndexStore, type ChunkIndexStore } from './chunk-index-store'; + +let tmpDir: string; +let db: DbHandle; +let store: ChunkIndexStore; +beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'chunkstore-')); + db = openDatabase(path.join(tmpDir, 'app.db')); + store = createChunkIndexStore(db); +}); +afterEach(() => { + db.close(); + try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch { /* ignore */ } +}); + +describe('createChunkIndexStore', () => { + test('record returns ChunkRecord with id > 0', () => { + const rec = store.record('item-1', 0, 'sha1-abc', 1024); + expect(rec.id).toBeGreaterThan(0); + expect(rec.itemId).toBe('item-1'); + expect(rec.chunkSeq).toBe(0); + expect(rec.sha1Hex).toBe('sha1-abc'); + expect(rec.bytes).toBe(1024); + }); + + test('listForItem returns chunks ordered by chunk_seq', () => { + store.record('it', 2, 's2', 200); + store.record('it', 0, 's0', 100); + store.record('it', 1, 's1', 150); + const all = store.listForItem('it'); + expect(all.map(r => r.chunkSeq)).toEqual([0, 1, 2]); + expect(all.map(r => r.sha1Hex)).toEqual(['s0', 's1', 's2']); + }); + + test('UNIQUE(item_id, chunk_seq): same key updates, no duplicate', () => { + store.record('it', 0, 'first', 100); + store.record('it', 0, 'second', 200); + const list = store.listForItem('it'); + expect(list).toHaveLength(1); + expect(list[0].sha1Hex).toBe('second'); + expect(list[0].bytes).toBe(200); + }); + + test('countForItem', () => { + expect(store.countForItem('it')).toBe(0); + store.record('it', 0, 'a', 1); + store.record('it', 1, 'b', 1); + expect(store.countForItem('it')).toBe(2); + expect(store.countForItem('other')).toBe(0); + }); + + test('lookupBySha1 finds dedupe candidates', () => { + store.record('item-A', 0, 'same-sha', 100); + store.record('item-B', 5, 'same-sha', 100); + store.record('item-C', 0, 'other-sha', 100); + + const hits = store.lookupBySha1('same-sha'); + expect(hits).toHaveLength(2); + expect(hits.map(r => r.itemId).sort()).toEqual(['item-A', 'item-B']); + }); + + test('deleteForItem removes all chunks for that item and returns count', () => { + store.record('it', 0, 'a', 1); + store.record('it', 1, 'b', 1); + store.record('keep', 0, 'c', 1); + + const removed = store.deleteForItem('it'); + expect(removed).toBe(2); + expect(store.countForItem('it')).toBe(0); + expect(store.countForItem('keep')).toBe(1); + }); + + test('deleteForItem on missing returns 0, doesnt throw', () => { + expect(store.deleteForItem('does-not-exist')).toBe(0); + }); + + test('bytes roundtrip', () => { + const rec = store.record('it', 0, 'sha', 1234567); + expect(rec.bytes).toBe(1234567); + const list = store.listForItem('it'); + expect(list[0].bytes).toBe(1234567); + }); +}); diff --git a/src/main/domain/chunk-index-store.ts b/src/main/domain/chunk-index-store.ts new file mode 100644 index 0000000..915cf8e --- /dev/null +++ b/src/main/domain/chunk-index-store.ts @@ -0,0 +1,93 @@ +import type { DbHandle } from '../infra/db'; + +export interface ChunkRecord { + id: number; + itemId: string; + chunkSeq: number; + sha1Hex: string; + bytes: number; + createdAt: number; +} + +export interface ChunkIndexStore { + /** + * Persistiert einen Chunk-Hash. Bei (itemId, chunkSeq)-Konflikt wird das + * bestehende Tupel ersetzt — die zuletzt geschriebene sha1 gewinnt + * (sinnvoll, falls dasselbe Segment neu geladen wurde). + */ + record(itemId: string, chunkSeq: number, sha1Hex: string, bytes: number): ChunkRecord; + listForItem(itemId: string): ChunkRecord[]; + countForItem(itemId: string): number; + lookupBySha1(sha1Hex: string): ChunkRecord[]; + deleteForItem(itemId: string): number; +} + +interface ChunkRow { + id: number; + item_id: string; + chunk_seq: number; + sha1_hex: string; + bytes: number; + created_at: number; +} + +function rowToRecord(row: ChunkRow): ChunkRecord { + return { + id: row.id, + itemId: row.item_id, + chunkSeq: row.chunk_seq, + sha1Hex: row.sha1_hex, + bytes: row.bytes, + createdAt: row.created_at, + }; +} + +export function createChunkIndexStore(db: DbHandle): ChunkIndexStore { + return { + record(itemId, chunkSeq, sha1Hex, bytes) { + const now = Math.floor(Date.now() / 1000); + db.run( + `INSERT INTO chunk_index(item_id, chunk_seq, sha1_hex, bytes, created_at) + VALUES (?, ?, ?, ?, ?) + ON CONFLICT(item_id, chunk_seq) DO UPDATE SET + sha1_hex = excluded.sha1_hex, + bytes = excluded.bytes, + created_at = excluded.created_at`, + [itemId, chunkSeq, sha1Hex, bytes, now] + ); + const row = db.get( + 'SELECT * FROM chunk_index WHERE item_id = ? AND chunk_seq = ?', + [itemId, chunkSeq] + ); + if (!row) throw new Error(`chunk-index-store: record lookup failed for ${itemId}/${chunkSeq}`); + return rowToRecord(row); + }, + + listForItem(itemId) { + const rows = db.all( + 'SELECT * FROM chunk_index WHERE item_id = ? ORDER BY chunk_seq ASC', + [itemId] + ); + return rows.map(rowToRecord); + }, + + countForItem(itemId) { + const row = db.get<{ c: number }>('SELECT COUNT(*) AS c FROM chunk_index WHERE item_id = ?', [itemId]); + return row?.c ?? 0; + }, + + lookupBySha1(sha1Hex) { + const rows = db.all( + 'SELECT * FROM chunk_index WHERE sha1_hex = ? ORDER BY item_id, chunk_seq', + [sha1Hex] + ); + return rows.map(rowToRecord); + }, + + deleteForItem(itemId) { + const before = db.get<{ c: number }>('SELECT COUNT(*) AS c FROM chunk_index WHERE item_id = ?', [itemId])?.c ?? 0; + db.run('DELETE FROM chunk_index WHERE item_id = ?', [itemId]); + return before; + }, + }; +} diff --git a/src/main/domain/config-normalize.test.ts b/src/main/domain/config-normalize.test.ts new file mode 100644 index 0000000..7cbc4e1 --- /dev/null +++ b/src/main/domain/config-normalize.test.ts @@ -0,0 +1,183 @@ +import { test, expect, describe } from 'vitest'; +import { + normalizeLogin, + normalizeAutoRecordPollSeconds, + normalizeAutoRecordList, + normalizeStreamlinkQuality, + normalizeFilenameTemplate, + normalizeMetadataCacheMinutes, + normalizePerformanceMode, + isPlainObject, + VALID_STREAMLINK_QUALITIES, +} from './config-normalize'; + +describe('normalizeLogin', () => { + test('trim + lowercase', () => { + expect(normalizeLogin(' Foo ')).toBe('foo'); + }); + test('strips single leading @', () => { + expect(normalizeLogin('@foo')).toBe('foo'); + }); + test('strips multiple leading @', () => { + expect(normalizeLogin('@@@foo')).toBe('foo'); + }); + test('preserves @ in middle of string', () => { + expect(normalizeLogin('foo@bar')).toBe('foo@bar'); + }); + test('empty stays empty', () => { + expect(normalizeLogin('')).toBe(''); + }); +}); + +describe('normalizeAutoRecordPollSeconds', () => { + test('default 90 for non-numeric (NaN producer)', () => { + // Number('x') === NaN, Number(undefined) === NaN → default 90. + // Number(null) === 0 (finite) → clamp to 30, see boundary test below. + expect(normalizeAutoRecordPollSeconds('x')).toBe(90); + expect(normalizeAutoRecordPollSeconds(undefined)).toBe(90); + expect(normalizeAutoRecordPollSeconds({})).toBe(90); + }); + test('null becomes 0 then clamps to 30', () => { + expect(normalizeAutoRecordPollSeconds(null)).toBe(30); + }); + test('clamps low to 30', () => { + expect(normalizeAutoRecordPollSeconds(5)).toBe(30); + }); + test('clamps high to 1800', () => { + expect(normalizeAutoRecordPollSeconds(99999)).toBe(1800); + }); + test('passes valid mid-range', () => { + expect(normalizeAutoRecordPollSeconds(120)).toBe(120); + }); + test('floors fractional', () => { + expect(normalizeAutoRecordPollSeconds(120.9)).toBe(120); + }); + test('boundary 30 stays', () => { + expect(normalizeAutoRecordPollSeconds(30)).toBe(30); + }); + test('boundary 1800 stays', () => { + expect(normalizeAutoRecordPollSeconds(1800)).toBe(1800); + }); +}); + +describe('normalizeAutoRecordList', () => { + test('empty for non-array', () => { + expect(normalizeAutoRecordList(null)).toEqual([]); + expect(normalizeAutoRecordList('x')).toEqual([]); + expect(normalizeAutoRecordList(undefined)).toEqual([]); + }); + test('empty array stays empty', () => { + expect(normalizeAutoRecordList([])).toEqual([]); + }); + test('lowercases + trims + dedupes', () => { + expect(normalizeAutoRecordList(['Foo', 'foo', ' BAR '])).toEqual(['foo', 'bar']); + }); + test('strips leading @ (twitch username paste-form)', () => { + expect(normalizeAutoRecordList(['@foo', 'foo', '@@bar'])).toEqual(['foo', 'bar']); + }); + test('drops non-string entries', () => { + expect(normalizeAutoRecordList(['foo', 123, null, 'bar'])).toEqual(['foo', 'bar']); + }); + test('drops empty strings after normalize', () => { + expect(normalizeAutoRecordList(['', '@', ' ', 'foo'])).toEqual(['foo']); + }); +}); + +describe('normalizeStreamlinkQuality', () => { + test('all valid values pass through', () => { + for (const q of VALID_STREAMLINK_QUALITIES) { + expect(normalizeStreamlinkQuality(q)).toBe(q); + } + }); + test('invalid string falls back to best', () => { + expect(normalizeStreamlinkQuality('foo')).toBe('best'); + }); + test('null/undefined/number fall back to best', () => { + expect(normalizeStreamlinkQuality(null)).toBe('best'); + expect(normalizeStreamlinkQuality(undefined)).toBe('best'); + expect(normalizeStreamlinkQuality(42)).toBe('best'); + }); +}); + +describe('normalizeFilenameTemplate', () => { + test('valid string used as-is', () => { + expect(normalizeFilenameTemplate('{title}.mp4', 'FB')).toBe('{title}.mp4'); + }); + test('trims whitespace', () => { + expect(normalizeFilenameTemplate(' hi ', 'FB')).toBe('hi'); + }); + test('empty string falls back', () => { + expect(normalizeFilenameTemplate('', 'FB')).toBe('FB'); + }); + test('whitespace-only falls back', () => { + expect(normalizeFilenameTemplate(' ', 'FB')).toBe('FB'); + }); + test('undefined falls back', () => { + expect(normalizeFilenameTemplate(undefined, 'FB')).toBe('FB'); + }); +}); + +describe('normalizeMetadataCacheMinutes', () => { + test('default 10 for NaN-producer', () => { + expect(normalizeMetadataCacheMinutes('x')).toBe(10); + expect(normalizeMetadataCacheMinutes(undefined)).toBe(10); + expect(normalizeMetadataCacheMinutes({})).toBe(10); + }); + test('null becomes 0 then clamps to 1', () => { + expect(normalizeMetadataCacheMinutes(null)).toBe(1); + }); + test('clamps low to 1', () => { + expect(normalizeMetadataCacheMinutes(0)).toBe(1); + expect(normalizeMetadataCacheMinutes(-5)).toBe(1); + }); + test('clamps high to 120', () => { + expect(normalizeMetadataCacheMinutes(999)).toBe(120); + }); + test('passes valid mid-range', () => { + expect(normalizeMetadataCacheMinutes(15)).toBe(15); + }); + test('floors fractional', () => { + expect(normalizeMetadataCacheMinutes(15.9)).toBe(15); + }); +}); + +describe('normalizePerformanceMode', () => { + test('stability passes', () => { + expect(normalizePerformanceMode('stability')).toBe('stability'); + }); + test('balanced passes', () => { + expect(normalizePerformanceMode('balanced')).toBe('balanced'); + }); + test('speed passes', () => { + expect(normalizePerformanceMode('speed')).toBe('speed'); + }); + test('invalid string falls back to balanced', () => { + expect(normalizePerformanceMode('foo')).toBe('balanced'); + }); + test('null/undefined fall back to balanced', () => { + expect(normalizePerformanceMode(null)).toBe('balanced'); + expect(normalizePerformanceMode(undefined)).toBe('balanced'); + }); +}); + +describe('isPlainObject', () => { + test('true for object literal', () => { + expect(isPlainObject({})).toBe(true); + expect(isPlainObject({ a: 1 })).toBe(true); + }); + test('false for array', () => { + expect(isPlainObject([])).toBe(false); + expect(isPlainObject([1, 2, 3])).toBe(false); + }); + test('false for null', () => { + expect(isPlainObject(null)).toBe(false); + }); + test('false for undefined', () => { + expect(isPlainObject(undefined)).toBe(false); + }); + test('false for primitives', () => { + expect(isPlainObject('x')).toBe(false); + expect(isPlainObject(42)).toBe(false); + expect(isPlainObject(true)).toBe(false); + }); +}); diff --git a/src/main/domain/config-normalize.ts b/src/main/domain/config-normalize.ts new file mode 100644 index 0000000..e660759 --- /dev/null +++ b/src/main/domain/config-normalize.ts @@ -0,0 +1,67 @@ +// Pure normalizer-Helpers fuer Config-Felder. Keine Side-Effects, keine Globals. + +export type PerformanceMode = 'stability' | 'balanced' | 'speed'; + +export const VALID_STREAMLINK_QUALITIES = ['best', 'source', '1080p60', '720p60', '720p', '480p', 'audio_only'] as const; + +const AUTO_RECORD_POLL_MIN_SECONDS = 30; +const AUTO_RECORD_POLL_MAX_SECONDS = 1800; +export const DEFAULT_METADATA_CACHE_MINUTES = 10; +export const DEFAULT_PERFORMANCE_MODE: PerformanceMode = 'balanced'; + +/** trim + strip leading @ + lowercase. Verbatim aus altem main.ts. */ +export function normalizeLogin(input: string): string { + return input.trim().replace(/^@+/, '').toLowerCase(); +} + +export function normalizeAutoRecordPollSeconds(value: unknown): number { + const parsed = Number(value); + if (!Number.isFinite(parsed)) return 90; + return Math.max(AUTO_RECORD_POLL_MIN_SECONDS, Math.min(AUTO_RECORD_POLL_MAX_SECONDS, Math.floor(parsed))); +} + +export function normalizeAutoRecordList(value: unknown): string[] { + if (!Array.isArray(value)) return []; + const seen = new Set(); + const out: string[] = []; + for (const v of value) { + if (typeof v !== 'string') continue; + const cleaned = normalizeLogin(v); + if (cleaned && !seen.has(cleaned)) { + seen.add(cleaned); + out.push(cleaned); + } + } + return out; +} + +export function normalizeStreamlinkQuality(value: unknown): string { + if (typeof value === 'string' && (VALID_STREAMLINK_QUALITIES as readonly string[]).includes(value)) { + return value; + } + return 'best'; +} + +export function normalizeFilenameTemplate(template: string | undefined, fallback: string): string { + const value = (template || '').trim(); + return value || fallback; +} + +export function normalizeMetadataCacheMinutes(value: unknown): number { + const parsed = Number(value); + if (!Number.isFinite(parsed)) { + return DEFAULT_METADATA_CACHE_MINUTES; + } + return Math.max(1, Math.min(120, Math.floor(parsed))); +} + +export function normalizePerformanceMode(mode: unknown): PerformanceMode { + if (mode === 'stability' || mode === 'balanced' || mode === 'speed') { + return mode; + } + return DEFAULT_PERFORMANCE_MODE; +} + +export function isPlainObject(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} diff --git a/src/main/domain/i18n-backend.test.ts b/src/main/domain/i18n-backend.test.ts new file mode 100644 index 0000000..8a2717a --- /dev/null +++ b/src/main/domain/i18n-backend.test.ts @@ -0,0 +1,49 @@ +import { test, expect, describe } from 'vitest'; +import { tBackend, BACKEND_MESSAGES, type BackendMessageKey } from './i18n-backend'; + +describe('tBackend', () => { + test('returns DE message for known key (default language)', () => { + expect(tBackend('invalidVodUrl', undefined, 'de')).toBe(BACKEND_MESSAGES.de.invalidVodUrl); + }); + + test('returns EN message when language=en', () => { + expect(tBackend('invalidVodUrl', undefined, 'en')).toBe(BACKEND_MESSAGES.en.invalidVodUrl); + }); + + test('unknown language falls back to de', () => { + expect(tBackend('invalidVodUrl', undefined, 'fr')).toBe(BACKEND_MESSAGES.de.invalidVodUrl); + expect(tBackend('invalidVodUrl', undefined, '')).toBe(BACKEND_MESSAGES.de.invalidVodUrl); + }); + + test('substitutes single {param}', () => { + const result = tBackend('streamlinkExitCode', { code: 42 }, 'en'); + expect(result).toBe('Streamlink exit code 42'); + }); + + test('substitutes multiple {params}', () => { + const result = tBackend('integrityDurationMismatch', { actual: 100, expected: 120 }, 'de'); + expect(result).toContain('100'); + expect(result).toContain('120'); + expect(result).not.toContain('{actual}'); + expect(result).not.toContain('{expected}'); + }); + + test('numeric params stringify', () => { + const result = tBackend('fileTooSmall', { bytes: 256 }, 'en'); + expect(result).toBe('File too small (256 bytes)'); + }); + + test('every DE key has an EN counterpart', () => { + const deKeys = Object.keys(BACKEND_MESSAGES.de) as BackendMessageKey[]; + const enKeys = Object.keys(BACKEND_MESSAGES.en); + for (const k of deKeys) { + expect(enKeys).toContain(k); + } + }); + + test('no template literal left after substitution for typical params', () => { + // attemptFailed has {attempt}, {max}, {errorClass}, {error} + const result = tBackend('attemptFailed', { attempt: 1, max: 3, errorClass: 'network', error: 'ETIMEDOUT' }, 'en'); + expect(result).toBe('Attempt 1/3 failed (network): ETIMEDOUT'); + }); +}); diff --git a/src/main/domain/i18n-backend.ts b/src/main/domain/i18n-backend.ts new file mode 100644 index 0000000..146e8d2 --- /dev/null +++ b/src/main/domain/i18n-backend.ts @@ -0,0 +1,101 @@ +// Backend-Messages (User-visible aus main.ts produziert). Pure: Sprache wird +// als Parameter uebergeben statt aus globalem config geholt. + +export const BACKEND_MESSAGES = { + de: { + invalidVodUrl: 'Ungueltige VOD-URL', + invalidClipUrl: 'Ungueltige Clip-URL', + clipNotFound: 'Clip nicht gefunden', + streamlinkAutoInstallFailed: 'Streamlink fehlt und konnte nicht automatisch installiert werden. Siehe debug.log.', + streamlinkMissing: 'Streamlink fehlt.', + streamlinkNotFound: 'Streamlink nicht gefunden. Installiere Streamlink oder Python+streamlink (py -3 -m pip install streamlink).', + streamlinkExitCode: 'Streamlink Fehlercode {code}', + ffmpegMissing: 'FFmpeg fehlt.', + ffmpegMergeFailed: 'FFmpeg Merge fehlgeschlagen.', + ffmpegSplitFailed: 'FFmpeg Split fehlgeschlagen.', + fileTooSmall: 'Datei zu klein ({bytes} Bytes)', + clipFileTooSmall: 'Clip-Datei zu klein ({bytes} Bytes) - Twitch hat den Stream evtl. nicht ausgeliefert.', + integrityNoVideo: 'Integritaetspruefung fehlgeschlagen: Kein Videostream gefunden.', + integrityTooShort: 'Integritaetspruefung fehlgeschlagen: Dauer zu kurz ({duration}s).', + integrityDurationMismatch: 'Integritaetspruefung fehlgeschlagen: {actual}s statt erwarteter ~{expected}s.', + integrityFailedGeneric: 'Integritaetspruefung fehlgeschlagen.', + downloadCancelled: 'Download wurde abgebrochen.', + downloadPaused: 'Download wurde pausiert.', + downloadFailedExitCode: 'Download fehlgeschlagen (Exit-Code {code})', + unknownDownloadError: 'Unbekannter Fehler beim Download', + notAllClipPartsDownloaded: 'Nicht alle Clip-Teile konnten heruntergeladen werden.', + notAllPartsDownloaded: 'Nicht alle Teile konnten heruntergeladen werden.', + mergeGroupFileMissing: 'Heruntergeladene Datei {index} fehlt.', + diskSpaceShortFor: 'Zu wenig Speicherplatz fur {context}: frei {free}, benoetigt ~{required}.', + diskSpaceShortGeneric: 'Zu wenig Speicherplatz.', + attemptFailed: 'Versuch {attempt}/{max} fehlgeschlagen ({errorClass}): {error}', + retryingIn: 'Neuer Versuch in {seconds}s ({errorClass})...', + statusCheckingTools: 'Prufe Download-Tools...', + statusDownloadStarted: 'Download gestartet', + statusBytesDownloaded: '{bytes} heruntergeladen', + statusFetchingChatReplay: 'Chat-Replay wird heruntergeladen...', + statusChatMessagesFetched: 'Chat-Nachrichten geladen: {count}', + preflightNoInternet: 'Keine Internetverbindung erkannt.', + preflightStreamlinkMissing: 'Streamlink fehlt oder ist nicht startbar.', + preflightFfmpegMissing: 'FFmpeg fehlt oder ist nicht startbar.', + preflightFfprobeMissing: 'FFprobe fehlt oder ist nicht startbar.', + preflightDownloadPathNotWritable: 'Download-Ordner ist nicht beschreibbar.' + }, + en: { + invalidVodUrl: 'Invalid VOD URL', + invalidClipUrl: 'Invalid clip URL', + clipNotFound: 'Clip not found', + streamlinkAutoInstallFailed: 'Streamlink is missing and could not be auto-installed. See debug.log.', + streamlinkMissing: 'Streamlink is missing.', + streamlinkNotFound: 'Streamlink not found. Install streamlink or Python+streamlink (py -3 -m pip install streamlink).', + streamlinkExitCode: 'Streamlink exit code {code}', + ffmpegMissing: 'FFmpeg is missing.', + ffmpegMergeFailed: 'FFmpeg merge failed.', + ffmpegSplitFailed: 'FFmpeg split failed.', + fileTooSmall: 'File too small ({bytes} bytes)', + clipFileTooSmall: 'Clip file too small ({bytes} bytes) - Twitch may not have served the stream.', + integrityNoVideo: 'Integrity check failed: no video stream found.', + integrityTooShort: 'Integrity check failed: duration too short ({duration}s).', + integrityDurationMismatch: 'Integrity check failed: {actual}s instead of expected ~{expected}s.', + integrityFailedGeneric: 'Integrity check failed.', + downloadCancelled: 'Download was cancelled.', + downloadPaused: 'Download was paused.', + downloadFailedExitCode: 'Download failed (exit code {code})', + unknownDownloadError: 'Unknown download error', + notAllClipPartsDownloaded: 'Not all clip parts could be downloaded.', + notAllPartsDownloaded: 'Not all parts could be downloaded.', + mergeGroupFileMissing: 'Downloaded file {index} is missing.', + diskSpaceShortFor: 'Not enough disk space for {context}: free {free}, need ~{required}.', + diskSpaceShortGeneric: 'Not enough disk space.', + attemptFailed: 'Attempt {attempt}/{max} failed ({errorClass}): {error}', + retryingIn: 'Retrying in {seconds}s ({errorClass})...', + statusCheckingTools: 'Checking download tools...', + statusDownloadStarted: 'Download started', + statusBytesDownloaded: '{bytes} downloaded', + statusFetchingChatReplay: 'Fetching chat replay...', + statusChatMessagesFetched: 'Chat messages fetched: {count}', + preflightNoInternet: 'No internet connection detected.', + preflightStreamlinkMissing: 'Streamlink is missing or not runnable.', + preflightFfmpegMissing: 'FFmpeg is missing or not runnable.', + preflightFfprobeMissing: 'FFprobe is missing or not runnable.', + preflightDownloadPathNotWritable: 'Download folder is not writable.' + } +} as const; + +export type BackendMessageKey = keyof typeof BACKEND_MESSAGES.de; +export type BackendLanguage = 'de' | 'en'; + +export function tBackend( + key: BackendMessageKey, + params: Record | undefined, + language: BackendLanguage | string +): string { + const lang: BackendLanguage = (language === 'en') ? 'en' : 'de'; + let template: string = BACKEND_MESSAGES[lang][key]; + if (params) { + for (const [k, v] of Object.entries(params)) { + template = template.replace(`{${k}}`, String(v)); + } + } + return template; +} diff --git a/src/main/domain/integrity-check.test.ts b/src/main/domain/integrity-check.test.ts new file mode 100644 index 0000000..a66720a --- /dev/null +++ b/src/main/domain/integrity-check.test.ts @@ -0,0 +1,115 @@ +import { test, expect, describe } from 'vitest'; +import { parseFfprobeJson, assessIntegrity, verifyIntegrityFromJson } from './integrity-check'; + +const FIXTURE_GOOD = JSON.stringify({ + streams: [ + { index: 0, codec_type: 'video', codec_name: 'h264', width: 1920, height: 1080, duration: '600.5' }, + { index: 1, codec_type: 'audio', codec_name: 'aac', duration: '600.5' }, + ], + format: { duration: '600.5', size: '50000000' }, +}); + +const FIXTURE_NO_VIDEO = JSON.stringify({ + streams: [ + { index: 0, codec_type: 'audio', codec_name: 'aac', duration: '10' }, + ], + format: { duration: '10', size: '500000' }, +}); + +const FIXTURE_EMPTY = JSON.stringify({ + streams: [], + format: { duration: '0.04', size: '1234' }, +}); + +describe('parseFfprobeJson', () => { + test('parses streams + format', () => { + const r = parseFfprobeJson(FIXTURE_GOOD); + expect(r.streams).toHaveLength(2); + expect(r.streams[0].codecType).toBe('video'); + expect(r.streams[0].codecName).toBe('h264'); + expect(r.streams[0].width).toBe(1920); + expect(r.durationSeconds).toBe(600.5); + expect(r.sizeBytes).toBe(50000000); + }); + + test('handles missing format gracefully', () => { + const r = parseFfprobeJson(JSON.stringify({ streams: [] })); + expect(r.durationSeconds).toBe(0); + expect(r.sizeBytes).toBe(0); + }); + + test('throws on malformed JSON', () => { + expect(() => parseFfprobeJson('{not-valid')).toThrow(/parse failed/); + }); + + test('coerces numeric strings to numbers', () => { + const r = parseFfprobeJson(JSON.stringify({ + streams: [{ codec_type: 'video', duration: '12.34' }], + format: { duration: '12.34', size: '987654' }, + })); + expect(r.durationSeconds).toBe(12.34); + expect(r.streams[0].durationSeconds).toBe(12.34); + expect(r.sizeBytes).toBe(987654); + }); +}); + +describe('assessIntegrity', () => { + test('valid file: ok=true, no reasons', () => { + const probe = parseFfprobeJson(FIXTURE_GOOD); + const v = assessIntegrity(probe); + expect(v.ok).toBe(true); + expect(v.reasons).toEqual([]); + expect(v.hasVideo).toBe(true); + expect(v.hasAudio).toBe(true); + expect(v.durationSeconds).toBe(600.5); + }); + + test('no-video stream rejected', () => { + const v = assessIntegrity(parseFfprobeJson(FIXTURE_NO_VIDEO)); + expect(v.ok).toBe(false); + expect(v.reasons).toContain('no-video-stream'); + expect(v.hasVideo).toBe(false); + }); + + test('zero-duration rejected as too-short', () => { + const v = assessIntegrity(parseFfprobeJson(FIXTURE_EMPTY)); + expect(v.ok).toBe(false); + expect(v.reasons.some(r => r.startsWith('duration-too-short'))).toBe(true); + }); + + test('expected-duration mismatch outside tolerance flagged', () => { + const v = assessIntegrity(parseFfprobeJson(FIXTURE_GOOD), { + expectedDurationSeconds: 700, + durationToleranceSeconds: 5, + }); + expect(v.ok).toBe(false); + expect(v.reasons.some(r => r.startsWith('duration-mismatch'))).toBe(true); + }); + + test('expected-duration within tolerance accepted', () => { + const v = assessIntegrity(parseFfprobeJson(FIXTURE_GOOD), { + expectedDurationSeconds: 598, + durationToleranceSeconds: 5, + }); + expect(v.ok).toBe(true); + }); + + test('custom minDurationSeconds threshold', () => { + const v = assessIntegrity(parseFfprobeJson(FIXTURE_GOOD), { + minDurationSeconds: 700, + }); + expect(v.ok).toBe(false); + expect(v.reasons.some(r => r.startsWith('duration-too-short'))).toBe(true); + }); +}); + +describe('verifyIntegrityFromJson', () => { + test('one-shot parse + assess', () => { + const v = verifyIntegrityFromJson(FIXTURE_GOOD); + expect(v.ok).toBe(true); + }); + + test('propagates parse errors', () => { + expect(() => verifyIntegrityFromJson('{broken')).toThrow(); + }); +}); diff --git a/src/main/domain/integrity-check.ts b/src/main/domain/integrity-check.ts new file mode 100644 index 0000000..0a12fae --- /dev/null +++ b/src/main/domain/integrity-check.ts @@ -0,0 +1,132 @@ +// Wrappt ffprobe -show_streams -show_format -of json + entscheidet, ob eine +// fertige Recording-/Download-Datei strukturell valide ist. +// Pure-Parser-Layer ist getrennt testbar; das eigentliche Spawn ist im Caller. + +export interface ProbeStream { + index: number; + codecType: string; // 'video' | 'audio' | 'subtitle' | ... + codecName?: string; + width?: number; + height?: number; + durationSeconds?: number; +} + +export interface ProbeResult { + streams: ProbeStream[]; + durationSeconds: number; + sizeBytes: number; +} + +export interface IntegrityVerdict { + ok: boolean; + reasons: string[]; + durationSeconds: number; + hasVideo: boolean; + hasAudio: boolean; +} + +export interface IntegrityCheckOptions { + expectedDurationSeconds?: number; + durationToleranceSeconds?: number; // default 5 + minDurationSeconds?: number; // default 1 +} + +interface FfprobeJsonStream { + index?: number; + codec_type?: string; + codec_name?: string; + width?: number; + height?: number; + duration?: string | number; +} + +interface FfprobeJson { + streams?: FfprobeJsonStream[]; + format?: { + duration?: string | number; + size?: string | number; + }; +} + +function toNumber(v: unknown, fallback = 0): number { + if (typeof v === 'number' && Number.isFinite(v)) return v; + if (typeof v === 'string') { + const n = Number(v); + if (Number.isFinite(n)) return n; + } + return fallback; +} + +export function parseFfprobeJson(rawJson: string): ProbeResult { + let parsed: FfprobeJson; + try { + parsed = JSON.parse(rawJson) as FfprobeJson; + } catch (e) { + throw new Error(`integrity-check: ffprobe JSON parse failed: ${e instanceof Error ? e.message : String(e)}`); + } + + const streams: ProbeStream[] = (parsed.streams ?? []).map((s, idx) => ({ + index: typeof s.index === 'number' ? s.index : idx, + codecType: typeof s.codec_type === 'string' ? s.codec_type : 'unknown', + codecName: typeof s.codec_name === 'string' ? s.codec_name : undefined, + width: typeof s.width === 'number' ? s.width : undefined, + height: typeof s.height === 'number' ? s.height : undefined, + durationSeconds: s.duration !== undefined ? toNumber(s.duration) : undefined, + })); + + const formatDuration = toNumber(parsed.format?.duration, 0); + const formatSize = toNumber(parsed.format?.size, 0); + + return { + streams, + durationSeconds: formatDuration, + sizeBytes: formatSize, + }; +} + +export function assessIntegrity(probe: ProbeResult, opts: IntegrityCheckOptions = {}): IntegrityVerdict { + const minDuration = opts.minDurationSeconds ?? 1; + const tolerance = opts.durationToleranceSeconds ?? 5; + + const hasVideo = probe.streams.some(s => s.codecType === 'video'); + const hasAudio = probe.streams.some(s => s.codecType === 'audio'); + + const reasons: string[] = []; + + if (!hasVideo) { + reasons.push('no-video-stream'); + } + + if (probe.durationSeconds < minDuration) { + reasons.push(`duration-too-short:${probe.durationSeconds.toFixed(2)}s<${minDuration}s`); + } + + if (typeof opts.expectedDurationSeconds === 'number' && opts.expectedDurationSeconds > 0) { + const diff = Math.abs(probe.durationSeconds - opts.expectedDurationSeconds); + if (diff > tolerance) { + reasons.push( + `duration-mismatch:actual=${probe.durationSeconds.toFixed(2)}s,` + + `expected=${opts.expectedDurationSeconds.toFixed(2)}s,` + + `tolerance=${tolerance}s` + ); + } + } + + return { + ok: reasons.length === 0, + reasons, + durationSeconds: probe.durationSeconds, + hasVideo, + hasAudio, + }; +} + +/** + * Convenience: vollstaendige integrity-check Pipeline. Caller liefert die + * ffprobe-JSON-Ausgabe als String (so bleibt das Modul Spawn-frei + leicht + * testbar; die main.ts hat schon ffprobe-Spawn-Helpers). + */ +export function verifyIntegrityFromJson(rawJson: string, opts?: IntegrityCheckOptions): IntegrityVerdict { + const probe = parseFfprobeJson(rawJson); + return assessIntegrity(probe, opts); +} diff --git a/src/main/domain/migrator.test.ts b/src/main/domain/migrator.test.ts new file mode 100644 index 0000000..b214ff2 --- /dev/null +++ b/src/main/domain/migrator.test.ts @@ -0,0 +1,122 @@ +import { test, expect, describe, beforeEach, afterEach } from 'vitest'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { openDatabase, type DbHandle } from '../infra/db'; +import { migrateJsonToSqlite } from './migrator'; + +let tmpDir: string; +let appDataDir: string; +let db: DbHandle; +beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'migrator-')); + appDataDir = path.join(tmpDir, 'appdata'); + fs.mkdirSync(appDataDir, { recursive: true }); + db = openDatabase(path.join(tmpDir, 'app.db')); +}); +afterEach(() => { + db.close(); + try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch { /* ignore */ } +}); + +function writeJson(name: string, payload: unknown): string { + const target = path.join(appDataDir, name); + fs.writeFileSync(target, JSON.stringify(payload, null, 2), 'utf-8'); + return target; +} + +describe('migrateJsonToSqlite', () => { + test('no JSON files: writes migrations_applied marker', () => { + const result = migrateJsonToSqlite({ db, appDataDir }); + expect(result.configMigrated).toBe(false); + expect(result.queueMigrated).toBe(false); + expect(result.downloadedVodsCount).toBe(0); + expect(result.streamersCount).toBe(0); + + const marker = db.get<{ name: string }>('SELECT name FROM migrations_applied WHERE name = ?', ['v4-to-v5-jsons']); + expect(marker?.name).toBe('v4-to-v5-jsons'); + }); + + test('migrates config.json keys into config_kv', () => { + writeJson('config.json', { + language: 'de', + performance_mode: 'speed', + metadata_cache_minutes: 30, + downloaded_vod_ids: ['1', '2', '3'], + auto_record_streamers: ['foo', 'bar'], + }); + const result = migrateJsonToSqlite({ db, appDataDir }); + expect(result.configMigrated).toBe(true); + + const lang = db.get<{ value: string }>('SELECT value FROM config_kv WHERE key = ?', ['language']); + expect(JSON.parse(lang!.value)).toBe('de'); + + const perf = db.get<{ value: string }>('SELECT value FROM config_kv WHERE key = ?', ['performance_mode']); + expect(JSON.parse(perf!.value)).toBe('speed'); + }); + + test('migrates downloaded_vod_ids', () => { + writeJson('config.json', { downloaded_vod_ids: ['100', '200', '300'] }); + const result = migrateJsonToSqlite({ db, appDataDir }); + expect(result.downloadedVodsCount).toBe(3); + const rows = db.all<{ vod_id: string }>('SELECT vod_id FROM downloaded_vods ORDER BY vod_id'); + expect(rows.map(r => r.vod_id)).toEqual(['100', '200', '300']); + }); + + test('migrates streamers from both auto-record and auto-vod-download lists', () => { + writeJson('config.json', { + auto_record_streamers: ['Alice', '@bob'], + auto_vod_download_streamers: ['bob', 'carol'], + }); + const result = migrateJsonToSqlite({ db, appDataDir }); + expect(result.streamersCount).toBeGreaterThanOrEqual(3); + + const alice = db.get<{ login: string; auto_record: number }>('SELECT login, auto_record FROM streamers WHERE login = ?', ['alice']); + expect(alice?.auto_record).toBe(1); + + const bob = db.get<{ login: string; auto_record: number; auto_vod_download: number }>('SELECT login, auto_record, auto_vod_download FROM streamers WHERE login = ?', ['bob']); + expect(bob?.auto_record).toBe(1); + expect(bob?.auto_vod_download).toBe(1); + + const carol = db.get<{ login: string; auto_vod_download: number }>('SELECT login, auto_vod_download FROM streamers WHERE login = ?', ['carol']); + expect(carol?.auto_vod_download).toBe(1); + }); + + test('migrates download_queue.json items', () => { + writeJson('download_queue.json', [ + { id: 'q1', status: 'pending', streamer: 'foo', vod_id: 'v1', created_at: 1000, updated_at: 1000 }, + { id: 'q2', status: 'completed', streamer: 'bar', vod_id: 'v2', created_at: 2000, updated_at: 3000, completed_at: 3000 }, + ]); + const result = migrateJsonToSqlite({ db, appDataDir }); + expect(result.queueMigrated).toBe(true); + + const all = db.all<{ id: string; status: string }>('SELECT id, status FROM queue_items ORDER BY id'); + expect(all).toHaveLength(2); + expect(all[0].status).toBe('pending'); + expect(all[1].status).toBe('completed'); + }); + + test('idempotent second run', () => { + writeJson('config.json', { downloaded_vod_ids: ['1', '2'] }); + migrateJsonToSqlite({ db, appDataDir }); + const result2 = migrateJsonToSqlite({ db, appDataDir }); + expect(result2.alreadyApplied).toBe(true); + const count = db.get<{ c: number }>('SELECT COUNT(*) AS c FROM downloaded_vods'); + expect(count?.c).toBe(2); + }); + + test('writes .v4-backup of source JSONs', () => { + const configPath = writeJson('config.json', { language: 'en' }); + migrateJsonToSqlite({ db, appDataDir }); + expect(fs.existsSync(configPath + '.v4-backup')).toBe(true); + expect(fs.readFileSync(configPath + '.v4-backup', 'utf-8')).toContain('"language": "en"'); + }); + + test('malformed JSON is logged + skipped', () => { + fs.writeFileSync(path.join(appDataDir, 'config.json'), '{ not valid json', 'utf-8'); + const result = migrateJsonToSqlite({ db, appDataDir }); + expect(result.configMigrated).toBe(false); + expect(result.errors.length).toBeGreaterThan(0); + expect(result.errors[0].source).toBe('config.json'); + }); +}); diff --git a/src/main/domain/migrator.ts b/src/main/domain/migrator.ts new file mode 100644 index 0000000..373d9b3 --- /dev/null +++ b/src/main/domain/migrator.ts @@ -0,0 +1,201 @@ +import * as fs from 'fs'; +import * as path from 'path'; +import type { DbHandle } from '../infra/db'; +import { normalizeLogin } from './config-normalize'; + +export interface MigratorOptions { + db: DbHandle; + appDataDir: string; +} + +export interface MigrationError { + source: string; + message: string; +} + +export interface MigrationResult { + alreadyApplied: boolean; + configMigrated: boolean; + queueMigrated: boolean; + downloadedVodsCount: number; + streamersCount: number; + errors: MigrationError[]; +} + +const MIGRATION_NAME = 'v4-to-v5-jsons'; + +const CONFIG_KV_KEYS = [ + 'language', 'performance_mode', 'metadata_cache_minutes', 'streamlink_quality', + 'streamlink_disable_ads', 'download_chat_replay', 'capture_live_chat', + 'discord_webhook_url', 'discord_notify_live_start', 'discord_notify_live_end', + 'discord_notify_vod_complete', 'discord_notify_vod_auto_queued', + 'auto_cleanup_enabled', 'auto_cleanup_days', 'auto_cleanup_target', + 'auto_cleanup_action', 'log_stream_events', 'auto_vod_download_poll_minutes', + 'auto_vod_max_age_hours', 'auto_resume_live_recording', + 'auto_merge_resumed_parts', 'delete_parts_after_merge', + 'auto_record_poll_seconds', 'filename_template_vod', 'filename_template_parts', + 'filename_template_clip', 'smart_queue_scheduler', 'prevent_duplicate_downloads', + 'persist_queue_on_restart', 'auto_resume_queue_on_startup', + 'notify_on_each_completion', +] as const; + +function backupOnce(srcPath: string): void { + const backupPath = srcPath + '.v4-backup'; + if (!fs.existsSync(backupPath)) { + fs.copyFileSync(srcPath, backupPath); + } +} + +function migrateConfig(db: DbHandle, configPath: string, errors: MigrationError[]): { ok: boolean; vodCount: number } { + try { + const raw = fs.readFileSync(configPath, 'utf-8'); + const config = JSON.parse(raw) as Record; + + let vodCount = 0; + db.transaction(() => { + for (const key of CONFIG_KV_KEYS) { + if (key in config) { + db.run( + "INSERT OR REPLACE INTO config_kv(key, value, updated_at) VALUES (?, ?, strftime('%s','now'))", + [key, JSON.stringify(config[key])] + ); + } + } + + const vodIds = Array.isArray(config.downloaded_vod_ids) ? config.downloaded_vod_ids : []; + for (const id of vodIds) { + if (typeof id !== 'string' || !id) continue; + db.run('INSERT OR IGNORE INTO downloaded_vods(vod_id) VALUES (?)', [id]); + vodCount += 1; + } + + const autoRec = Array.isArray(config.auto_record_streamers) ? config.auto_record_streamers : []; + for (const s of autoRec) { + if (typeof s !== 'string' || !s) continue; + const login = normalizeLogin(s); + if (!login) continue; + db.run( + 'INSERT INTO streamers(login, auto_record) VALUES (?, 1) ON CONFLICT(login) DO UPDATE SET auto_record = 1', + [login] + ); + } + + const autoDl = Array.isArray(config.auto_vod_download_streamers) ? config.auto_vod_download_streamers : []; + for (const s of autoDl) { + if (typeof s !== 'string' || !s) continue; + const login = normalizeLogin(s); + if (!login) continue; + db.run( + 'INSERT INTO streamers(login, auto_vod_download) VALUES (?, 1) ON CONFLICT(login) DO UPDATE SET auto_vod_download = 1', + [login] + ); + } + }); + + backupOnce(configPath); + return { ok: true, vodCount }; + } catch (e) { + errors.push({ source: 'config.json', message: e instanceof Error ? e.message : String(e) }); + return { ok: false, vodCount: 0 }; + } +} + +function migrateQueue(db: DbHandle, queuePath: string, errors: MigrationError[]): boolean { + try { + const raw = fs.readFileSync(queuePath, 'utf-8'); + const queue = JSON.parse(raw); + if (!Array.isArray(queue)) return false; + + const now = Math.floor(Date.now() / 1000); + db.transaction(() => { + for (const rawItem of queue) { + if (!rawItem || typeof rawItem !== 'object') continue; + const item = rawItem as Record; + const id = typeof item.id === 'string' ? item.id : null; + if (!id) continue; + db.run( + `INSERT OR REPLACE INTO queue_items + (id, streamer_login, vod_id, clip_id, title, output_path, status, + progress_pct, error_message, created_at, updated_at, completed_at, payload_json) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + [ + id, + typeof item.streamer === 'string' ? normalizeLogin(item.streamer) : null, + typeof item.vod_id === 'string' ? item.vod_id : null, + typeof item.clip_id === 'string' ? item.clip_id : null, + typeof item.title === 'string' ? item.title : null, + typeof item.output_path === 'string' ? item.output_path : null, + typeof item.status === 'string' ? item.status : 'pending', + typeof item.progress_pct === 'number' ? item.progress_pct : null, + typeof item.error_message === 'string' ? item.error_message : null, + typeof item.created_at === 'number' ? item.created_at : now, + typeof item.updated_at === 'number' ? item.updated_at : now, + typeof item.completed_at === 'number' ? item.completed_at : null, + JSON.stringify(item), + ] + ); + } + }); + + backupOnce(queuePath); + return true; + } catch (e) { + errors.push({ source: 'download_queue.json', message: e instanceof Error ? e.message : String(e) }); + return false; + } +} + +export function migrateJsonToSqlite(opts: MigratorOptions): MigrationResult { + const { db, appDataDir } = opts; + const errors: MigrationError[] = []; + + const existing = db.get<{ name: string }>( + 'SELECT name FROM migrations_applied WHERE name = ?', + [MIGRATION_NAME] + ); + if (existing) { + return { + alreadyApplied: true, + configMigrated: false, + queueMigrated: false, + downloadedVodsCount: 0, + streamersCount: 0, + errors: [], + }; + } + + let configMigrated = false; + let queueMigrated = false; + let downloadedVodsCount = 0; + + const configPath = path.join(appDataDir, 'config.json'); + if (fs.existsSync(configPath)) { + const r = migrateConfig(db, configPath, errors); + configMigrated = r.ok; + downloadedVodsCount = r.vodCount; + } + + const queuePath = path.join(appDataDir, 'download_queue.json'); + if (fs.existsSync(queuePath)) { + queueMigrated = migrateQueue(db, queuePath, errors); + } + + const streamersCount = db.get<{ c: number }>('SELECT COUNT(*) AS c FROM streamers')?.c ?? 0; + + db.run( + 'INSERT INTO migrations_applied(name, payload) VALUES (?, ?)', + [ + MIGRATION_NAME, + JSON.stringify({ configMigrated, queueMigrated, downloadedVodsCount, streamersCount, errorCount: errors.length }), + ] + ); + + return { + alreadyApplied: false, + configMigrated, + queueMigrated, + downloadedVodsCount, + streamersCount, + errors, + }; +} diff --git a/src/main/domain/pkce.test.ts b/src/main/domain/pkce.test.ts new file mode 100644 index 0000000..33be3f9 --- /dev/null +++ b/src/main/domain/pkce.test.ts @@ -0,0 +1,45 @@ +import { test, expect, describe } from 'vitest'; +import * as crypto from 'crypto'; +import { createPkcePair, generateState } from './pkce'; + +describe('createPkcePair', () => { + test('returns S256 method', () => { + expect(createPkcePair().codeChallengeMethod).toBe('S256'); + }); + + test('verifier is 43+ chars base64url-safe', () => { + const { codeVerifier } = createPkcePair(); + expect(codeVerifier.length).toBeGreaterThanOrEqual(43); + // RFC 7636 unreserved chars only: [A-Z a-z 0-9 - . _ ~] + // base64url uses [A-Z a-z 0-9 - _], no = padding. + expect(/^[A-Za-z0-9_-]+$/.test(codeVerifier)).toBe(true); + }); + + test('challenge matches sha256(verifier) base64url-encoded', () => { + const pair = createPkcePair(); + const expected = crypto.createHash('sha256').update(pair.codeVerifier).digest('base64') + .replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); + expect(pair.codeChallenge).toBe(expected); + }); + + test('two pairs differ (sufficient entropy)', () => { + const a = createPkcePair(); + const b = createPkcePair(); + expect(a.codeVerifier).not.toBe(b.codeVerifier); + expect(a.codeChallenge).not.toBe(b.codeChallenge); + }); +}); + +describe('generateState', () => { + test('returns >= 16 chars', () => { + expect(generateState().length).toBeGreaterThanOrEqual(16); + }); + + test('base64url-safe charset', () => { + expect(/^[A-Za-z0-9_-]+$/.test(generateState())).toBe(true); + }); + + test('two states differ', () => { + expect(generateState()).not.toBe(generateState()); + }); +}); diff --git a/src/main/domain/pkce.ts b/src/main/domain/pkce.ts new file mode 100644 index 0000000..0f95e7d --- /dev/null +++ b/src/main/domain/pkce.ts @@ -0,0 +1,35 @@ +import * as crypto from 'crypto'; + +/** + * PKCE (Proof Key for Code Exchange) Helper fuer OAuth 2.1 Authorization Code Flow. + * RFC 7636. Twitch unterstuetzt S256. + */ + +export interface PkcePair { + codeVerifier: string; // 43-128 ASCII chars [A-Z a-z 0-9 - . _ ~] + codeChallenge: string; // base64url(sha256(codeVerifier)) + codeChallengeMethod: 'S256'; +} + +function base64url(buf: Buffer): string { + return buf.toString('base64') + .replace(/\+/g, '-') + .replace(/\//g, '_') + .replace(/=+$/, ''); +} + +export function createPkcePair(): PkcePair { + // 32 random bytes → 43-char base64url. Innerhalb der RFC-Range. + const verifier = base64url(crypto.randomBytes(32)); + const challenge = base64url(crypto.createHash('sha256').update(verifier).digest()); + return { + codeVerifier: verifier, + codeChallenge: challenge, + codeChallengeMethod: 'S256', + }; +} + +export function generateState(): string { + // 16 random bytes als base64url-State-Parameter (CSRF-Schutz). + return base64url(crypto.randomBytes(16)); +} diff --git a/src/main/domain/token-store.test.ts b/src/main/domain/token-store.test.ts new file mode 100644 index 0000000..2337899 --- /dev/null +++ b/src/main/domain/token-store.test.ts @@ -0,0 +1,120 @@ +import { test, expect, describe, beforeEach, afterEach } from 'vitest'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { openDatabase, type DbHandle } from '../infra/db'; +import { MemorySecureStorage } from '../infra/secure-storage'; +import { createTokenStore, type TokenStore } from './token-store'; + +let tmpDir: string; +let db: DbHandle; +let store: TokenStore; +beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'tokens-')); + db = openDatabase(path.join(tmpDir, 'app.db')); + store = createTokenStore(db, new MemorySecureStorage()); +}); +afterEach(() => { + db.close(); + try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch { /* ignore */ } +}); + +describe('createTokenStore', () => { + test('upsert new account returns record with id > 0', () => { + const rec = store.upsert({ + provider: 'twitch', + twitchUserId: 'u1', + login: 'alice', + accessToken: 'aaa.aaa.aaa', + }); + expect(rec.id).toBeGreaterThan(0); + expect(rec.login).toBe('alice'); + expect(rec.provider).toBe('twitch'); + expect(rec.twitchUserId).toBe('u1'); + }); + + test('upsert same (provider, twitch_user_id) updates, no duplicate row', () => { + store.upsert({ provider: 'twitch', twitchUserId: 'u1', login: 'alice', accessToken: 't1' }); + const updated = store.upsert({ provider: 'twitch', twitchUserId: 'u1', login: 'alice2', accessToken: 't2' }); + expect(updated.login).toBe('alice2'); + const all = store.list('twitch'); + expect(all).toHaveLength(1); + expect(all[0].login).toBe('alice2'); + }); + + test('list() returns all accounts, list(provider) filters', () => { + store.upsert({ provider: 'twitch', twitchUserId: 'u1', login: 'a', accessToken: 'x' }); + store.upsert({ provider: 'twitch', twitchUserId: 'u2', login: 'b', accessToken: 'y' }); + store.upsert({ provider: 'youtube', twitchUserId: undefined, login: 'c', accessToken: 'z' }); + expect(store.list()).toHaveLength(3); + expect(store.list('twitch')).toHaveLength(2); + expect(store.list('youtube')).toHaveLength(1); + }); + + test('getDefault returns null when nothing default', () => { + store.upsert({ provider: 'twitch', twitchUserId: 'u1', login: 'a', accessToken: 'x' }); + expect(store.getDefault('twitch')).toBeNull(); + }); + + test('upsert with isDefault=true makes it default, demotes siblings', () => { + const a = store.upsert({ provider: 'twitch', twitchUserId: 'u1', login: 'a', accessToken: 'x', isDefault: true }); + const b = store.upsert({ provider: 'twitch', twitchUserId: 'u2', login: 'b', accessToken: 'y', isDefault: true }); + + const def = store.getDefault('twitch'); + expect(def?.id).toBe(b.id); + + const aAgain = store.list('twitch').find(r => r.id === a.id); + expect(aAgain?.isDefault).toBe(false); + }); + + test('setDefault toggles is_default exclusivity within provider', () => { + const a = store.upsert({ provider: 'twitch', twitchUserId: 'u1', login: 'a', accessToken: 'x', isDefault: true }); + const b = store.upsert({ provider: 'twitch', twitchUserId: 'u2', login: 'b', accessToken: 'y' }); + + store.setDefault(b.id); + expect(store.getDefault('twitch')?.id).toBe(b.id); + + const aAgain = store.list('twitch').find(r => r.id === a.id); + expect(aAgain?.isDefault).toBe(false); + }); + + test('getAccessToken returns decrypted plaintext', () => { + const rec = store.upsert({ provider: 'twitch', twitchUserId: 'u1', login: 'a', accessToken: 'super-secret-token' }); + expect(store.getAccessToken(rec.id)).toBe('super-secret-token'); + }); + + test('getRefreshToken returns null if not provided, value if provided', () => { + const noRefresh = store.upsert({ provider: 'twitch', twitchUserId: 'u1', login: 'a', accessToken: 't1' }); + expect(store.getRefreshToken(noRefresh.id)).toBeNull(); + + const withRefresh = store.upsert({ + provider: 'twitch', twitchUserId: 'u2', login: 'b', + accessToken: 't2', refreshToken: 'refresh-xyz', + }); + expect(store.getRefreshToken(withRefresh.id)).toBe('refresh-xyz'); + }); + + test('scopes roundtrip as array', () => { + const rec = store.upsert({ + provider: 'twitch', twitchUserId: 'u1', login: 'a', + accessToken: 't', scopes: ['user:read:email', 'channel:read:subscriptions'], + }); + expect(rec.scopes).toEqual(['user:read:email', 'channel:read:subscriptions']); + }); + + test('delete removes the record', () => { + const rec = store.upsert({ provider: 'twitch', twitchUserId: 'u1', login: 'a', accessToken: 'x' }); + store.delete(rec.id); + expect(store.list('twitch')).toHaveLength(0); + expect(() => store.getAccessToken(rec.id)).toThrow(); + }); + + test('expiresAt roundtrip', () => { + const future = Math.floor(Date.now() / 1000) + 3600; + const rec = store.upsert({ + provider: 'twitch', twitchUserId: 'u1', login: 'a', + accessToken: 't', expiresAt: future, + }); + expect(rec.expiresAt).toBe(future); + }); +}); diff --git a/src/main/domain/token-store.ts b/src/main/domain/token-store.ts new file mode 100644 index 0000000..9793567 --- /dev/null +++ b/src/main/domain/token-store.ts @@ -0,0 +1,203 @@ +import type { DbHandle } from '../infra/db'; +import type { SecureStorage } from '../infra/secure-storage'; + +export interface TokenRecord { + id: number; + provider: string; + twitchUserId: string | null; + login: string | null; + displayName: string | null; + expiresAt: number | null; + scopes: string[]; + isDefault: boolean; + createdAt: number; + updatedAt: number; +} + +export interface TokenWriteInput { + provider: string; + twitchUserId?: string; + login?: string; + displayName?: string; + accessToken: string; + refreshToken?: string; + expiresAt?: number; + scopes?: string[]; + isDefault?: boolean; +} + +export interface TokenStore { + upsert(input: TokenWriteInput): TokenRecord; + list(provider?: string): TokenRecord[]; + getDefault(provider: string): TokenRecord | null; + setDefault(id: number): void; + getAccessToken(id: number): string; + getRefreshToken(id: number): string | null; + delete(id: number): void; +} + +interface TokenRow { + id: number; + provider: string; + twitch_user_id: string | null; + login: string | null; + display_name: string | null; + encrypted_access_token: string; + encrypted_refresh_token: string | null; + expires_at: number | null; + scopes_json: string | null; + is_default: number; + created_at: number; + updated_at: number; +} + +function rowToRecord(row: TokenRow): TokenRecord { + let scopes: string[] = []; + if (row.scopes_json) { + try { + const parsed = JSON.parse(row.scopes_json); + if (Array.isArray(parsed)) { + scopes = parsed.filter((s): s is string => typeof s === 'string'); + } + } catch { /* malformed scopes payload — treat as empty */ } + } + return { + id: row.id, + provider: row.provider, + twitchUserId: row.twitch_user_id, + login: row.login, + displayName: row.display_name, + expiresAt: row.expires_at, + scopes, + isDefault: row.is_default === 1, + createdAt: row.created_at, + updatedAt: row.updated_at, + }; +} + +export function createTokenStore(db: DbHandle, storage: SecureStorage): TokenStore { + function getRowOrThrow(id: number): TokenRow { + const row = db.get('SELECT * FROM oauth_accounts WHERE id = ?', [id]); + if (!row) throw new Error(`token-store: account id=${id} not found`); + return row; + } + + return { + upsert(input: TokenWriteInput): TokenRecord { + const now = Math.floor(Date.now() / 1000); + const encryptedAccess = storage.encrypt(input.accessToken); + const encryptedRefresh = input.refreshToken !== undefined + ? storage.encrypt(input.refreshToken) + : null; + const scopesJson = input.scopes && input.scopes.length > 0 + ? JSON.stringify(input.scopes) + : null; + const isDefault = input.isDefault ? 1 : 0; + const twitchUserId = input.twitchUserId ?? null; + + let resultId: number | null = null; + + db.transaction(() => { + // Insert or update conditional on UNIQUE(provider, twitch_user_id). + // Sqlite's ON CONFLICT braucht den vollstaendigen Konflikt-Ausdruck. + db.run( + `INSERT INTO oauth_accounts( + provider, twitch_user_id, login, display_name, + encrypted_access_token, encrypted_refresh_token, + expires_at, scopes_json, is_default, created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(provider, twitch_user_id) DO UPDATE SET + login = excluded.login, + display_name = excluded.display_name, + encrypted_access_token = excluded.encrypted_access_token, + encrypted_refresh_token = excluded.encrypted_refresh_token, + expires_at = excluded.expires_at, + scopes_json = excluded.scopes_json, + is_default = excluded.is_default, + updated_at = excluded.updated_at`, + [ + input.provider, + twitchUserId, + input.login ?? null, + input.displayName ?? null, + encryptedAccess, + encryptedRefresh, + input.expiresAt ?? null, + scopesJson, + isDefault, + now, + now, + ] + ); + + // Wenn dieser Eintrag default ist: alle anderen mit gleichem provider auf 0 setzen. + if (isDefault === 1) { + db.run( + `UPDATE oauth_accounts + SET is_default = 0, updated_at = ? + WHERE provider = ? + AND NOT (twitch_user_id IS ? AND provider IS ?)`, + [now, input.provider, twitchUserId, input.provider] + ); + } + + const lookup = db.get<{ id: number }>( + `SELECT id FROM oauth_accounts + WHERE provider = ? + AND (twitch_user_id IS ? OR (twitch_user_id IS NULL AND ? IS NULL))`, + [input.provider, twitchUserId, twitchUserId] + ); + resultId = lookup?.id ?? null; + }); + + if (resultId === null) throw new Error('token-store: upsert lookup failed'); + return rowToRecord(getRowOrThrow(resultId)); + }, + + list(provider?: string): TokenRecord[] { + const rows = provider + ? db.all('SELECT * FROM oauth_accounts WHERE provider = ? ORDER BY id', [provider]) + : db.all('SELECT * FROM oauth_accounts ORDER BY id'); + return rows.map(rowToRecord); + }, + + getDefault(provider: string): TokenRecord | null { + const row = db.get( + 'SELECT * FROM oauth_accounts WHERE provider = ? AND is_default = 1 LIMIT 1', + [provider] + ); + return row ? rowToRecord(row) : null; + }, + + setDefault(id: number): void { + const target = getRowOrThrow(id); + const now = Math.floor(Date.now() / 1000); + db.transaction(() => { + db.run( + 'UPDATE oauth_accounts SET is_default = 0, updated_at = ? WHERE provider = ?', + [now, target.provider] + ); + db.run( + 'UPDATE oauth_accounts SET is_default = 1, updated_at = ? WHERE id = ?', + [now, id] + ); + }); + }, + + getAccessToken(id: number): string { + const row = getRowOrThrow(id); + return storage.decrypt(row.encrypted_access_token); + }, + + getRefreshToken(id: number): string | null { + const row = getRowOrThrow(id); + return row.encrypted_refresh_token + ? storage.decrypt(row.encrypted_refresh_token) + : null; + }, + + delete(id: number): void { + db.run('DELETE FROM oauth_accounts WHERE id = ?', [id]); + }, + }; +} diff --git a/src/main/domain/top-clips-crawler.test.ts b/src/main/domain/top-clips-crawler.test.ts new file mode 100644 index 0000000..e4c6a99 --- /dev/null +++ b/src/main/domain/top-clips-crawler.test.ts @@ -0,0 +1,137 @@ +import { test, expect, describe } from 'vitest'; +import { fetchTopClips, rangeLastDays } from './top-clips-crawler'; + +function fakeFetch(rows: Array>, status = 200): typeof fetch { + return (async (url: string | URL | Request, init?: RequestInit): Promise => { + // verify request shape lightly inside the fake + const headers = init?.headers as Record | undefined; + if (status === 200 && (!headers?.['Authorization'] || !headers?.['Client-Id'])) { + return new Response('missing auth headers', { status: 401 }); + } + return new Response(JSON.stringify({ data: rows }), { + status, + headers: { 'Content-Type': 'application/json' }, + }); + }) as unknown as typeof fetch; +} + +describe('fetchTopClips', () => { + test('returns parsed clips sorted by view_count desc', async () => { + const fakeRows = [ + { + id: 'C2', url: 'u2', embed_url: 'e2', broadcaster_id: 'b', broadcaster_name: 'B', + creator_id: 'c', creator_name: 'C', video_id: 'v', game_id: 'g', language: 'en', + title: 'mid', view_count: 50, created_at: '2026-05-10T00:00:00Z', + thumbnail_url: 't', duration: 30, vod_offset: 120, + }, + { + id: 'C1', url: 'u1', embed_url: 'e1', broadcaster_id: 'b', broadcaster_name: 'B', + creator_id: 'c', creator_name: 'C', video_id: 'v', game_id: 'g', language: 'en', + title: 'high', view_count: 200, created_at: '2026-05-09T00:00:00Z', + thumbnail_url: 't', duration: 45, vod_offset: null, + }, + ]; + const clips = await fetchTopClips({ + clientId: 'CID', accessToken: 'TOK', broadcasterId: 'b', + fetchImpl: fakeFetch(fakeRows), + }); + + expect(clips).toHaveLength(2); + expect(clips[0].id).toBe('C1'); + expect(clips[0].viewCount).toBe(200); + expect(clips[1].id).toBe('C2'); + expect(clips[1].vodOffsetSeconds).toBe(120); + expect(clips[0].vodOffsetSeconds).toBeNull(); + }); + + test('snake_case → camelCase mapping for broadcaster fields', async () => { + const fakeRows = [ + { + id: 'X', url: 'u', embed_url: 'e', broadcaster_id: 'bid', broadcaster_name: 'BName', + creator_id: 'cid', creator_name: 'CName', video_id: 'vid', game_id: 'gid', + language: 'de', title: 'T', view_count: 10, created_at: '2026-05-01T00:00:00Z', + thumbnail_url: 'th', duration: 12, + }, + ]; + const [c] = await fetchTopClips({ + clientId: 'CID', accessToken: 'TOK', broadcasterId: 'bid', + fetchImpl: fakeFetch(fakeRows), + }); + expect(c.broadcasterId).toBe('bid'); + expect(c.broadcasterName).toBe('BName'); + expect(c.creatorId).toBe('cid'); + expect(c.creatorName).toBe('CName'); + expect(c.videoId).toBe('vid'); + expect(c.gameId).toBe('gid'); + }); + + test('builds query string with broadcaster_id + first + date range', async () => { + let capturedUrl: string | null = null; + const captureFetch = (async (url: string | URL | Request): Promise => { + capturedUrl = String(url); + return new Response(JSON.stringify({ data: [] }), { status: 200 }); + }) as unknown as typeof fetch; + + await fetchTopClips({ + clientId: 'CID', accessToken: 'TOK', broadcasterId: '12345', + startedAt: '2026-05-01T00:00:00Z', endedAt: '2026-05-11T00:00:00Z', + first: 50, fetchImpl: captureFetch, + }); + expect(capturedUrl).toContain('broadcaster_id=12345'); + expect(capturedUrl).toContain('first=50'); + expect(capturedUrl).toContain('started_at=2026-05-01T00%3A00%3A00Z'); + expect(capturedUrl).toContain('ended_at=2026-05-11T00%3A00%3A00Z'); + }); + + test('clamps first to [1, 100]', async () => { + let capturedUrl: string | null = null; + const captureFetch = (async (url: string | URL | Request): Promise => { + capturedUrl = String(url); + return new Response(JSON.stringify({ data: [] }), { status: 200 }); + }) as unknown as typeof fetch; + + await fetchTopClips({ clientId: 'C', accessToken: 'T', broadcasterId: 'b', first: 999, fetchImpl: captureFetch }); + expect(capturedUrl).toContain('first=100'); + + await fetchTopClips({ clientId: 'C', accessToken: 'T', broadcasterId: 'b', first: 0, fetchImpl: captureFetch }); + expect(capturedUrl).toContain('first=1'); + }); + + test('throws on non-2xx response', async () => { + await expect(fetchTopClips({ + clientId: 'C', accessToken: 'T', broadcasterId: 'b', + fetchImpl: fakeFetch([], 503), + })).rejects.toThrow(/503/); + }); + + test('throws on malformed JSON', async () => { + const brokenFetch = (async (): Promise => new Response('{not-json', { status: 200 })) as unknown as typeof fetch; + await expect(fetchTopClips({ + clientId: 'C', accessToken: 'T', broadcasterId: 'b', fetchImpl: brokenFetch, + })).rejects.toThrow(/parse failed/); + }); + + test('empty data returns empty array (not null)', async () => { + const emptyFetch = (async (): Promise => new Response(JSON.stringify({ data: [] }), { status: 200 })) as unknown as typeof fetch; + const clips = await fetchTopClips({ + clientId: 'C', accessToken: 'T', broadcasterId: 'b', fetchImpl: emptyFetch, + }); + expect(clips).toEqual([]); + }); +}); + +describe('rangeLastDays', () => { + test('produces ISO RFC3339 strings exactly N days apart', () => { + const now = new Date('2026-05-11T12:00:00Z'); + const range = rangeLastDays(7, now); + expect(range.endedAt).toBe('2026-05-11T12:00:00.000Z'); + expect(range.startedAt).toBe('2026-05-04T12:00:00.000Z'); + }); + + test('1-day range', () => { + const now = new Date('2026-05-11T12:00:00Z'); + const range = rangeLastDays(1, now); + expect(range.startedAt).toBe('2026-05-10T12:00:00.000Z'); + expect(range.endedAt).toBe('2026-05-11T12:00:00.000Z'); + }); +}); diff --git a/src/main/domain/top-clips-crawler.ts b/src/main/domain/top-clips-crawler.ts new file mode 100644 index 0000000..e415c54 --- /dev/null +++ b/src/main/domain/top-clips-crawler.ts @@ -0,0 +1,135 @@ +// Twitch Helix Top-Clips Crawler. Pure: fetch wird via injizierter fetchImpl +// aufgerufen (Tests koennen mocken). Helix-Endpunkt: +// GET https://api.twitch.tv/helix/clips?broadcaster_id=X&first=N +// +// Auth: Client-Credentials (app-token) reicht — kein User-Token noetig. +// Spaeter koennen wir aus token-store den default-Twitch-User-Token nehmen. + +const HELIX_CLIPS_URL = 'https://api.twitch.tv/helix/clips'; + +export interface TopClip { + id: string; + url: string; + embedUrl: string; + broadcasterId: string; + broadcasterName: string; + creatorId: string; + creatorName: string; + videoId: string; + gameId: string; + language: string; + title: string; + viewCount: number; + createdAt: string; // ISO timestamp + thumbnailUrl: string; + duration: number; // seconds + vodOffsetSeconds: number | null; +} + +interface HelixClipRow { + id: string; + url: string; + embed_url: string; + broadcaster_id: string; + broadcaster_name: string; + creator_id: string; + creator_name: string; + video_id: string; + game_id: string; + language: string; + title: string; + view_count: number; + created_at: string; + thumbnail_url: string; + duration: number; + vod_offset?: number | null; +} + +interface HelixClipsResponse { + data?: HelixClipRow[]; + pagination?: { cursor?: string }; +} + +export interface FetchTopClipsOptions { + clientId: string; + accessToken: string; + broadcasterId: string; + startedAt?: string; // ISO RFC3339 + endedAt?: string; + first?: number; // 1-100, default 20 + fetchImpl?: typeof fetch; +} + +function rowToClip(row: HelixClipRow): TopClip { + return { + id: row.id, + url: row.url, + embedUrl: row.embed_url, + broadcasterId: row.broadcaster_id, + broadcasterName: row.broadcaster_name, + creatorId: row.creator_id, + creatorName: row.creator_name, + videoId: row.video_id, + gameId: row.game_id, + language: row.language, + title: row.title, + viewCount: row.view_count, + createdAt: row.created_at, + thumbnailUrl: row.thumbnail_url, + duration: row.duration, + vodOffsetSeconds: row.vod_offset ?? null, + }; +} + +export async function fetchTopClips(opts: FetchTopClipsOptions): Promise { + const fetchFn = opts.fetchImpl ?? fetch; + const first = Math.min(100, Math.max(1, opts.first ?? 20)); + + const params = new URLSearchParams({ + broadcaster_id: opts.broadcasterId, + first: String(first), + }); + if (opts.startedAt) params.set('started_at', opts.startedAt); + if (opts.endedAt) params.set('ended_at', opts.endedAt); + + const res = await fetchFn(`${HELIX_CLIPS_URL}?${params.toString()}`, { + headers: { + 'Authorization': `Bearer ${opts.accessToken}`, + 'Client-Id': opts.clientId, + }, + }); + + const text = await res.text(); + if (!res.ok) { + throw new Error(`top-clips-crawler: helix ${res.status}: ${text}`); + } + let parsed: HelixClipsResponse; + try { + parsed = JSON.parse(text) as HelixClipsResponse; + } catch (e) { + throw new Error(`top-clips-crawler: parse failed: ${e instanceof Error ? e.message : String(e)}`); + } + + const rows = parsed.data ?? []; + // Helix returns clips already sorted by view_count desc, but we re-sort + // defensively in case that order ever changes. + return rows.map(rowToClip).sort((a, b) => b.viewCount - a.viewCount); +} + +export interface DateRange { + startedAt: string; + endedAt: string; +} + +/** + * Convenience: ISO range fuer "letzte N Tage" ab jetzt. Twitch erwartet + * RFC3339 Format (`2026-05-11T00:00:00Z`). + */ +export function rangeLastDays(days: number, now: Date = new Date()): DateRange { + const end = new Date(now.getTime()); + const start = new Date(now.getTime() - days * 24 * 60 * 60 * 1000); + return { + startedAt: start.toISOString(), + endedAt: end.toISOString(), + }; +} diff --git a/src/main/domain/twitch-oauth.test.ts b/src/main/domain/twitch-oauth.test.ts new file mode 100644 index 0000000..6898e2b --- /dev/null +++ b/src/main/domain/twitch-oauth.test.ts @@ -0,0 +1,153 @@ +import { test, expect, describe } from 'vitest'; +import { + startLoginFlow, + awaitAuthorizationCode, + exchangeCodeForToken, + fetchTwitchUserInfo, +} from './twitch-oauth'; +import * as http from 'http'; + +function httpGet(url: string): Promise<{ status: number }> { + return new Promise((resolve, reject) => { + const req = http.get(url, res => { + res.on('data', () => { /* drain */ }); + res.on('end', () => resolve({ status: res.statusCode ?? 0 })); + }); + req.on('error', reject); + }); +} + +describe('startLoginFlow', () => { + test('builds Twitch authorize URL with required params + PKCE + state', async () => { + const flow = await startLoginFlow({ + clientId: 'test-client', + scopes: ['user:read:email', 'channel:read:subscriptions'], + }); + try { + expect(flow.authUrl).toContain('https://id.twitch.tv/oauth2/authorize'); + const url = new URL(flow.authUrl); + expect(url.searchParams.get('client_id')).toBe('test-client'); + expect(url.searchParams.get('response_type')).toBe('code'); + expect(url.searchParams.get('scope')).toBe('user:read:email channel:read:subscriptions'); + expect(url.searchParams.get('state')).toBe(flow.state); + expect(url.searchParams.get('code_challenge')).toBe(flow.pkce.codeChallenge); + expect(url.searchParams.get('code_challenge_method')).toBe('S256'); + expect(url.searchParams.get('redirect_uri')).toMatch(/^http:\/\/127\.0\.0\.1:\d+\/oauth\/callback$/); + } finally { + flow.server.close(); + } + }); +}); + +describe('awaitAuthorizationCode', () => { + test('returns code on successful redirect with matching state', async () => { + const flow = await startLoginFlow({ clientId: 't', scopes: ['user:read:email'] }); + try { + const captureP = awaitAuthorizationCode(flow, 3000); + await httpGet(`${flow.server.url}?code=AUTHCODE&state=${flow.state}`); + const result = await captureP; + expect(result.code).toBe('AUTHCODE'); + expect(result.state).toBe(flow.state); + } finally { + flow.server.close(); + } + }); + + test('rejects on state mismatch (CSRF protection)', async () => { + const flow = await startLoginFlow({ clientId: 't', scopes: ['user:read:email'] }); + try { + // .catch fangt unhandled rejection ab — wir pruefen den Error manuell. + const captureP = awaitAuthorizationCode(flow, 3000).catch((e: Error) => e); + await httpGet(`${flow.server.url}?code=AUTHCODE&state=WRONG_STATE`); + const err = await captureP; + expect(err).toBeInstanceOf(Error); + expect((err as Error).message).toMatch(/state mismatch/); + } finally { + flow.server.close(); + } + }); + + test('rejects on error parameter', async () => { + const flow = await startLoginFlow({ clientId: 't', scopes: ['user:read:email'] }); + try { + const captureP = awaitAuthorizationCode(flow, 3000).catch((e: Error) => e); + await httpGet(`${flow.server.url}?error=access_denied&error_description=user+denied`); + const err = await captureP; + expect(err).toBeInstanceOf(Error); + expect((err as Error).message).toMatch(/access_denied/); + } finally { + flow.server.close(); + } + }); + + test('rejects on missing code', async () => { + const flow = await startLoginFlow({ clientId: 't', scopes: ['user:read:email'] }); + try { + const captureP = awaitAuthorizationCode(flow, 3000).catch((e: Error) => e); + await httpGet(`${flow.server.url}?state=${flow.state}`); + const err = await captureP; + expect(err).toBeInstanceOf(Error); + expect((err as Error).message).toMatch(/missing code/); + } finally { + flow.server.close(); + } + }); +}); + +describe('exchangeCodeForToken', () => { + test('POSTs correct body and returns parsed token', async () => { + let capturedBody: string | null = null; + const fakeFetch = async (_url: string | URL | Request, init?: RequestInit): Promise => { + capturedBody = init?.body as string; + return new Response(JSON.stringify({ + access_token: 'ACC', + refresh_token: 'REF', + expires_in: 14400, + scope: ['user:read:email'], + token_type: 'bearer', + }), { status: 200, headers: { 'Content-Type': 'application/json' } }); + }; + const token = await exchangeCodeForToken({ + clientId: 'cid', code: 'CODE', codeVerifier: 'VERIFIER', + redirectUri: 'http://127.0.0.1:5555/oauth/callback', + fetchImpl: fakeFetch as unknown as typeof fetch, + }); + expect(token.access_token).toBe('ACC'); + expect(token.refresh_token).toBe('REF'); + expect(capturedBody).toContain('client_id=cid'); + expect(capturedBody).toContain('code=CODE'); + expect(capturedBody).toContain('code_verifier=VERIFIER'); + expect(capturedBody).toContain('grant_type=authorization_code'); + }); + + test('throws on non-2xx response', async () => { + const fakeFetch = async (): Promise => new Response('bad request', { status: 400 }); + await expect(exchangeCodeForToken({ + clientId: 'cid', code: 'X', codeVerifier: 'V', redirectUri: 'http://x', + fetchImpl: fakeFetch as unknown as typeof fetch, + })).rejects.toThrow(/400/); + }); +}); + +describe('fetchTwitchUserInfo', () => { + test('returns first user from helix /users response', async () => { + const fakeFetch = async (url: string | URL | Request, init?: RequestInit): Promise => { + const headers = init?.headers as Record; + expect(headers['Authorization']).toBe('Bearer TOKEN'); + expect(headers['Client-Id']).toBe('CID'); + return new Response(JSON.stringify({ + data: [{ id: '12345', login: 'alice', display_name: 'Alice' }], + }), { status: 200 }); + }; + const user = await fetchTwitchUserInfo('TOKEN', 'CID', fakeFetch as unknown as typeof fetch); + expect(user.id).toBe('12345'); + expect(user.login).toBe('alice'); + expect(user.display_name).toBe('Alice'); + }); + + test('throws when no user in response', async () => { + const fakeFetch = async (): Promise => new Response(JSON.stringify({ data: [] }), { status: 200 }); + await expect(fetchTwitchUserInfo('T', 'C', fakeFetch as unknown as typeof fetch)) + .rejects.toThrow(/no user/); + }); +}); diff --git a/src/main/domain/twitch-oauth.ts b/src/main/domain/twitch-oauth.ts new file mode 100644 index 0000000..85979cf --- /dev/null +++ b/src/main/domain/twitch-oauth.ts @@ -0,0 +1,162 @@ +import { createPkcePair, generateState, type PkcePair } from './pkce'; +import { startLoopbackServer, type LoopbackServer } from '../infra/loopback-server'; + +/** + * Twitch OAuth 2.1 Authorization Code Flow + PKCE. + * + * Twitch supports PKCE since ~2022. Endpoints: + * Authorize: https://id.twitch.tv/oauth2/authorize + * Token: https://id.twitch.tv/oauth2/token + * Validate: https://id.twitch.tv/oauth2/validate + * Helix /users (whoami): https://api.twitch.tv/helix/users + * + * Flow: + * 1. startLoginFlow({clientId, scopes}) → { authUrl, ... } + * 2. shell.openExternal(authUrl) im Caller (main.ts hat shell) + * 3. await completeLoginFlow(state) → wartet auf Loopback-Redirect + * 4. Exchange code+verifier gegen token via fetch + * 5. Helix /users mit Bearer-Token → twitch_user_id + login + display_name + * + * Plan 03b liefert NUR Module + Tests. Eigentlicher login-flow IPC handler + * + Renderer-Button kommt in Folgeplan, weil das Twitch-Account-Setup + * (Client-ID in Twitch Dev Console mit korrektem Redirect-URI) erst + * vorbereitet werden muss. + */ + +const TWITCH_AUTHORIZE_URL = 'https://id.twitch.tv/oauth2/authorize'; +const TWITCH_TOKEN_URL = 'https://id.twitch.tv/oauth2/token'; +const TWITCH_HELIX_USERS_URL = 'https://api.twitch.tv/helix/users'; + +export interface TwitchTokenResponse { + access_token: string; + refresh_token: string; + expires_in: number; + scope: string[]; + token_type: 'bearer'; +} + +export interface TwitchUserInfo { + id: string; + login: string; + display_name: string; +} + +export interface LoginStart { + authUrl: string; + state: string; + pkce: PkcePair; + server: LoopbackServer; + redirectUri: string; +} + +export interface LoginStartOptions { + clientId: string; + scopes: string[]; + pathPrefix?: string; // default '/oauth/callback' + port?: number; // 0 = OS-chooses +} + +export async function startLoginFlow(opts: LoginStartOptions): Promise { + const server = await startLoopbackServer({ + pathPrefix: opts.pathPrefix ?? '/oauth/callback', + port: opts.port, + }); + + const pkce = createPkcePair(); + const state = generateState(); + const redirectUri = server.url; + + const params = new URLSearchParams({ + client_id: opts.clientId, + redirect_uri: redirectUri, + response_type: 'code', + scope: opts.scopes.join(' '), + state, + code_challenge: pkce.codeChallenge, + code_challenge_method: pkce.codeChallengeMethod, + force_verify: 'true', + }); + const authUrl = `${TWITCH_AUTHORIZE_URL}?${params.toString()}`; + + return { authUrl, state, pkce, server, redirectUri }; +} + +export interface CompleteLoginResult { + code: string; + state: string; +} + +/** + * Wartet auf Redirect-Capture und prueft state. + * Throws bei mismatch state, bei `?error=` Parameter, oder bei Timeout. + */ +export async function awaitAuthorizationCode(login: LoginStart, timeoutMs?: number): Promise { + const params = await login.server.awaitParams({ timeoutMs }); + if (params.has('error')) { + const err = params.get('error') ?? 'unknown_error'; + const desc = params.get('error_description') ?? ''; + throw new Error(`twitch-oauth: provider error: ${err}${desc ? ` — ${desc}` : ''}`); + } + const returnedState = params.get('state') ?? ''; + if (returnedState !== login.state) { + throw new Error('twitch-oauth: state mismatch (possible CSRF or stale flow)'); + } + const code = params.get('code'); + if (!code) { + throw new Error('twitch-oauth: missing code parameter'); + } + return { code, state: returnedState }; +} + +export interface TokenExchangeOptions { + clientId: string; + code: string; + codeVerifier: string; + redirectUri: string; + fetchImpl?: typeof fetch; +} + +export async function exchangeCodeForToken(opts: TokenExchangeOptions): Promise { + const fetchFn = opts.fetchImpl ?? fetch; + const body = new URLSearchParams({ + client_id: opts.clientId, + code: opts.code, + code_verifier: opts.codeVerifier, + grant_type: 'authorization_code', + redirect_uri: opts.redirectUri, + }); + + const res = await fetchFn(TWITCH_TOKEN_URL, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: body.toString(), + }); + + const text = await res.text(); + if (!res.ok) { + throw new Error(`twitch-oauth: token endpoint ${res.status}: ${text}`); + } + return JSON.parse(text) as TwitchTokenResponse; +} + +export async function fetchTwitchUserInfo( + accessToken: string, + clientId: string, + fetchImpl?: typeof fetch +): Promise { + const fetchFn = fetchImpl ?? fetch; + const res = await fetchFn(TWITCH_HELIX_USERS_URL, { + headers: { + 'Authorization': `Bearer ${accessToken}`, + 'Client-Id': clientId, + }, + }); + const text = await res.text(); + if (!res.ok) { + throw new Error(`twitch-oauth: helix /users ${res.status}: ${text}`); + } + const json = JSON.parse(text) as { data?: TwitchUserInfo[] }; + const first = json.data?.[0]; + if (!first) throw new Error('twitch-oauth: helix /users returned no user'); + return first; +} diff --git a/src/main/domain/update-version-utils.test.ts b/src/main/domain/update-version-utils.test.ts new file mode 100644 index 0000000..fa8d91f --- /dev/null +++ b/src/main/domain/update-version-utils.test.ts @@ -0,0 +1,66 @@ +import { test, expect, describe } from 'vitest'; +import { + normalizeUpdateVersion, + compareUpdateVersions, + isNewerUpdateVersion, +} from './update-version-utils'; + +describe('normalizeUpdateVersion', () => { + test('strips v-prefix lowercase', () => { + expect(normalizeUpdateVersion('v1.2.3')).toBe('1.2.3'); + }); + test('strips V-prefix uppercase', () => { + expect(normalizeUpdateVersion('V1.2.3')).toBe('1.2.3'); + }); + test('trims whitespace', () => { + expect(normalizeUpdateVersion(' 1.2.3 ')).toBe('1.2.3'); + }); + test('handles null and undefined as empty string', () => { + expect(normalizeUpdateVersion(null)).toBe(''); + expect(normalizeUpdateVersion(undefined)).toBe(''); + }); + test('passes plain version unchanged', () => { + expect(normalizeUpdateVersion('1.0.1')).toBe('1.0.1'); + }); +}); + +describe('compareUpdateVersions', () => { + test('older < newer in same minor', () => { + expect(compareUpdateVersions('1.0.1', '1.0.2')).toBeLessThan(0); + }); + test('newer > older in same minor', () => { + expect(compareUpdateVersions('1.0.2', '1.0.1')).toBeGreaterThan(0); + }); + test('equal versions return 0', () => { + expect(compareUpdateVersions('1.0.1', '1.0.1')).toBe(0); + }); + test('v-prefix is normalized away', () => { + expect(compareUpdateVersions('v1.0.1', '1.0.1')).toBe(0); + }); + test('extra trailing part is newer', () => { + expect(compareUpdateVersions('1.0.1', '1.0.1.1')).toBeLessThan(0); + }); + test('major bump wins', () => { + expect(compareUpdateVersions('2.0.0', '1.99.99')).toBeGreaterThan(0); + }); + test('null versions sort lowest', () => { + expect(compareUpdateVersions(null, '1.0.0')).toBeLessThan(0); + expect(compareUpdateVersions('1.0.0', null)).toBeGreaterThan(0); + }); + test('both null returns 0', () => { + expect(compareUpdateVersions(null, null)).toBe(0); + expect(compareUpdateVersions('', '')).toBe(0); + }); +}); + +describe('isNewerUpdateVersion', () => { + test('strictly newer returns true', () => { + expect(isNewerUpdateVersion('1.0.2', '1.0.1')).toBe(true); + }); + test('equal returns false', () => { + expect(isNewerUpdateVersion('1.0.1', '1.0.1')).toBe(false); + }); + test('older returns false', () => { + expect(isNewerUpdateVersion('1.0.1', '1.0.2')).toBe(false); + }); +}); diff --git a/src/main/domain/update-version-utils.ts b/src/main/domain/update-version-utils.ts new file mode 100644 index 0000000..885ec35 --- /dev/null +++ b/src/main/domain/update-version-utils.ts @@ -0,0 +1,34 @@ +export function normalizeUpdateVersion(version: string | null | undefined): string { + return (version || '').trim().replace(/^v/i, ''); +} + +function parseVersionPart(part: string): number { + const numeric = Number(part.replace(/[^0-9].*$/, '')); + return Number.isFinite(numeric) ? numeric : 0; +} + +export function compareUpdateVersions(left: string | null | undefined, right: string | null | undefined): number { + const a = normalizeUpdateVersion(left); + const b = normalizeUpdateVersion(right); + + if (!a && !b) return 0; + if (!a) return -1; + if (!b) return 1; + + const aParts = a.split('.').map(parseVersionPart); + const bParts = b.split('.').map(parseVersionPart); + const maxLength = Math.max(aParts.length, bParts.length); + + for (let i = 0; i < maxLength; i += 1) { + const av = aParts[i] || 0; + const bv = bParts[i] || 0; + if (av > bv) return 1; + if (av < bv) return -1; + } + + return 0; +} + +export function isNewerUpdateVersion(candidate: string | null | undefined, baseline: string | null | undefined): boolean { + return compareUpdateVersions(candidate, baseline) > 0; +} diff --git a/src/main/index.ts b/src/main/index.ts new file mode 100644 index 0000000..7f8ffae --- /dev/null +++ b/src/main/index.ts @@ -0,0 +1,3 @@ +// Stammverzeichnis fuer das v5-Architektur-Refactoring. +// Plan 04 macht daraus den Entry-Point statt src/main.ts. +export {}; diff --git a/src/main/infra/.gitkeep b/src/main/infra/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/src/main/infra/chunk-hash.test.ts b/src/main/infra/chunk-hash.test.ts new file mode 100644 index 0000000..b557b07 --- /dev/null +++ b/src/main/infra/chunk-hash.test.ts @@ -0,0 +1,67 @@ +import { test, expect, describe, beforeEach, afterEach } from 'vitest'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { hashBuffer, hashFile } from './chunk-hash'; + +let tmpDir: string; +beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'chunkhash-')); +}); +afterEach(() => { + try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch { /* ignore */ } +}); + +describe('hashBuffer', () => { + test('"hello" sha1', () => { + expect(hashBuffer(Buffer.from('hello', 'utf-8'))) + .toBe('aaf4c61ddcc5e8a2dabede0f3b482cd9aea9434d'); + }); + + test('empty buffer sha1', () => { + expect(hashBuffer(Buffer.alloc(0))) + .toBe('da39a3ee5e6b4b0d3255bfef95601890afd80709'); + }); + + test('large buffer hashes deterministically', () => { + const big = Buffer.alloc(1024 * 1024, 0x42); // 1MB of 'B' bytes + const a = hashBuffer(big); + const b = hashBuffer(big); + expect(a).toBe(b); + expect(a).toHaveLength(40); // sha1 = 40 hex chars + }); + + test('different content produces different hashes', () => { + expect(hashBuffer(Buffer.from('a'))).not.toBe(hashBuffer(Buffer.from('b'))); + }); +}); + +describe('hashFile', () => { + test('file hash matches buffer hash for same content', async () => { + const content = 'roundtrip-test-payload'; + const filePath = path.join(tmpDir, 'a.bin'); + fs.writeFileSync(filePath, content, 'utf-8'); + const fileHash = await hashFile(filePath); + const bufHash = hashBuffer(Buffer.from(content, 'utf-8')); + expect(fileHash).toBe(bufHash); + }); + + test('empty file = empty-buffer sha1', async () => { + const filePath = path.join(tmpDir, 'empty.bin'); + fs.writeFileSync(filePath, ''); + const fileHash = await hashFile(filePath); + expect(fileHash).toBe('da39a3ee5e6b4b0d3255bfef95601890afd80709'); + }); + + test('large file (4MB) hashes correctly', async () => { + const filePath = path.join(tmpDir, 'big.bin'); + const payload = Buffer.alloc(4 * 1024 * 1024, 0x55); + fs.writeFileSync(filePath, payload); + const fileHash = await hashFile(filePath); + expect(fileHash).toBe(hashBuffer(payload)); + }); + + test('missing file rejects', async () => { + await expect(hashFile(path.join(tmpDir, 'does-not-exist'))).rejects.toThrow(); + }); +}); diff --git a/src/main/infra/chunk-hash.ts b/src/main/infra/chunk-hash.ts new file mode 100644 index 0000000..8ddf1c4 --- /dev/null +++ b/src/main/infra/chunk-hash.ts @@ -0,0 +1,26 @@ +import * as crypto from 'crypto'; +import * as fs from 'fs'; + +export function hashBuffer(b: Buffer): string { + return crypto.createHash('sha1').update(b).digest('hex'); +} + +/** + * Streaming sha1-Hash einer Datei. Async, damit grosse Recorded-Segments + * (oft mehrere MB) nicht den Event-Loop blockieren. + */ +export function hashFile(filePath: string): Promise { + return new Promise((resolve, reject) => { + const hash = crypto.createHash('sha1'); + const stream = fs.createReadStream(filePath); + stream.on('error', reject); + stream.on('data', (chunk: Buffer | string) => { + if (typeof chunk === 'string') { + hash.update(chunk, 'utf-8'); + } else { + hash.update(chunk); + } + }); + stream.on('end', () => resolve(hash.digest('hex'))); + }); +} diff --git a/src/main/infra/db.test.ts b/src/main/infra/db.test.ts new file mode 100644 index 0000000..544a8c1 --- /dev/null +++ b/src/main/infra/db.test.ts @@ -0,0 +1,137 @@ +import { test, expect, describe, beforeEach, afterEach } from 'vitest'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { openDatabase, type DbHandle } from './db'; + +let tmpDir: string; +let db: DbHandle | null = null; +beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'db-test-')); +}); +afterEach(() => { + try { db?.close(); } catch { /* ignore */ } + db = null; + try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch { /* ignore */ } +}); + +describe('openDatabase', () => { + test('creates a new file', () => { + const target = path.join(tmpDir, 'a.db'); + db = openDatabase(target); + expect(fs.existsSync(target)).toBe(true); + expect(typeof db.run).toBe('function'); + expect(typeof db.get).toBe('function'); + expect(typeof db.all).toBe('function'); + expect(typeof db.close).toBe('function'); + expect(typeof db.transaction).toBe('function'); + expect(typeof db.runBatch).toBe('function'); + }); + + test('schema_meta row exists with schema_version=5', () => { + db = openDatabase(path.join(tmpDir, 'b.db')); + const row = db.get<{ value: string }>('SELECT value FROM schema_meta WHERE key = ?', ['schema_version']); + expect(row?.value).toBe('5'); + }); + + test('WAL mode active', () => { + db = openDatabase(path.join(tmpDir, 'c.db')); + const row = db.get<{ journal_mode: string }>('PRAGMA journal_mode'); + expect(row?.journal_mode).toBe('wal'); + }); + + test('idempotent open: existing file keeps schema_version=5', () => { + const target = path.join(tmpDir, 'd.db'); + db = openDatabase(target); + db.close(); + db = openDatabase(target); + const row = db.get<{ value: string }>('SELECT value FROM schema_meta WHERE key = ?', ['schema_version']); + expect(row?.value).toBe('5'); + }); + + test('run + get + all roundtrip on downloaded_vods', () => { + db = openDatabase(path.join(tmpDir, 'e.db')); + db.run('INSERT INTO downloaded_vods(vod_id) VALUES (?)', ['1234']); + db.run('INSERT INTO downloaded_vods(vod_id) VALUES (?)', ['5678']); + const one = db.get<{ vod_id: string }>('SELECT vod_id FROM downloaded_vods WHERE vod_id = ?', ['1234']); + expect(one?.vod_id).toBe('1234'); + const all = db.all<{ vod_id: string }>('SELECT vod_id FROM downloaded_vods ORDER BY vod_id'); + expect(all.map(r => r.vod_id)).toEqual(['1234', '5678']); + }); + + test('transaction commits as bracket', () => { + db = openDatabase(path.join(tmpDir, 'f.db')); + const handle = db; + const inserted = handle.transaction(() => { + handle.run('INSERT INTO downloaded_vods(vod_id) VALUES (?)', ['t1']); + handle.run('INSERT INTO downloaded_vods(vod_id) VALUES (?)', ['t2']); + return 2; + }); + expect(inserted).toBe(2); + const c = handle.get<{ c: number }>('SELECT COUNT(*) AS c FROM downloaded_vods'); + expect(c?.c).toBe(2); + }); + + test('chunk_index table accepts insert + UNIQUE(item_id, chunk_seq)', () => { + db = openDatabase(path.join(tmpDir, 'chunk.db')); + db.run( + 'INSERT INTO chunk_index(item_id, chunk_seq, sha1_hex, bytes) VALUES (?, ?, ?, ?)', + ['item1', 0, 'abc123', 1024] + ); + const handle = db; + expect(() => { + handle.run( + 'INSERT INTO chunk_index(item_id, chunk_seq, sha1_hex, bytes) VALUES (?, ?, ?, ?)', + ['item1', 0, 'different', 2048] + ); + }).toThrow(); // UNIQUE violation + const rows = handle.all<{ sha1_hex: string }>('SELECT sha1_hex FROM chunk_index WHERE item_id = ?', ['item1']); + expect(rows).toHaveLength(1); + expect(rows[0].sha1_hex).toBe('abc123'); + }); + + test('oauth_accounts table exists and accepts insert', () => { + db = openDatabase(path.join(tmpDir, 'oauth.db')); + db.run( + `INSERT INTO oauth_accounts(provider, twitch_user_id, login, encrypted_access_token) + VALUES (?, ?, ?, ?)`, + ['twitch', 'user-123', 'alice', 'ciphertext-blob'] + ); + const row = db.get<{ login: string; provider: string }>( + 'SELECT login, provider FROM oauth_accounts WHERE twitch_user_id = ?', + ['user-123'] + ); + expect(row?.login).toBe('alice'); + expect(row?.provider).toBe('twitch'); + }); + + test('oauth_accounts UNIQUE(provider, twitch_user_id) enforced', () => { + db = openDatabase(path.join(tmpDir, 'oauth-unique.db')); + db.run( + `INSERT INTO oauth_accounts(provider, twitch_user_id, login, encrypted_access_token) + VALUES (?, ?, ?, ?)`, + ['twitch', 'u1', 'a', 'x'] + ); + const handle = db; + expect(() => { + handle.run( + `INSERT INTO oauth_accounts(provider, twitch_user_id, login, encrypted_access_token) + VALUES (?, ?, ?, ?)`, + ['twitch', 'u1', 'b', 'y'] + ); + }).toThrow(); + }); + + test('transaction rolls back on throw', () => { + db = openDatabase(path.join(tmpDir, 'g.db')); + const handle = db; + expect(() => { + handle.transaction(() => { + handle.run('INSERT INTO downloaded_vods(vod_id) VALUES (?)', ['x1']); + throw new Error('boom'); + }); + }).toThrow('boom'); + const c = handle.get<{ c: number }>('SELECT COUNT(*) AS c FROM downloaded_vods'); + expect(c?.c).toBe(0); + }); +}); diff --git a/src/main/infra/db.ts b/src/main/infra/db.ts new file mode 100644 index 0000000..4b75a49 --- /dev/null +++ b/src/main/infra/db.ts @@ -0,0 +1,60 @@ +import Database, { type Database as DatabaseT } from 'better-sqlite3'; +import { SCHEMA_V5_SQL } from './schema-v5'; + +/** + * Public DB-Handle. Schmaler Wrapper um better-sqlite3. + */ +export interface DbHandle { + run(sql: string, params?: unknown[]): void; + get(sql: string, params?: unknown[]): T | undefined; + all(sql: string, params?: unknown[]): T[]; + transaction(fn: () => R): R; + runBatch(sql: string): void; + close(): void; + readonly raw: DatabaseT; +} + +function splitStatements(sql: string): string[] { + return sql + .split(';') + .map(s => s.trim()) + .filter(s => s.length > 0); +} + +function runMultiStatement(db: DatabaseT, sql: string): void { + for (const stmt of splitStatements(sql)) { + db.prepare(stmt).run(); + } +} + +export function openDatabase(filePath: string): DbHandle { + const db = new Database(filePath); + db.pragma('journal_mode = WAL'); + db.pragma('busy_timeout = 5000'); + db.pragma('foreign_keys = ON'); + + runMultiStatement(db, SCHEMA_V5_SQL); + + const handle: DbHandle = { + run(sql, params) { + db.prepare(sql).run(...(params ?? []) as unknown[]); + }, + get(sql: string, params?: unknown[]): T | undefined { + return db.prepare(sql).get(...(params ?? []) as unknown[]) as T | undefined; + }, + all(sql: string, params?: unknown[]): T[] { + return db.prepare(sql).all(...(params ?? []) as unknown[]) as T[]; + }, + transaction(fn: () => R): R { + return db.transaction(fn)(); + }, + runBatch(sql) { + runMultiStatement(db, sql); + }, + close() { + db.close(); + }, + get raw() { return db; }, + }; + return handle; +} diff --git a/src/main/infra/duration.test.ts b/src/main/infra/duration.test.ts new file mode 100644 index 0000000..f776ff7 --- /dev/null +++ b/src/main/infra/duration.test.ts @@ -0,0 +1,65 @@ +import { test, expect, describe } from 'vitest'; +import { parseDuration, formatDuration, formatDurationDashed } from './duration'; + +describe('parseDuration', () => { + test('1h2m3s = 3723', () => { + expect(parseDuration('1h2m3s')).toBe(3723); + }); + test('45m = 2700', () => { + expect(parseDuration('45m')).toBe(2700); + }); + test('10s = 10', () => { + expect(parseDuration('10s')).toBe(10); + }); + test('empty string = 0', () => { + expect(parseDuration('')).toBe(0); + }); + test('unknown format = 0', () => { + expect(parseDuration('abcdef')).toBe(0); + }); + test('partial 2h = 7200', () => { + expect(parseDuration('2h')).toBe(7200); + }); + test('h and s without m = 3601', () => { + expect(parseDuration('1h1s')).toBe(3601); + }); +}); + +describe('formatDuration', () => { + test('3723 = 01:02:03', () => { + expect(formatDuration(3723)).toBe('01:02:03'); + }); + test('0 = 00:00:00', () => { + expect(formatDuration(0)).toBe('00:00:00'); + }); + test('negative = 00:00:00', () => { + expect(formatDuration(-1)).toBe('00:00:00'); + }); + test('Infinity = 00:00:00', () => { + expect(formatDuration(Infinity)).toBe('00:00:00'); + }); + test('NaN = 00:00:00', () => { + expect(formatDuration(NaN)).toBe('00:00:00'); + }); + test('3600 = 01:00:00', () => { + expect(formatDuration(3600)).toBe('01:00:00'); + }); + test('86399 = 23:59:59', () => { + expect(formatDuration(86399)).toBe('23:59:59'); + }); + test('fractional seconds floored', () => { + expect(formatDuration(3723.9)).toBe('01:02:03'); + }); +}); + +describe('formatDurationDashed', () => { + test('3723 = 01-02-03', () => { + expect(formatDurationDashed(3723)).toBe('01-02-03'); + }); + test('negative = 00-00-00', () => { + expect(formatDurationDashed(-1)).toBe('00-00-00'); + }); + test('NaN = 00-00-00', () => { + expect(formatDurationDashed(NaN)).toBe('00-00-00'); + }); +}); diff --git a/src/main/infra/duration.ts b/src/main/infra/duration.ts new file mode 100644 index 0000000..e2aa5ec --- /dev/null +++ b/src/main/infra/duration.ts @@ -0,0 +1,28 @@ +export function parseDuration(duration: string): number { + let seconds = 0; + const hours = duration.match(/(\d+)h/); + const minutes = duration.match(/(\d+)m/); + const secs = duration.match(/(\d+)s/); + + if (hours) seconds += parseInt(hours[1]) * 3600; + if (minutes) seconds += parseInt(minutes[1]) * 60; + if (secs) seconds += parseInt(secs[1]); + + return seconds; +} + +export function formatDuration(seconds: number): string { + if (!isFinite(seconds) || seconds < 0) return '00:00:00'; + const h = Math.floor(seconds / 3600); + const m = Math.floor((seconds % 3600) / 60); + const s = Math.floor(seconds % 60); + return `${h.toString().padStart(2, '0')}:${m.toString().padStart(2, '0')}:${s.toString().padStart(2, '0')}`; +} + +export function formatDurationDashed(seconds: number): string { + if (!isFinite(seconds) || seconds < 0) return '00-00-00'; + const h = Math.floor(seconds / 3600); + const m = Math.floor((seconds % 3600) / 60); + const s = Math.floor(seconds % 60); + return `${h.toString().padStart(2, '0')}-${m.toString().padStart(2, '0')}-${s.toString().padStart(2, '0')}`; +} diff --git a/src/main/infra/format-helpers.test.ts b/src/main/infra/format-helpers.test.ts new file mode 100644 index 0000000..889ab01 --- /dev/null +++ b/src/main/infra/format-helpers.test.ts @@ -0,0 +1,103 @@ +import { test, expect, describe } from 'vitest'; +import { + sanitizeFilenamePart, + formatTwitchDurationFromSeconds, + formatDateWithPattern, + getMergeGroupPhaseText, +} from './format-helpers'; + +describe('sanitizeFilenamePart', () => { + test('replaces Windows-invalid chars with underscore', () => { + expect(sanitizeFilenamePart('ac:d"e|f?g*h')).toBe('a_b_c_d_e_f_g_h'); + }); + test('replaces path separators', () => { + expect(sanitizeFilenamePart('a/b\\c')).toBe('a_b_c'); + }); + test('strips control chars', () => { + expect(sanitizeFilenamePart('a\x00b\x1fc')).toBe('a_b_c'); + }); + test('trims whitespace', () => { + expect(sanitizeFilenamePart(' hi ')).toBe('hi'); + }); + test('empty falls back to default', () => { + expect(sanitizeFilenamePart('')).toBe('unnamed'); + }); + test('custom fallback', () => { + expect(sanitizeFilenamePart('', 'FB')).toBe('FB'); + }); + test('only-invalid-chars falls back', () => { + expect(sanitizeFilenamePart('////').trim()).not.toBe(''); + // '////' becomes '____' which is non-empty, so no fallback + expect(sanitizeFilenamePart('////')).toBe('____'); + }); +}); + +describe('formatTwitchDurationFromSeconds', () => { + test('0 = 0s', () => { + expect(formatTwitchDurationFromSeconds(0)).toBe('0s'); + }); + test('45 = 45s', () => { + expect(formatTwitchDurationFromSeconds(45)).toBe('45s'); + }); + test('65 = 1m5s', () => { + expect(formatTwitchDurationFromSeconds(65)).toBe('1m5s'); + }); + test('3725 = 1h2m5s', () => { + expect(formatTwitchDurationFromSeconds(3725)).toBe('1h2m5s'); + }); + test('3600 = 1h0m0s', () => { + expect(formatTwitchDurationFromSeconds(3600)).toBe('1h0m0s'); + }); + test('negative clamped to 0', () => { + expect(formatTwitchDurationFromSeconds(-5)).toBe('0s'); + }); + test('NaN clamped to 0', () => { + expect(formatTwitchDurationFromSeconds(NaN)).toBe('0s'); + }); + test('Infinity clamped to 0', () => { + expect(formatTwitchDurationFromSeconds(Infinity)).toBe('0s'); + }); +}); + +describe('formatDateWithPattern', () => { + const d = new Date(2026, 4, 11, 23, 5, 7); // 2026-05-11 23:05:07 + + test('yyyy-MM-dd', () => { + expect(formatDateWithPattern(d, 'yyyy-MM-dd')).toBe('2026-05-11'); + }); + test('yy MM dd', () => { + expect(formatDateWithPattern(d, 'yy/MM/dd')).toBe('26/05/11'); + }); + test('HH:mm:ss', () => { + expect(formatDateWithPattern(d, 'HH:mm:ss')).toBe('23:05:07'); + }); + test('combined pattern', () => { + expect(formatDateWithPattern(d, 'yyyy-MM-dd_HH-mm-ss')).toBe('2026-05-11_23-05-07'); + }); + test('backslashes are stripped after token substitution', () => { + // Note: \ does NOT escape the date-token (no negative-lookbehind in regex). + // It only removes the literal backslash from the output. So 'yyyy\\X' → 'YYYYX'. + expect(formatDateWithPattern(d, 'yyyy\\X')).toBe('2026X'); + }); +}); + +describe('getMergeGroupPhaseText', () => { + test('known DE phases', () => { + expect(getMergeGroupPhaseText('downloading', 'de')).toBe('VOD wird heruntergeladen'); + expect(getMergeGroupPhaseText('merging', 'de')).toBe('Zusammenfugen...'); + expect(getMergeGroupPhaseText('splitting', 'de')).toBe('Part wird erstellt'); + expect(getMergeGroupPhaseText('cleanup', 'de')).toBe('Aufraumen...'); + }); + test('known EN phases', () => { + expect(getMergeGroupPhaseText('downloading', 'en')).toBe('Downloading VOD'); + expect(getMergeGroupPhaseText('merging', 'en')).toBe('Merging...'); + expect(getMergeGroupPhaseText('splitting', 'en')).toBe('Splitting Part'); + expect(getMergeGroupPhaseText('cleanup', 'en')).toBe('Cleaning up...'); + }); + test('unknown phase passes through', () => { + expect(getMergeGroupPhaseText('unknown', 'de')).toBe('unknown'); + }); + test('unknown language falls back to DE', () => { + expect(getMergeGroupPhaseText('downloading', 'fr')).toBe('VOD wird heruntergeladen'); + }); +}); diff --git a/src/main/infra/format-helpers.ts b/src/main/infra/format-helpers.ts new file mode 100644 index 0000000..13259fd --- /dev/null +++ b/src/main/infra/format-helpers.ts @@ -0,0 +1,78 @@ +// Pure-Format-Helpers, extrahiert aus main.ts. Keine Globals, keine I/O. + +const FILENAME_INVALID_RE = /[<>:"|?*\x00-\x1f]/g; +const FILENAME_PATH_SEP_RE = /[\\/]/g; + +/** + * Entfernt Windows-Filesystem-verbotene Zeichen und Pfad-Separatoren aus einem + * Datei-Namen-Teilstring. Fallback wird zurueckgegeben, wenn nach Cleanup + * nichts uebrig bleibt. + */ +export function sanitizeFilenamePart(input: string, fallback = 'unnamed'): string { + const cleaned = (input || '') + .replace(FILENAME_INVALID_RE, '_') + .replace(FILENAME_PATH_SEP_RE, '_') + .trim(); + return cleaned || fallback; +} + +/** + * Twitch-Style Duration-Format: `1h2m3s`, `2m5s`, `42s`. Negative oder + * NaN-Inputs werden auf 0 geclamt. + */ +export function formatTwitchDurationFromSeconds(totalSeconds: number): string { + const seconds = Math.max(0, Math.floor(Number.isFinite(totalSeconds) ? totalSeconds : 0)); + const h = Math.floor(seconds / 3600); + const m = Math.floor((seconds % 3600) / 60); + const s = seconds % 60; + + if (h > 0) return `${h}h${m}m${s}s`; + if (m > 0) return `${m}m${s}s`; + return `${s}s`; +} + +const DATE_TOKEN_RE = /yyyy|yy|MM|M|dd|d|HH|H|hh|h|mm|m|ss|s/g; + +/** + * Date-Formatter mit Pattern-Tokens (yyyy, yy, MM, M, dd, d, HH, H, hh, h, + * mm, m, ss, s). Backslash-escapes (\T) lassen das Folgezeichen literal. + */ +export function formatDateWithPattern(date: Date, pattern: string): string { + const tokenMap: Record = { + yyyy: date.getFullYear().toString(), + yy: date.getFullYear().toString().slice(-2), + MM: (date.getMonth() + 1).toString().padStart(2, '0'), + M: (date.getMonth() + 1).toString(), + dd: date.getDate().toString().padStart(2, '0'), + d: date.getDate().toString(), + HH: date.getHours().toString().padStart(2, '0'), + H: date.getHours().toString(), + hh: date.getHours().toString().padStart(2, '0'), + h: date.getHours().toString(), + mm: date.getMinutes().toString().padStart(2, '0'), + m: date.getMinutes().toString(), + ss: date.getSeconds().toString().padStart(2, '0'), + s: date.getSeconds().toString(), + }; + + return pattern + .replace(DATE_TOKEN_RE, token => tokenMap[token] ?? token) + .replace(/\\(.)/g, '$1'); +} + +export type MergeGroupLanguage = 'de' | 'en'; + +/** + * Label fuer den aktuellen Merge-Group-Phase-Status. Pure variant — Sprache + * wird vom Caller injiziert. + */ +export function getMergeGroupPhaseText(phase: string, language: MergeGroupLanguage | string): string { + const isEnglish = language === 'en'; + switch (phase) { + case 'downloading': return isEnglish ? 'Downloading VOD' : 'VOD wird heruntergeladen'; + case 'merging': return isEnglish ? 'Merging...' : 'Zusammenfugen...'; + case 'splitting': return isEnglish ? 'Splitting Part' : 'Part wird erstellt'; + case 'cleanup': return isEnglish ? 'Cleaning up...' : 'Aufraumen...'; + default: return phase; + } +} diff --git a/src/main/infra/fs-atomic.test.ts b/src/main/infra/fs-atomic.test.ts new file mode 100644 index 0000000..c75148c --- /dev/null +++ b/src/main/infra/fs-atomic.test.ts @@ -0,0 +1,53 @@ +import { test, expect, describe, beforeEach, afterEach } from 'vitest'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { writeFileAtomicSync } from './fs-atomic'; + +let tmpDir: string; +beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'fsatomic-')); +}); +afterEach(() => { + try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch { /* ignore */ } +}); + +describe('writeFileAtomicSync', () => { + test('writes a string payload', () => { + const target = path.join(tmpDir, 'a.txt'); + writeFileAtomicSync(target, 'hello'); + expect(fs.readFileSync(target, 'utf-8')).toBe('hello'); + }); + + test('writes a buffer payload', () => { + const target = path.join(tmpDir, 'b.bin'); + writeFileAtomicSync(target, Buffer.from([1, 2, 3, 4])); + expect(fs.readFileSync(target)).toEqual(Buffer.from([1, 2, 3, 4])); + }); + + test('overwrites existing file', () => { + const target = path.join(tmpDir, 'c.txt'); + fs.writeFileSync(target, 'old'); + writeFileAtomicSync(target, 'new'); + expect(fs.readFileSync(target, 'utf-8')).toBe('new'); + }); + + test('cleans up tmp file after success', () => { + const target = path.join(tmpDir, 'd.txt'); + writeFileAtomicSync(target, 'x'); + expect(fs.existsSync(target + '.tmp')).toBe(false); + }); + + test('utf-8 multibyte chars roundtrip', () => { + const target = path.join(tmpDir, 'e.txt'); + writeFileAtomicSync(target, 'aeoeue-aeoeue'); + expect(fs.readFileSync(target, 'utf-8')).toBe('aeoeue-aeoeue'); + }); + + test('empty payload writes empty file', () => { + const target = path.join(tmpDir, 'f.txt'); + writeFileAtomicSync(target, ''); + expect(fs.readFileSync(target, 'utf-8')).toBe(''); + expect(fs.statSync(target).size).toBe(0); + }); +}); diff --git a/src/main/infra/fs-atomic.ts b/src/main/infra/fs-atomic.ts new file mode 100644 index 0000000..c761c65 --- /dev/null +++ b/src/main/infra/fs-atomic.ts @@ -0,0 +1,29 @@ +import * as fs from 'fs'; + +/** + * Atomic write via tmp + rename. Survives crash mid-write — either old or + * new content, never partial. Windows fallback: copy + unlink if rename + * fails (e.g. target locked by reader). fsync best-effort. + */ +export function writeFileAtomicSync(targetPath: string, payload: string | Buffer): void { + const buffer = Buffer.isBuffer(payload) ? payload : Buffer.from(payload, 'utf-8'); + const tmpPath = targetPath + '.tmp'; + + let fd: number | null = null; + try { + fd = fs.openSync(tmpPath, 'w'); + fs.writeSync(fd, buffer, 0, buffer.length, 0); + try { fs.fsyncSync(fd); } catch { /* fsync may fail on some FS; rename is still safer than nothing */ } + } finally { + if (fd !== null) { + try { fs.closeSync(fd); } catch { /* ignore */ } + } + } + + try { + fs.renameSync(tmpPath, targetPath); + } catch { + fs.copyFileSync(tmpPath, targetPath); + try { fs.unlinkSync(tmpPath); } catch { /* ignore */ } + } +} diff --git a/src/main/infra/loopback-server.test.ts b/src/main/infra/loopback-server.test.ts new file mode 100644 index 0000000..f175a6a --- /dev/null +++ b/src/main/infra/loopback-server.test.ts @@ -0,0 +1,58 @@ +import { test, expect, describe } from 'vitest'; +import * as http from 'http'; +import { startLoopbackServer } from './loopback-server'; + +function httpGet(url: string): Promise<{ status: number; body: string }> { + return new Promise((resolve, reject) => { + const req = http.get(url, res => { + let body = ''; + res.on('data', chunk => { body += chunk.toString(); }); + res.on('end', () => resolve({ status: res.statusCode ?? 0, body })); + }); + req.on('error', reject); + }); +} + +describe('startLoopbackServer', () => { + test('binds to 127.0.0.1 and returns url with pathPrefix', async () => { + const server = await startLoopbackServer({ pathPrefix: '/cb' }); + expect(server.url).toMatch(/^http:\/\/127\.0\.0\.1:\d+\/cb$/); + server.close(); + }); + + test('captures redirect params (code + state)', async () => { + const server = await startLoopbackServer({ pathPrefix: '/cb' }); + const captureP = server.awaitParams({ timeoutMs: 3000 }); + const response = await httpGet(`${server.url}?code=abc123&state=xyz`); + expect(response.status).toBe(200); + const params = await captureP; + expect(params.get('code')).toBe('abc123'); + expect(params.get('state')).toBe('xyz'); + server.close(); + }); + + test('non-matching path returns 404, capture not triggered', async () => { + const server = await startLoopbackServer({ pathPrefix: '/cb' }); + const captureP = server.awaitParams({ timeoutMs: 500 }); + const response = await httpGet(`${server.url.replace('/cb', '/other')}`); + expect(response.status).toBe(404); + await expect(captureP).rejects.toThrow(/timeout/); + server.close(); + }); + + test('error param renders errorHtml', async () => { + const server = await startLoopbackServer({ pathPrefix: '/cb' }); + const captureP = server.awaitParams({ timeoutMs: 3000 }); + const response = await httpGet(`${server.url}?error=access_denied`); + expect(response.body).toContain('Fehler'); + const params = await captureP; + expect(params.get('error')).toBe('access_denied'); + server.close(); + }); + + test('timeout rejects', async () => { + const server = await startLoopbackServer({ pathPrefix: '/cb' }); + await expect(server.awaitParams({ timeoutMs: 200 })).rejects.toThrow(/timeout/); + server.close(); + }); +}); diff --git a/src/main/infra/loopback-server.ts b/src/main/infra/loopback-server.ts new file mode 100644 index 0000000..67912f1 --- /dev/null +++ b/src/main/infra/loopback-server.ts @@ -0,0 +1,120 @@ +import * as http from 'http'; +import { URL } from 'url'; + +/** + * Ephemerer HTTP-Server auf localhost:PORT fuer OAuth-Redirect-Capture. + * RFC 8252 (OAuth 2.0 for Native Apps) — System-Browser + Loopback-Redirect. + * + * Lifecycle: + * const server = await startLoopbackServer({ pathPrefix: '/oauth/callback' }); + * console.log(server.url); // http://127.0.0.1:54321/oauth/callback + * const params = await server.awaitParams({ timeoutMs: 5 * 60 * 1000 }); + * server.close(); + * + * Bindet immer auf 127.0.0.1 (nicht 0.0.0.0) — der OS-Listener ist nur lokal + * erreichbar, kein Firewall-Prompt unter Windows. + */ + +export interface LoopbackServerOptions { + pathPrefix: string; // z.B. '/oauth/callback' + port?: number; // 0 = OS waehlt freien Port + successHtml?: string; // HTML-Antwort beim Capture + errorHtml?: string; +} + +export interface LoopbackServer { + readonly url: string; + awaitParams(opts?: { timeoutMs?: number }): Promise; + close(): void; +} + +const DEFAULT_SUCCESS = `Login erfolgreich + +

Login erfolgreich

Du kannst dieses Fenster jetzt schliessen.

`; + +const DEFAULT_ERROR = `Fehler + +

Fehler

Login abgebrochen.

`; + +export function startLoopbackServer(opts: LoopbackServerOptions): Promise { + const successHtml = opts.successHtml ?? DEFAULT_SUCCESS; + const errorHtml = opts.errorHtml ?? DEFAULT_ERROR; + const pathPrefix = opts.pathPrefix.startsWith('/') ? opts.pathPrefix : '/' + opts.pathPrefix; + + return new Promise((resolve, reject) => { + let resolveCapture: ((p: URLSearchParams) => void) | null = null; + let rejectCapture: ((e: Error) => void) | null = null; + let captureSettled = false; + + const captureP = new Promise((res, rej) => { + resolveCapture = res; + rejectCapture = rej; + }); + + const server = http.createServer((req, res) => { + try { + const url = new URL(req.url || '/', 'http://127.0.0.1'); + if (!url.pathname.startsWith(pathPrefix)) { + res.writeHead(404, { 'Content-Type': 'text/plain' }); + res.end('not found'); + return; + } + const params = url.searchParams; + const hasError = params.has('error'); + res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' }); + res.end(hasError ? errorHtml : successHtml); + if (!captureSettled && resolveCapture) { + captureSettled = true; + resolveCapture(params); + } + } catch (e) { + res.writeHead(500, { 'Content-Type': 'text/plain' }); + res.end('internal error'); + if (!captureSettled && rejectCapture) { + captureSettled = true; + rejectCapture(e instanceof Error ? e : new Error(String(e))); + } + } + }); + + server.on('error', reject); + server.listen(opts.port ?? 0, '127.0.0.1', () => { + const addr = server.address(); + if (!addr || typeof addr === 'string') { + server.close(); + reject(new Error('loopback-server: failed to determine bound port')); + return; + } + const url = `http://127.0.0.1:${addr.port}${pathPrefix}`; + + resolve({ + url, + async awaitParams(awaitOpts) { + const timeoutMs = awaitOpts?.timeoutMs ?? 5 * 60 * 1000; + let timer: NodeJS.Timeout | null = null; + const timeoutP = new Promise((_, rej) => { + timer = setTimeout(() => { + if (!captureSettled && rejectCapture) { + captureSettled = true; + rejectCapture(new Error('loopback-server: timeout waiting for redirect')); + } + rej(new Error('loopback-server: timeout waiting for redirect')); + }, timeoutMs); + }); + try { + return await Promise.race([captureP, timeoutP]); + } finally { + if (timer) clearTimeout(timer); + } + }, + close() { + try { server.close(); } catch { /* already closed */ } + }, + }); + }); + }); +} diff --git a/src/main/infra/schema-v5.ts b/src/main/infra/schema-v5.ts new file mode 100644 index 0000000..72c1a61 --- /dev/null +++ b/src/main/infra/schema-v5.ts @@ -0,0 +1,104 @@ +// SQLite-Schema v5 fuer Twitch VOD Manager. +// Inline-Konstante damit tsc kein non-TS-Asset kopieren muss. +// Alle Tabellen mit IF NOT EXISTS — Schema-Bootstrap ist idempotent. +// PRAGMA-Statements (WAL etc.) werden separat von db.ts vor dem Bootstrap gesetzt. + +export const SCHEMA_V5_SQL = ` +CREATE TABLE IF NOT EXISTS schema_meta ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL +); + +INSERT OR IGNORE INTO schema_meta(key, value) VALUES ('schema_version', '5'); +INSERT OR IGNORE INTO schema_meta(key, value) VALUES ('created_at', CAST(strftime('%s','now') AS TEXT)); + +CREATE TABLE IF NOT EXISTS config_kv ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL, + updated_at INTEGER NOT NULL DEFAULT (strftime('%s','now')) +); + +CREATE TABLE IF NOT EXISTS queue_items ( + id TEXT PRIMARY KEY, + streamer_login TEXT, + vod_id TEXT, + clip_id TEXT, + title TEXT, + output_path TEXT, + status TEXT NOT NULL, + progress_pct REAL, + error_message TEXT, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + completed_at INTEGER, + payload_json TEXT NOT NULL +); + +CREATE INDEX IF NOT EXISTS idx_queue_status ON queue_items(status); +CREATE INDEX IF NOT EXISTS idx_queue_streamer ON queue_items(streamer_login); +CREATE INDEX IF NOT EXISTS idx_queue_created ON queue_items(created_at); + +CREATE TABLE IF NOT EXISTS downloaded_vods ( + vod_id TEXT PRIMARY KEY, + downloaded_at INTEGER NOT NULL DEFAULT (strftime('%s','now')) +); + +CREATE TABLE IF NOT EXISTS streamers ( + login TEXT PRIMARY KEY, + auto_record INTEGER NOT NULL DEFAULT 0, + auto_vod_download INTEGER NOT NULL DEFAULT 0, + added_at INTEGER NOT NULL DEFAULT (strftime('%s','now')) +); + +CREATE INDEX IF NOT EXISTS idx_streamers_autorec ON streamers(auto_record); +CREATE INDEX IF NOT EXISTS idx_streamers_autodl ON streamers(auto_vod_download); + +CREATE TABLE IF NOT EXISTS archive_files ( + path TEXT PRIMARY KEY, + streamer_login TEXT, + size_bytes INTEGER, + duration_seconds INTEGER, + created_at INTEGER, + verified INTEGER NOT NULL DEFAULT 0 +); + +CREATE INDEX IF NOT EXISTS idx_archive_streamer ON archive_files(streamer_login); + +CREATE TABLE IF NOT EXISTS chunk_index ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + item_id TEXT NOT NULL, + chunk_seq INTEGER NOT NULL, + sha1_hex TEXT NOT NULL, + bytes INTEGER NOT NULL, + created_at INTEGER NOT NULL DEFAULT (strftime('%s','now')), + UNIQUE(item_id, chunk_seq) +); + +CREATE INDEX IF NOT EXISTS idx_chunk_item ON chunk_index(item_id); +CREATE INDEX IF NOT EXISTS idx_chunk_sha1 ON chunk_index(sha1_hex); + +CREATE TABLE IF NOT EXISTS oauth_accounts ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + provider TEXT NOT NULL, + twitch_user_id TEXT, + login TEXT, + display_name TEXT, + encrypted_access_token TEXT NOT NULL, + encrypted_refresh_token TEXT, + expires_at INTEGER, + scopes_json TEXT, + is_default INTEGER NOT NULL DEFAULT 0, + created_at INTEGER NOT NULL DEFAULT (strftime('%s','now')), + updated_at INTEGER NOT NULL DEFAULT (strftime('%s','now')), + UNIQUE(provider, twitch_user_id) +); + +CREATE INDEX IF NOT EXISTS idx_oauth_provider ON oauth_accounts(provider); +CREATE INDEX IF NOT EXISTS idx_oauth_default ON oauth_accounts(is_default); + +CREATE TABLE IF NOT EXISTS migrations_applied ( + name TEXT PRIMARY KEY, + applied_at INTEGER NOT NULL DEFAULT (strftime('%s','now')), + payload TEXT +); +`; diff --git a/src/main/infra/secure-storage.test.ts b/src/main/infra/secure-storage.test.ts new file mode 100644 index 0000000..463e00d --- /dev/null +++ b/src/main/infra/secure-storage.test.ts @@ -0,0 +1,45 @@ +import { test, expect, describe } from 'vitest'; +import { MemorySecureStorage, createElectronSecureStorage, type SecureStorage } from './secure-storage'; + +describe('MemorySecureStorage', () => { + test('isEncryptionAvailable returns false (kennzeichnet Memory-Mode)', () => { + const s: SecureStorage = new MemorySecureStorage(); + expect(s.isEncryptionAvailable()).toBe(false); + }); + + test('roundtrip ascii', () => { + const s = new MemorySecureStorage(); + const cipher = s.encrypt('hello'); + expect(cipher).not.toBe('hello'); // base64-Kodierung greift + expect(s.decrypt(cipher)).toBe('hello'); + }); + + test('roundtrip multi-byte', () => { + const s = new MemorySecureStorage(); + expect(s.decrypt(s.encrypt('aeoeue-test'))).toBe('aeoeue-test'); + }); + + test('roundtrip empty string', () => { + const s = new MemorySecureStorage(); + expect(s.decrypt(s.encrypt(''))).toBe(''); + }); + + test('long token (simuliert OAuth access_token Groesse)', () => { + const s = new MemorySecureStorage(); + const token = 'a'.repeat(256); + expect(s.decrypt(s.encrypt(token))).toBe(token); + }); +}); + +describe('createElectronSecureStorage', () => { + test('is exported as function', () => { + expect(typeof createElectronSecureStorage).toBe('function'); + }); + + test('throws useful error if called outside Electron (vitest env)', () => { + // In vitest (Node-only) ist electron entweder nicht installiert oder hat keine + // app-context-Funktionen. Genaues Error-Wording ist nicht stable, aber Aufruf + // muss throwen statt undefined zurueckgeben. + expect(() => createElectronSecureStorage()).toThrow(); + }); +}); diff --git a/src/main/infra/secure-storage.ts b/src/main/infra/secure-storage.ts new file mode 100644 index 0000000..90dbe4e --- /dev/null +++ b/src/main/infra/secure-storage.ts @@ -0,0 +1,58 @@ +// Verschluesselt String-Payloads im OS-Keystore (Win Credential Manager via +// Electron safeStorage). MemorySecureStorage ist fuer Tests/Headless-Envs — +// gibt plaintext zurueck und meldet isEncryptionAvailable() === false, damit +// Caller das in den Log schreiben oder verweigern koennen. + +export interface SecureStorage { + isEncryptionAvailable(): boolean; + encrypt(plaintext: string): string; + decrypt(ciphertext: string): string; +} + +export class MemorySecureStorage implements SecureStorage { + isEncryptionAvailable(): boolean { + return false; + } + encrypt(plaintext: string): string { + // Base64 als Kennzeichnung — kein Schutz, nur damit `decrypt(encrypt(x)) === x` + // semantisch konsistent ist (kein literal plaintext zwischen den Methoden). + return Buffer.from(plaintext, 'utf-8').toString('base64'); + } + decrypt(ciphertext: string): string { + return Buffer.from(ciphertext, 'base64').toString('utf-8'); + } +} + +interface SafeStorageLike { + isEncryptionAvailable(): boolean; + encryptString(plain: string): Buffer; + decryptString(buf: Buffer): string; +} + +/** + * Wrappt electron.safeStorage. Setzt voraus, dass `app.whenReady()` gefired ist. + * Wird per Lazy-Require konstruiert, sodass Module ausserhalb von Electron + * (zB Tests) das Modul importieren koennen ohne Crash. + */ +export function createElectronSecureStorage(): SecureStorage { + // eslint-disable-next-line @typescript-eslint/no-require-imports + const electron = require('electron'); + const safeStorage = electron?.safeStorage as SafeStorageLike | undefined; + if (!safeStorage) { + throw new Error('Electron safeStorage not available (called before app.whenReady?)'); + } + + return { + isEncryptionAvailable(): boolean { + return safeStorage.isEncryptionAvailable(); + }, + encrypt(plaintext: string): string { + const buf = safeStorage.encryptString(plaintext); + return buf.toString('base64'); + }, + decrypt(ciphertext: string): string { + const buf = Buffer.from(ciphertext, 'base64'); + return safeStorage.decryptString(buf); + }, + }; +} diff --git a/src/preload.ts b/src/preload.ts new file mode 100644 index 0000000..380a3d2 --- /dev/null +++ b/src/preload.ts @@ -0,0 +1,182 @@ +import { contextBridge, ipcRenderer } from 'electron'; +import { CustomClip, MergeGroupItem, MergeGroup, QueueItem, DownloadProgress } from './types'; + +// Types +interface RuntimeMetricsSnapshot { + cacheHits: number; + cacheMisses: number; + duplicateSkips: number; + retriesScheduled: number; + retriesExhausted: number; + integrityFailures: number; + downloadsStarted: number; + downloadsCompleted: number; + downloadsFailed: number; + downloadedBytesTotal: number; + lastSpeedBytesPerSec: number; + avgSpeedBytesPerSec: number; + activeItemId: string | null; + activeItemTitle: string | null; + lastErrorClass: string | null; + lastRetryDelaySeconds: number; + timestamp: string; + queue: { + pending: number; + downloading: number; + paused: number; + completed: number; + error: number; + total: number; + }; + caches: { + loginToUserId: number; + vodList: number; + clipInfo: number; + }; + config: { + performanceMode: 'stability' | 'balanced' | 'speed'; + smartScheduler: boolean; + metadataCacheMinutes: number; + duplicatePrevention: boolean; + }; +} + +interface VideoInfo { + duration: number; + width: number; + height: number; + fps: number; +} + +// Expose protected methods to renderer +contextBridge.exposeInMainWorld('api', { + // Config + getConfig: () => ipcRenderer.invoke('get-config'), + saveConfig: (config: any) => ipcRenderer.invoke('save-config', config), + + // Auth + login: () => ipcRenderer.invoke('login'), + + // Twitch API + getUserId: (username: string) => ipcRenderer.invoke('get-user-id', username), + getVODs: (userId: string, forceRefresh: boolean = false) => ipcRenderer.invoke('get-vods', userId, forceRefresh), + + // Queue + getQueue: () => ipcRenderer.invoke('get-queue'), + addToQueue: (item: Omit) => ipcRenderer.invoke('add-to-queue', item), + startLiveRecording: (streamerName: string) => ipcRenderer.invoke('start-live-recording', streamerName), + removeFromQueue: (id: string) => ipcRenderer.invoke('remove-from-queue', id), + reorderQueue: (orderIds: string[]) => ipcRenderer.invoke('reorder-queue', orderIds), + clearCompleted: () => ipcRenderer.invoke('clear-completed'), + retryFailedDownloads: () => ipcRenderer.invoke('retry-failed-downloads'), + retryQueueItem: (id: string) => ipcRenderer.invoke('retry-queue-item', id), + createMergeGroup: (itemIds: string[]) => ipcRenderer.invoke('create-merge-group', itemIds), + + // Download + startDownload: () => ipcRenderer.invoke('start-download'), + pauseDownload: () => ipcRenderer.invoke('pause-download'), + cancelDownload: () => ipcRenderer.invoke('cancel-download'), + isDownloading: () => ipcRenderer.invoke('is-downloading'), + downloadClip: (url: string) => ipcRenderer.invoke('download-clip', url), + + // Files + selectFolder: () => ipcRenderer.invoke('select-folder'), + selectVideoFile: () => ipcRenderer.invoke('select-video-file'), + selectMultipleVideos: () => ipcRenderer.invoke('select-multiple-videos'), + saveVideoDialog: (defaultName: string) => ipcRenderer.invoke('save-video-dialog', defaultName), + openFolder: (path: string) => ipcRenderer.invoke('open-folder', path), + openFile: (path: string) => ipcRenderer.invoke('open-file', path), + showInFolder: (path: string) => ipcRenderer.invoke('show-in-folder', path), + openDebugLogFile: () => ipcRenderer.invoke('open-debug-log-file'), + checkFolderWritable: (path: string) => ipcRenderer.invoke('check-folder-writable', path), + getStorageStats: () => ipcRenderer.invoke('get-storage-stats'), + getArchiveStats: () => ipcRenderer.invoke('get-archive-stats'), + getStreamerProfile: (login: string, forceRefresh?: boolean) => ipcRenderer.invoke('get-streamer-profile', login, forceRefresh), + getVodStoryboard: (vodId: string) => ipcRenderer.invoke('get-vod-storyboard', vodId), + getLiveStatusSnapshot: () => ipcRenderer.invoke('get-live-status-snapshot'), + onLiveStatusBatchUpdate: (callback: (info: { changes: Array<{ login: string; isLive: boolean }> }) => void) => { + ipcRenderer.on('live-status-batch-update', (_, info) => callback(info)); + }, + searchArchive: (filter: Record) => ipcRenderer.invoke('search-archive', filter), + runStorageCleanup: (options?: { dryRun?: boolean }) => ipcRenderer.invoke('run-storage-cleanup', options), + readChatFile: (filePath: string) => ipcRenderer.invoke('read-chat-file', filePath), + getAutomationStatus: () => ipcRenderer.invoke('get-automation-status'), + triggerAutoVodScan: () => ipcRenderer.invoke('trigger-auto-vod-scan'), + triggerAutoRecordScan: () => ipcRenderer.invoke('trigger-auto-record-scan'), + onAutoVodScanCompleted: (callback: (info: { queuedCount: number }) => void) => { + ipcRenderer.on('auto-vod-scan-completed', (_, info) => callback(info)); + }, + + // Video Cutter + getVideoInfo: (filePath: string): Promise => ipcRenderer.invoke('get-video-info', filePath), + extractFrame: (filePath: string, timeSeconds: number): Promise => ipcRenderer.invoke('extract-frame', filePath, timeSeconds), + cutVideo: (inputFile: string, startTime: number, endTime: number): Promise<{ success: boolean; outputFile: string | null }> => + ipcRenderer.invoke('cut-video', inputFile, startTime, endTime), + + // Merge Videos + mergeVideos: (inputFiles: string[], outputFile: string): Promise<{ success: boolean; outputFile: string | null }> => + ipcRenderer.invoke('merge-videos', inputFiles, outputFile), + + // App + getVersion: () => ipcRenderer.invoke('get-version'), + checkUpdate: () => ipcRenderer.invoke('check-update'), + downloadUpdate: () => ipcRenderer.invoke('download-update'), + installUpdate: () => ipcRenderer.invoke('install-update'), + openExternal: (url: string) => ipcRenderer.invoke('open-external', url), + runPreflight: (autoFix: boolean) => ipcRenderer.invoke('run-preflight', autoFix), + getDebugLog: (lines: number) => ipcRenderer.invoke('get-debug-log', lines), + getRuntimeMetrics: (): Promise => ipcRenderer.invoke('get-runtime-metrics'), + exportRuntimeMetrics: (): Promise<{ success: boolean; cancelled?: boolean; error?: string; filePath?: string }> => + ipcRenderer.invoke('export-runtime-metrics'), + resetDownloadedVodIds: (): Promise<{ success: boolean; removedCount: number }> => + ipcRenderer.invoke('reset-downloaded-vod-ids'), + markVodDownloaded: (vodId: string, mark: boolean): Promise<{ success: boolean }> => + ipcRenderer.invoke('mark-vod-downloaded', vodId, mark), + exportConfig: (): Promise<{ success: boolean; cancelled?: boolean; error?: string; filePath?: string }> => + ipcRenderer.invoke('export-config'), + importConfig: (): Promise<{ success: boolean; cancelled?: boolean; error?: string; filePath?: string }> => + ipcRenderer.invoke('import-config'), + + // Events + onDownloadProgress: (callback: (progress: DownloadProgress) => void) => { + ipcRenderer.on('download-progress', (_, progress) => callback(progress)); + }, + onQueueUpdated: (callback: (queue: QueueItem[]) => void) => { + ipcRenderer.on('queue-updated', (_, queue) => callback(queue)); + }, + onQueueDuplicateSkipped: (callback: (payload: { title: string; streamer: string; url: string }) => void) => { + ipcRenderer.on('queue-duplicate-skipped', (_, payload) => callback(payload)); + }, + onDownloadStarted: (callback: () => void) => { + ipcRenderer.on('download-started', () => callback()); + }, + onDownloadFinished: (callback: () => void) => { + ipcRenderer.on('download-finished', () => callback()); + }, + onCutProgress: (callback: (percent: number) => void) => { + ipcRenderer.on('cut-progress', (_, percent) => callback(percent)); + }, + onMergeProgress: (callback: (percent: number) => void) => { + ipcRenderer.on('merge-progress', (_, percent) => callback(percent)); + }, + + // Auto-Update Events + onUpdateChecking: (callback: () => void) => { + ipcRenderer.on('update-checking', () => callback()); + }, + onUpdateAvailable: (callback: (info: { version: string; releaseDate?: string; releaseName?: string; releaseNotes?: string }) => void) => { + ipcRenderer.on('update-available', (_, info) => callback(info)); + }, + onUpdateNotAvailable: (callback: () => void) => { + ipcRenderer.on('update-not-available', () => callback()); + }, + onUpdateDownloadProgress: (callback: (progress: { percent: number; bytesPerSecond: number; transferred: number; total: number }) => void) => { + ipcRenderer.on('update-download-progress', (_, progress) => callback(progress)); + }, + onUpdateDownloaded: (callback: (info: { version: string; releaseDate?: string; releaseName?: string; releaseNotes?: string }) => void) => { + ipcRenderer.on('update-downloaded', (_, info) => callback(info)); + }, + onUpdateError: (callback: (payload: { message: string }) => void) => { + ipcRenderer.on('update-error', (_, payload) => callback(payload)); + } +}); diff --git a/src/renderer-archive.ts b/src/renderer-archive.ts new file mode 100644 index 0000000..e42ca66 --- /dev/null +++ b/src/renderer-archive.ts @@ -0,0 +1,175 @@ +let archiveStreamerSelectPopulated = false; +let archiveSearchInFlight = false; +let archiveSearchDebounceTimer: number | null = null; + +function populateArchiveStreamerSelect(): void { + if (archiveStreamerSelectPopulated) return; + const select = document.getElementById('archiveSearchStreamer') as HTMLSelectElement | null; + if (!select) return; + + const streamers = (config.streamers as string[] | undefined) || []; + const sorted = [...streamers].sort((a, b) => a.localeCompare(b)); + const opts = sorted.map((s) => ``).join(''); + applyHtml(select, `${opts}`); + archiveStreamerSelectPopulated = true; +} + +function onArchiveSearchInput(): void { + if (archiveSearchDebounceTimer !== null) { + window.clearTimeout(archiveSearchDebounceTimer); + } + // 250ms debounce — feels snappy without spamming the IO walker on + // every keystroke. The walk is fast but pointless to repeat mid-type. + archiveSearchDebounceTimer = window.setTimeout(() => { + archiveSearchDebounceTimer = null; + void performArchiveSearch(); + }, 250); +} + +async function performArchiveSearch(): Promise { + if (archiveSearchInFlight) return; + populateArchiveStreamerSelect(); + + const queryEl = document.getElementById('archiveSearchQuery') as HTMLInputElement | null; + const typeEl = document.getElementById('archiveSearchType') as HTMLSelectElement | null; + const streamerEl = document.getElementById('archiveSearchStreamer') as HTMLSelectElement | null; + const sortEl = document.getElementById('archiveSearchSort') as HTMLSelectElement | null; + const summaryEl = document.getElementById('archiveSearchSummary'); + const resultsEl = document.getElementById('archiveSearchResults'); + const btn = document.getElementById('btnArchiveSearch') as HTMLButtonElement | null; + if (!resultsEl) return; + + archiveSearchInFlight = true; + if (btn) btn.disabled = true; + if (summaryEl) summaryEl.textContent = UI_TEXT.static.archiveSearching || 'Scanne...'; + + try { + const filter = { + query: queryEl?.value || '', + type: ((typeEl?.value as 'all' | 'live' | 'vod') || 'all'), + streamer: streamerEl?.value || '', + sinceMs: null, + untilMs: null, + sort: ((sortEl?.value as 'date_desc') || 'date_desc'), + limit: 200 + }; + const result = await window.api.searchArchive(filter); + renderArchiveSearchResults(result); + } catch (e) { + if (summaryEl) summaryEl.textContent = `Fehler: ${String(e)}`; + applyHtml(resultsEl, ''); + } finally { + archiveSearchInFlight = false; + if (btn) btn.disabled = false; + } +} + +function renderArchiveSearchResults(result: ArchiveSearchResult): void { + const summaryEl = document.getElementById('archiveSearchSummary'); + const resultsEl = document.getElementById('archiveSearchResults'); + if (!resultsEl) return; + + if (!result.rootExists) { + if (summaryEl) summaryEl.textContent = UI_TEXT.static.archiveNoRoot; + applyHtml(resultsEl, ''); + return; + } + + if (summaryEl) { + const tmpl = result.truncated + ? UI_TEXT.static.archiveSummaryTruncated + : UI_TEXT.static.archiveSummary; + summaryEl.textContent = (tmpl || '') + .replace('{matchCount}', String(result.matchCount)) + .replace('{scanned}', String(result.totalScanned)) + .replace('{shown}', String(result.hits.length)); + } + + if (result.hits.length === 0) { + applyHtml(resultsEl, `
${escapeHtml(UI_TEXT.static.archiveNoMatches || 'Keine Treffer.')}
`); + return; + } + + const rows = result.hits.map((hit) => { + const date = new Date(hit.mtimeMs).toLocaleString(); + const typeBadge = `${hit.type === 'live' ? 'LIVE' : 'VOD'}`; + const safeFullAttr = hit.fullPath.replace(/\\/g, '\\\\').replace(/'/g, "\\'"); + const chatBtn = hit.chatPath + ? `` + : ''; + const eventsBtn = hit.eventsPath + ? `` + : ''; + return ` +
+
+
+ ${typeBadge} + ${escapeHtml(hit.streamer)} + ${escapeHtml(date)} +
+
${escapeHtml(hit.fileName)}
+
${escapeHtml(formatBytes(hit.size))}
+
+
+ + + ${chatBtn} + ${eventsBtn} +
+
+ `; + }).join(''); + + applyHtml(resultsEl, rows); +} + +function openFilePath(filePath: string): void { + void window.api.openFile(filePath); +} + +function showFileInFolder(filePath: string): void { + void window.api.showInFolder(filePath); +} + +function openEventsOrChat(filePath: string, title: string, kind: 'chat' | 'events'): void { + if (kind === 'events') { + const fn = (window as unknown as { openEventsViewer?: (p: string, t: string) => void }).openEventsViewer; + if (typeof fn === 'function') fn(filePath, title); + } else { + const fn = (window as unknown as { openChatViewer?: (p: string, t: string) => void }).openChatViewer; + if (typeof fn === 'function') fn(filePath, title); + } +} + +(window as unknown as { + performArchiveSearch: typeof performArchiveSearch; + onArchiveSearchInput: typeof onArchiveSearchInput; + openFilePath: typeof openFilePath; + showFileInFolder: typeof showFileInFolder; + openEventsOrChat: typeof openEventsOrChat; +}).performArchiveSearch = performArchiveSearch; +(window as unknown as { onArchiveSearchInput: typeof onArchiveSearchInput }).onArchiveSearchInput = onArchiveSearchInput; +(window as unknown as { openFilePath: typeof openFilePath }).openFilePath = openFilePath; +(window as unknown as { showFileInFolder: typeof showFileInFolder }).showFileInFolder = showFileInFolder; +(window as unknown as { openEventsOrChat: typeof openEventsOrChat }).openEventsOrChat = openEventsOrChat; + +function initArchiveSearchInput(): void { + const queryEl = document.getElementById('archiveSearchQuery') as HTMLInputElement | null; + if (queryEl && !queryEl.dataset.bound) { + queryEl.addEventListener('input', onArchiveSearchInput); + queryEl.addEventListener('keydown', (e) => { + if (e.key === 'Enter') void performArchiveSearch(); + }); + queryEl.dataset.bound = '1'; + } + const filters = ['archiveSearchType', 'archiveSearchStreamer', 'archiveSearchSort']; + for (const id of filters) { + const el = document.getElementById(id) as HTMLSelectElement | null; + if (el && !el.dataset.bound) { + el.addEventListener('change', () => { void performArchiveSearch(); }); + el.dataset.bound = '1'; + } + } +} +(window as unknown as { initArchiveSearchInput: typeof initArchiveSearchInput }).initArchiveSearchInput = initArchiveSearchInput; diff --git a/src/renderer-command-palette.ts b/src/renderer-command-palette.ts new file mode 100644 index 0000000..cb5f3e4 --- /dev/null +++ b/src/renderer-command-palette.ts @@ -0,0 +1,232 @@ +// Command Palette — Pillar 5 UI Power. +// Ctrl+K oeffnet ein Suchfeld + Liste schnell ausfuehrbarer Aktionen. +// MVP: 6 statische Tab-Wechsel-Befehle, prefix-match auf Label. + +interface PaletteCommand { + id: string; + label: string; + hint: string; + keywords: string; // fuer Match — Label kleingeschrieben + Synonyme + action: () => void; +} + +(function initCommandPalette() { + const STORE: { commands: PaletteCommand[]; activeIndex: number; filtered: PaletteCommand[] } = { + commands: [], + activeIndex: 0, + filtered: [], + }; + + function buildCommands(): PaletteCommand[] { + const w = window as unknown as { + showTab?: (tab: string) => void; + selectStreamer?: (name: string, forceRefresh?: boolean) => Promise; + config?: { streamers?: Array<{ name: string }> }; + }; + const showTab = w.showTab; + if (typeof showTab !== 'function') { + return []; + } + + // hint 'Open' statt 'Tab' — 'Tab' las sich wie eine Tastatur-Taste + // ('druecke Tab') statt 'oeffnet diesen Tab'. + const tabs: Array<{ id: string; labels: string[]; hint: string }> = [ + { id: 'vods', labels: ['VODs', 'videos', 'streams'], hint: 'Open' }, + { id: 'queue', labels: ['Queue', 'downloads', 'warteschlange'], hint: 'Open' }, + { id: 'streamers', labels: ['Streamers', 'channels'], hint: 'Open' }, + { id: 'stats', labels: ['Stats', 'statistiken', 'dashboard'], hint: 'Open' }, + { id: 'archive', labels: ['Archive', 'archiv'], hint: 'Open' }, + { id: 'settings', labels: ['Settings', 'einstellungen', 'config'], hint: 'Open' }, + ]; + + const tabCommands: PaletteCommand[] = tabs.map(t => ({ + id: 'tab:' + t.id, + label: t.labels[0], + hint: t.hint, + keywords: t.labels.join(' ').toLowerCase(), + action: () => showTab(t.id), + })); + + // Streamer-Liste aus globalem config (gefuellt nach renderer-Init). + const streamerCommands: PaletteCommand[] = []; + const streamers = Array.isArray(w.config?.streamers) ? w.config.streamers : []; + const selectStreamer = w.selectStreamer; + if (typeof selectStreamer === 'function') { + for (const entry of streamers) { + if (!entry || typeof entry.name !== 'string') continue; + const name = entry.name; + streamerCommands.push({ + id: 'streamer:' + name.toLowerCase(), + label: name, + hint: 'Streamer', + keywords: ('@' + name + ' ' + name).toLowerCase(), + action: () => { + showTab('vods'); + void selectStreamer(name); + }, + }); + } + } + + return [...tabCommands, ...streamerCommands]; + } + + function getModal(): HTMLElement | null { + return document.getElementById('commandPaletteModal'); + } + + function getInput(): HTMLInputElement | null { + return document.getElementById('commandPaletteInput') as HTMLInputElement | null; + } + + function getList(): HTMLUListElement | null { + return document.getElementById('commandPaletteList') as HTMLUListElement | null; + } + + function isOpen(): boolean { + return Boolean(getModal()?.classList.contains('show')); + } + + function clearList(list: HTMLUListElement) { + while (list.firstChild) list.removeChild(list.firstChild); + } + + function render() { + const list = getList(); + if (!list) return; + clearList(list); + STORE.filtered.forEach((cmd, idx) => { + const li = document.createElement('li'); + li.className = 'cp-item' + (idx === STORE.activeIndex ? ' cp-active' : ''); + li.dataset.cmdId = cmd.id; + li.setAttribute('role', 'option'); + li.setAttribute('aria-selected', idx === STORE.activeIndex ? 'true' : 'false'); + + const label = document.createElement('span'); + label.className = 'cp-item-label'; + label.textContent = cmd.label; + li.appendChild(label); + + const hint = document.createElement('span'); + hint.className = 'cp-item-hint'; + hint.textContent = cmd.hint; + li.appendChild(hint); + + li.addEventListener('mouseenter', () => { + STORE.activeIndex = idx; + render(); + }); + li.addEventListener('click', () => { + executeAt(idx); + }); + + list.appendChild(li); + }); + } + + function applyFilter(query: string) { + const q = query.trim().toLowerCase(); + if (!q) { + STORE.filtered = STORE.commands.slice(); + } else { + STORE.filtered = STORE.commands.filter(c => c.keywords.includes(q)); + } + if (STORE.activeIndex >= STORE.filtered.length) { + STORE.activeIndex = STORE.filtered.length > 0 ? STORE.filtered.length - 1 : 0; + } + render(); + } + + function executeAt(idx: number) { + const cmd = STORE.filtered[idx]; + if (!cmd) return; + close(); + try { + cmd.action(); + } catch (e) { + console.error('command-palette: action failed', cmd.id, e); + } + } + + function open() { + const modal = getModal(); + const input = getInput(); + if (!modal || !input) return; + STORE.commands = buildCommands(); + STORE.filtered = STORE.commands.slice(); + STORE.activeIndex = 0; + input.value = ''; + modal.classList.add('show'); + requestAnimationFrame(() => input.focus()); + render(); + } + + function close() { + const modal = getModal(); + if (!modal) return; + modal.classList.remove('show'); + } + + function onKeydown(e: KeyboardEvent) { + // Toggle: Ctrl+K (Linux/Windows) or Cmd+K (Mac) + if ((e.ctrlKey || e.metaKey) && !e.altKey && !e.shiftKey && (e.key === 'k' || e.key === 'K')) { + e.preventDefault(); + if (isOpen()) { + close(); + } else { + open(); + } + return; + } + + if (!isOpen()) return; + + if (e.key === 'Escape') { + e.preventDefault(); + close(); + return; + } + if (e.key === 'ArrowDown') { + e.preventDefault(); + if (STORE.filtered.length === 0) return; + STORE.activeIndex = (STORE.activeIndex + 1) % STORE.filtered.length; + render(); + return; + } + if (e.key === 'ArrowUp') { + e.preventDefault(); + if (STORE.filtered.length === 0) return; + STORE.activeIndex = (STORE.activeIndex - 1 + STORE.filtered.length) % STORE.filtered.length; + render(); + return; + } + if (e.key === 'Enter') { + e.preventDefault(); + executeAt(STORE.activeIndex); + return; + } + } + + function attach() { + const input = getInput(); + if (input) { + input.addEventListener('input', () => applyFilter(input.value)); + } + const modal = getModal(); + if (modal) { + modal.addEventListener('click', e => { + if (e.target === modal) close(); + }); + } + document.addEventListener('keydown', onKeydown, { capture: true }); + } + + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', attach); + } else { + attach(); + } + + // Expose for renderer.ts closeTopmostOpenModal integration. + (window as unknown as { closeCommandPalette?: () => void }).closeCommandPalette = close; +})(); diff --git a/src/renderer-globals.d.ts b/src/renderer-globals.d.ts new file mode 100644 index 0000000..ba97604 --- /dev/null +++ b/src/renderer-globals.d.ts @@ -0,0 +1,403 @@ +interface AppConfig { + client_id?: string; + client_secret?: string; + download_path?: string; + streamers?: string[]; + theme?: string; + download_mode?: 'parts' | 'full'; + part_minutes?: number; + language?: 'de' | 'en'; + filename_template_vod?: string; + filename_template_parts?: string; + filename_template_clip?: string; + smart_queue_scheduler?: boolean; + performance_mode?: 'stability' | 'balanced' | 'speed'; + prevent_duplicate_downloads?: boolean; + persist_queue_on_restart?: boolean; + metadata_cache_minutes?: number; + parallel_downloads?: number; + auto_resume_queue_on_startup?: boolean; + downloaded_vod_ids?: string[]; + streamlink_quality?: string; + notify_on_each_completion?: boolean; + streamlink_disable_ads?: boolean; + auto_record_streamers?: string[]; + auto_record_poll_seconds?: number; + download_chat_replay?: boolean; + capture_live_chat?: boolean; + discord_webhook_url?: string; + discord_notify_live_start?: boolean; + discord_notify_live_end?: boolean; + discord_notify_vod_complete?: boolean; + discord_notify_vod_auto_queued?: boolean; + auto_cleanup_enabled?: boolean; + auto_cleanup_days?: number; + auto_cleanup_target?: 'live_only' | 'all'; + auto_cleanup_action?: 'delete' | 'archive'; + log_stream_events?: boolean; + auto_vod_download_streamers?: string[]; + auto_vod_download_poll_minutes?: number; + auto_vod_max_age_hours?: number; + auto_resume_live_recording?: boolean; + auto_merge_resumed_parts?: boolean; + delete_parts_after_merge?: boolean; + [key: string]: unknown; +} + +interface VOD { + id: string; + title: string; + created_at: string; + duration: string; + thumbnail_url: string; + url: string; + view_count: number; + stream_id?: string; +} + +interface CustomClip { + startSec: number; + durationSec: number; + startPart: number; + filenameFormat: 'simple' | 'timestamp' | 'template' | 'parts'; + filenameTemplate?: string; +} + +interface MergeGroupItem { + url: string; + title: string; + date: string; + streamer: string; + duration_str: string; +} + +interface MergeGroup { + items: MergeGroupItem[]; + mergePhase: 'downloading' | 'merging' | 'splitting' | 'cleanup' | 'done'; + currentItemIndex: number; + downloadedFiles: Record; + mergedFile?: string; + splitFiles?: string[]; + totalDurationSec?: number; +} + +interface QueueItem { + id: string; + title: string; + url: string; + date: string; + streamer: string; + duration_str: string; + status: 'pending' | 'downloading' | 'paused' | 'completed' | 'error'; + progress: number; + currentPart?: number; + totalParts?: number; + speed?: string; + eta?: string; + downloadedBytes?: number; + totalBytes?: number; + progressStatus?: string; + last_error?: string; + customClip?: CustomClip; + mergeGroup?: MergeGroup; + outputFiles?: string[]; + isLive?: boolean; + recordingHealth?: 'ok' | 'stale' | 'unknown'; +} + +interface DownloadProgress { + id: string; + progress: number; + speed: string; + speedBytesPerSec?: number; + eta: string; + status: string; + currentPart?: number; + totalParts?: number; + downloadedBytes?: number; + totalBytes?: number; + recordingHealth?: 'ok' | 'stale' | 'unknown'; +} + +interface RuntimeMetricsSnapshot { + cacheHits: number; + cacheMisses: number; + duplicateSkips: number; + retriesScheduled: number; + retriesExhausted: number; + integrityFailures: number; + downloadsStarted: number; + downloadsCompleted: number; + downloadsFailed: number; + downloadedBytesTotal: number; + lastSpeedBytesPerSec: number; + avgSpeedBytesPerSec: number; + activeItemId: string | null; + activeItemTitle: string | null; + lastErrorClass: string | null; + lastRetryDelaySeconds: number; + timestamp: string; + queue: { + pending: number; + downloading: number; + paused: number; + completed: number; + error: number; + total: number; + }; + caches: { + loginToUserId: number; + vodList: number; + clipInfo: number; + }; + config: { + performanceMode: 'stability' | 'balanced' | 'speed'; + smartScheduler: boolean; + metadataCacheMinutes: number; + duplicatePrevention: boolean; + }; +} + +interface VideoInfo { + duration: number; + width: number; + height: number; + fps: number; +} + +interface ClipDialogData { + url: string; + title: string; + date: string; + streamer: string; + duration: string; +} + +interface UpdateInfo { + version: string; + releaseDate?: string; + releaseName?: string; + releaseNotes?: string; +} + +interface UpdateDownloadProgress { + percent: number; + bytesPerSecond: number; + transferred: number; + total: number; +} + +interface PreflightChecks { + internet: boolean; + streamlink: boolean; + ffmpeg: boolean; + ffprobe: boolean; + downloadPathWritable: boolean; +} + +interface PreflightResult { + ok: boolean; + autoFixApplied: boolean; + checks: PreflightChecks; + messages: string[]; + timestamp: string; +} + +interface StreamerStorageEntry { + name: string; + fileCount: number; + totalBytes: number; + liveBytes: number; + chatBytes: number; + folderPath: string; +} +interface CleanupReport { + enabled: boolean; + dryRun: boolean; + cutoffDays: number; + target: 'live_only' | 'all'; + action: 'delete' | 'archive'; + scannedAt: string; + candidates: number; + processed: number; + failed: number; + bytesFreed: number; + failures: Array<{ path: string; error: string }>; +} +interface StorageStatsResult { + downloadPath: string; + rootExists: boolean; + freeBytes: number | null; + totalFiles: number; + totalBytes: number; + streamers: StreamerStorageEntry[]; + extras: StreamerStorageEntry[]; + scannedAt: string; +} + +interface StreamerProfile { + login: string; + displayName: string; + avatarUrl: string; + bannerUrl: string; + description: string; + broadcasterType: '' | 'partner' | 'affiliate'; + followerCount: number | null; + vodCount: number; + lastStreamAt: string | null; + isLive: boolean; + currentTitle: string | null; + currentGame: string | null; + currentStreamPreviewUrl: string; + currentStreamViewers: number | null; + twitchUrl: string; + fetchedAt: number; +} + +interface VodStoryboard { + vodId: string; + spriteDataUrl: string; + cols: number; + rows: number; + cellWidth: number; + cellHeight: number; + framesInSprite: number; +} + +interface ArchiveSearchHit { + fullPath: string; + fileName: string; + streamer: string; + type: 'live' | 'vod' | 'chat' | 'events' | 'other'; + size: number; + mtimeMs: number; + chatPath: string | null; + eventsPath: string | null; +} +interface ArchiveSearchResult { + totalScanned: number; + matchCount: number; + truncated: boolean; + hits: ArchiveSearchHit[]; + scannedAt: string; + rootExists: boolean; +} + +interface ArchiveStatsTopStreamer { + streamer: string; + bytes: number; + fileCount: number; + liveBytes: number; + vodBytes: number; + chatBytes: number; +} +interface ArchiveStatsDay { date: string; count: number; bytes: number } +interface ArchiveStatsBucket { label: string; count: number; bytes: number } +interface ArchiveStats { + totalFiles: number; + totalBytes: number; + liveCount: number; + liveBytes: number; + vodCount: number; + vodBytes: number; + chatCount: number; + chatBytes: number; + eventsCount: number; + streamerCount: number; + avgRecordingSizeBytes: number; + topStreamers: ArchiveStatsTopStreamer[]; + dailyActivity: ArchiveStatsDay[]; + sizeBuckets: ArchiveStatsBucket[]; + scannedAt: string; + downloadPath: string; + rootExists: boolean; +} + +interface ApiBridge { + getConfig(): Promise; + saveConfig(config: Partial): Promise; + login(): Promise; + getUserId(username: string): Promise; + getVODs(userId: string, forceRefresh?: boolean): Promise; + getQueue(): Promise; + addToQueue(item: Omit): Promise; + startLiveRecording(streamerName: string): Promise<{ success: boolean; error?: string; streamer?: string; title?: string }>; + removeFromQueue(id: string): Promise; + reorderQueue(orderIds: string[]): Promise; + clearCompleted(): Promise; + retryFailedDownloads(): Promise; + retryQueueItem(id: string): Promise; + createMergeGroup(itemIds: string[]): Promise; + startDownload(): Promise; + pauseDownload(): Promise; + cancelDownload(): Promise; + isDownloading(): Promise; + downloadClip(url: string): Promise<{ success: boolean; error?: string }>; + selectFolder(): Promise; + selectVideoFile(): Promise; + selectMultipleVideos(): Promise; + saveVideoDialog(defaultName: string): Promise; + openFolder(path: string): Promise; + openFile(path: string): Promise; + showInFolder(path: string): Promise; + openDebugLogFile(): Promise; + checkFolderWritable(path: string): Promise; + getStorageStats(): Promise; + getArchiveStats(): Promise; + getStreamerProfile(login: string, forceRefresh?: boolean): Promise; + getVodStoryboard(vodId: string): Promise; + getLiveStatusSnapshot(): Promise>; + onLiveStatusBatchUpdate(callback: (info: { changes: Array<{ login: string; isLive: boolean }> }) => void): void; + searchArchive(filter: { + query?: string; + type?: 'all' | 'live' | 'vod' | 'chat' | 'events'; + streamer?: string; + sinceMs?: number | null; + untilMs?: number | null; + sort?: 'date_desc' | 'date_asc' | 'size_desc' | 'size_asc' | 'name_asc'; + limit?: number; + }): Promise; + runStorageCleanup(options?: { dryRun?: boolean }): Promise; + readChatFile(filePath: string): Promise<{ success: boolean; error?: string; format?: 'replay' | 'live'; messages?: Array>; truncated?: boolean; total?: number }>; + getAutomationStatus(): Promise<{ + autoRecord: { watching: number; lastRunAt: number; nextRunAt: number; lastTriggeredCount: number; inFlight: boolean }; + autoVod: { watching: number; lastRunAt: number; nextRunAt: number; lastQueuedCount: number; inFlight: boolean }; + }>; + triggerAutoVodScan(): Promise<{ queuedCount: number }>; + triggerAutoRecordScan(): Promise<{ triggered: number }>; + onAutoVodScanCompleted(callback: (info: { queuedCount: number }) => void): void; + getVideoInfo(filePath: string): Promise; + extractFrame(filePath: string, timeSeconds: number): Promise; + cutVideo(inputFile: string, startTime: number, endTime: number): Promise<{ success: boolean; outputFile: string | null }>; + mergeVideos(inputFiles: string[], outputFile: string): Promise<{ success: boolean; outputFile: string | null }>; + getVersion(): Promise; + checkUpdate(): Promise<{ checking?: boolean; error?: boolean; skipped?: 'ready-to-install' | 'in-progress' | 'throttled' | 'error' | string }>; + downloadUpdate(): Promise<{ downloading?: boolean; error?: boolean; skipped?: 'ready-to-install' | 'in-progress' | 'error' | string }>; + installUpdate(): Promise; + openExternal(url: string): Promise; + runPreflight(autoFix: boolean): Promise; + getDebugLog(lines: number): Promise; + getRuntimeMetrics(): Promise; + exportRuntimeMetrics(): Promise<{ success: boolean; cancelled?: boolean; error?: string; filePath?: string }>; + resetDownloadedVodIds(): Promise<{ success: boolean; removedCount: number }>; + markVodDownloaded(vodId: string, mark: boolean): Promise<{ success: boolean }>; + exportConfig(): Promise<{ success: boolean; cancelled?: boolean; error?: string; filePath?: string }>; + importConfig(): Promise<{ success: boolean; cancelled?: boolean; error?: string; filePath?: string }>; + onDownloadProgress(callback: (progress: DownloadProgress) => void): void; + onQueueUpdated(callback: (queue: QueueItem[]) => void): void; + onQueueDuplicateSkipped(callback: (payload: { title: string; streamer: string; url: string }) => void): void; + onDownloadStarted(callback: () => void): void; + onDownloadFinished(callback: () => void): void; + onCutProgress(callback: (percent: number) => void): void; + onMergeProgress(callback: (percent: number) => void): void; + onUpdateChecking(callback: () => void): void; + onUpdateAvailable(callback: (info: UpdateInfo) => void): void; + onUpdateNotAvailable(callback: () => void): void; + onUpdateDownloadProgress(callback: (progress: UpdateDownloadProgress) => void): void; + onUpdateDownloaded(callback: (info: UpdateInfo) => void): void; + onUpdateError(callback: (payload: { message: string }) => void): void; +} + +interface Window { + api: ApiBridge; +} diff --git a/src/renderer-locale-de.ts b/src/renderer-locale-de.ts new file mode 100644 index 0000000..260332b --- /dev/null +++ b/src/renderer-locale-de.ts @@ -0,0 +1,516 @@ +const UI_TEXT_DE = { + appName: 'Twitch VOD Manager', + static: { + navVods: 'Twitch VODs', + navClips: 'Twitch Clips', + navCutter: 'Video schneiden', + navMerge: 'Videos zusammenfugen', + navSettings: 'Einstellungen', + queueTitle: 'Warteschlange', + retryFailed: 'Wiederholen', + retryFailedHint: 'Nur fehlgeschlagene Downloads erneut starten', + healthUnknown: 'System: Unbekannt', + healthGood: 'System: Stabil', + healthWarn: 'System: Warnung', + healthBad: 'System: Problem', + clearQueue: 'Leeren', + refresh: 'Aktualisieren', + streamerPlaceholder: 'Streamer hinzufugen...', + clipsHeading: 'Twitch Clip-Download', + clipsInfoTitle: 'Info', + clipsInfoText: 'Unterstutzte Formate:\n- https://clips.twitch.tv/ClipName\n- https://www.twitch.tv/streamer/clip/ClipName\n\nClips werden im Download-Ordner unter "Clips/StreamerName/" gespeichert.', + cutterSelectTitle: 'Video auswahlen', + cutterPreviewPlaceholder: 'Video auswahlen um Vorschau zu sehen', + cutterBrowse: 'Durchsuchen', + commandPaletteSearchPlaceholder: 'Befehl suchen...', + commandPaletteHint: 'Up/Down zum Navigieren, Enter zum Ausfuehren, Esc zum Schliessen', + mergeTitle: 'Videos zusammenfugen', + mergeDesc: 'Wahle mehrere Videos aus, um sie zu einem Video zusammenzufugen. Die Reihenfolge kann geandert werden.', + mergeAdd: '+ Videos hinzufugen', + designTitle: 'Design', + themeLabel: 'Theme', + themeLight: 'Hell', + languageLabel: 'Sprache', + languageDe: 'Deutsch', + languageEn: 'Englisch', + apiTitle: 'Twitch API', + clientIdLabel: 'Client ID', + clientSecretLabel: 'Client Secret', + saveSettings: 'Speichern & Verbinden', + downloadSettingsTitle: 'Download-Einstellungen', + storageLabel: 'Speicherort', + openFolder: 'Offnen', + modeLabel: 'Download-Modus', + modeFull: 'Ganzes VOD', + modeParts: 'In Teile splitten', + partMinutesLabel: 'Teil-Lange (Minuten)', + parallelDownloadsLabel: 'Parallele Downloads', + parallelDownloads1: '1 (Standard)', + parallelDownloads2: '2 (Parallel)', + performanceModeLabel: 'Performance-Profil', + performanceModeStability: 'Max Stabilitat', + performanceModeBalanced: 'Ausgewogen', + performanceModeSpeed: 'Max Geschwindigkeit', + smartSchedulerLabel: 'Smart Queue Scheduler aktivieren', + smartSchedulerHint: 'Bevorzugt kuerzere VODs und aeltere Queue-Eintraege zuerst, damit der Durchsatz gleichmaessig bleibt. Deaktivieren = strikte Einfuegereihenfolge.', + streamerInvalid: 'Twitch-Username ungueltig (4-25 Zeichen, Buchstaben/Zahlen/Unterstrich).', + apiHelpIntro: 'Du brauchst eine Client-ID und ein Client-Secret von Twitch.', + apiHelpLinkText: 'dev.twitch.tv/console/apps', + openDebugLogFile: 'Log-Datei oeffnen', + storageCardTitle: 'Speicher', + storageCardIntro: 'Disk-Verbrauch pro Streamer im aktuellen Download-Ordner. Live-Aufnahmen werden separat ausgewiesen.', + storageRefresh: 'Aktualisieren', + storageEmpty: 'Download-Ordner ist leer oder nicht lesbar.', + storageScanning: 'Scanne...', + storageSummary: 'Gesamt: {files} Dateien, {size} — Freier Speicher: {free}', + storageColumnFolder: 'Ordner', + storageColumnFiles: 'Dateien', + storageColumnTotal: 'Gesamt', + storageColumnLive: 'Live', + storageColumnChat: 'Chat', + storageColumnActionsAria: 'Aktionen', + storageOpen: 'Oeffnen', + storageOtherFolders: 'Andere Ordner im Download-Pfad', + cleanupTitle: 'Auto-Cleanup', + cleanupIntro: 'Aufnahmen aelter als X Tage in einen Archiv-Ordner verschieben oder loeschen. Sidecar-Chat-Dateien (.chat.json/.chat.jsonl) werden mit der Aufnahme bewegt.', + cleanupEnabledLabel: 'Auto-Cleanup aktivieren', + cleanupDaysLabel: 'Tage-Schwelle', + cleanupTargetLabel: 'Bereich', + cleanupTargetLive: 'Nur Live-Aufnahmen', + cleanupTargetAll: 'Alle Aufnahmen', + cleanupActionLabel: 'Aktion', + cleanupActionArchive: 'In Archiv verschieben', + cleanupActionDelete: 'Loeschen', + cleanupDryRun: 'Vorschau', + cleanupRunNow: 'Jetzt ausfuehren', + cleanupReportPreview: 'Wuerde {count} Dateien betreffen (~{size}). Es wurden keine Dateien verschoben oder geloescht.', + cleanupReportDone: '{count} Dateien verarbeitet, ~{size} frei.{failed}', + cleanupReportFailedSuffix: ' {failed} fehlgeschlagen.', + cleanupReportEmpty: 'Keine Aufnahmen aelter als {days} Tage gefunden.', + discordCardTitle: 'Discord-Webhook', + discordCardIntro: 'Sende Benachrichtigungen an einen Discord-Channel via Webhook - nuetzlich fuer Multi-Device-Setups oder eine dedizierte Archiv-Maschine.', + discordWebhookUrlLabel: 'Webhook-URL', + discordNotifyLiveStartLabel: 'Bei Live-Aufnahme-Start benachrichtigen', + discordNotifyLiveEndLabel: 'Bei Live-Aufnahme-Ende benachrichtigen', + discordNotifyVodAutoQueuedLabel: 'Bei automatisch eingereihten VODs benachrichtigen', + autoResumeLiveRecordingLabel: 'Live-Aufnahme automatisch fortsetzen wenn Streamlink abbricht (max. 5 Versuche)', + autoMergeResumedPartsLabel: 'Fortgesetzte Aufnahme-Parts automatisch zu einer Datei zusammenfuegen (ffmpeg concat, kein Re-Encode)', + deletePartsAfterMergeLabel: 'Einzelne Parts nach erfolgreichem Merge loeschen', + autoVodCardTitle: 'Auto-VOD-Download', + autoVodCardIntro: 'Streamer mit aktiviertem VOD-Toggle werden in dem hier festgelegten Intervall auf neue Twitch-VODs geprueft. Neue VODs innerhalb des Alters-Fensters werden automatisch zur Download-Queue hinzugefuegt.', + autoVodPollMinutesLabel: 'Poll-Intervall (Minuten)', + autoVodMaxAgeHoursLabel: 'Max. Alter (Stunden)', + autoVodScanNow: 'Jetzt scannen', + autoRecordScanNow: 'Live-Status pruefen', + statsTitle: 'Archiv-Statistik', + statsIntro: 'Aggregiert ueber den Download-Ordner. Live-Aufnahmen liegen unter {streamer}/live/, VOD-Downloads direkt unter {streamer}/. Lade-Zeit skaliert mit der Anzahl Dateien.', + statsRefresh: 'Aktualisieren', + statsScanning: 'Scanne...', + statsScannedAt: 'Letzter Scan', + statsSummaryTitle: 'Uebersicht', + statsTopStreamersTitle: 'Top Streamer (nach Groesse)', + statsActivityTitle: 'Aktivitaet (letzte 30 Tage)', + statsSizeBucketsTitle: 'Aufnahme-Groessen-Verteilung', + statsTotalRecordings: 'Aufnahmen gesamt', + statsLiveRecordings: 'Live-Aufnahmen', + statsVodRecordings: 'VOD-Downloads', + statsStreamers: 'Streamer', + statsAvgSize: 'Durchschn. Groesse', + statsChatFiles: 'Chat-Dateien', + statsFiles: 'Dateien', + statsActivityEmpty: 'Keine Aufnahmen in den letzten 30 Tagen.', + statsActivitySummary: '{count} Aufnahmen - {size} in den letzten 30 Tagen', + statsEmpty: 'Keine Daten.', + statsNoRoot: 'Download-Ordner nicht gefunden. Setze zuerst einen Download-Pfad in den Einstellungen.', + navStats: 'Statistik', + navArchive: 'Archiv', + archiveTitle: 'Archiv durchsuchen', + archiveIntro: 'Suche nach Dateinamen, Streamern oder Datum-Strings. Treffer zeigen Recordings (Live + VOD); zugehoerige Chat- und Events-Dateien werden als Companion-Buttons angeboten.', + archiveAllTypes: 'Alle Typen', + archiveTypeLive: 'Live-Aufnahmen', + archiveTypeVod: 'VOD-Downloads', + archiveAllStreamers: 'Alle Streamer', + archiveSortDateDesc: 'Neueste zuerst', + archiveSortDateAsc: 'Aelteste zuerst', + archiveSortSizeDesc: 'Groesste zuerst', + archiveSortSizeAsc: 'Kleinste zuerst', + archiveSortNameAsc: 'Name (A-Z)', + archiveSearchBtn: 'Suchen', + archiveSearching: 'Scanne...', + archiveSummary: '{matchCount} Treffer (gescannt: {scanned} Dateien)', + archiveSummaryTruncated: '{matchCount} Treffer (gescannt: {scanned} Dateien, gezeigt: {shown} - verfeinere die Suche fuer mehr)', + archiveNoMatches: 'Keine Treffer.', + archiveNoRoot: 'Download-Ordner nicht gefunden. Setze zuerst einen Download-Pfad in den Einstellungen.', + archiveSearchPlaceholder: 'Suche...', + archiveSearchAria: 'Archiv durchsuchen', + archiveOpen: 'Oeffnen', + archiveShowInFolder: 'Ordner', + archiveViewChat: 'Chat', + archiveViewEvents: 'Events', + discordNotifyVodCompleteLabel: 'Bei abgeschlossenem VOD-Download benachrichtigen', + backupCardTitle: 'Sicherung & Wartung', + backupCardIntro: 'Konfiguration sichern, auf einem anderen Geraet wiederherstellen oder die Liste der bereits heruntergeladenen VODs zuruecksetzen.', + exportConfig: 'Konfiguration exportieren', + importConfig: 'Konfiguration importieren', + resetDownloadedIds: 'Downloaded-VODs zuruecksetzen', + configExported: 'Konfiguration exportiert.', + configExportFailed: 'Export der Konfiguration fehlgeschlagen.', + configImported: 'Konfiguration importiert. Einige Aenderungen erfordern evtl. einen Neustart.', + configImportFailed: 'Import der Konfiguration fehlgeschlagen.', + resetDownloadedConfirm: 'Liste der heruntergeladenen VODs zuruecksetzen? Karten verlieren das gruene Haekchen, es werden aber keine Dateien geloescht.', + resetDownloadedDone: '{count} Eintraege aus der Downloaded-Liste entfernt.', + duplicatePreventionLabel: 'Duplikate in Queue verhindern', + persistQueueLabel: 'Queue zwischen App-Starts speichern', + autoResumeQueueLabel: 'Queue beim Start automatisch fortsetzen', + autoResumeQueueHint: 'Wenn aktiv und die gespeicherte Queue noch ausstehende Eintraege hat, starten Downloads ~5 Sekunden nach dem Fensteroeffnen. Deaktivieren = Start-Klick noetig.', + notifyEachCompletionLabel: 'Benachrichtigung bei jedem fertigen Download', + notifyEachCompletionHint: 'Standardmaessig aus — bei langen Queues wuerde das System-Notifications-Panel sonst zugespammt. Die Queue-End-Zusammenfassung erscheint trotzdem.', + streamlinkDisableAdsLabel: 'Twitch-Ads beim Download ueberspringen', + streamlinkDisableAdsHint: 'Gibt --twitch-disable-ads an streamlink weiter, damit Mid-Roll-Ads nicht ins VOD eingebettet werden. Empfohlen aktiv lassen.', + downloadChatReplayLabel: 'Chat-Replay parallel zum VOD speichern (.chat.json)', + downloadChatReplayHint: 'Nach erfolgreichem VOD-Download wird der oeffentliche Chat-Replay via Twitch GQL geholt und als JSON neben dem Video gespeichert. Twitch behaelt Chat-Replays nur solange wie das VOD selbst.', + captureLiveChatLabel: 'Live-Chat waehrend der Aufnahme mitschneiden (.chat.jsonl)', + captureLiveChatHint: 'Oeffnet waehrend einer Live-Aufnahme eine anonyme IRC-Verbindung zum Twitch-Chat und schreibt jede Nachricht in eine .chat.jsonl-Datei neben dem Video (JSON Lines, eine Nachricht pro Zeile, damit ein Mid-Stream-Abbruch frueheren Inhalt nicht korrumpiert).', + logStreamEventsLabel: 'Stream-Events bei Live-Aufnahmen mitloggen (.events.jsonl)', + logStreamEventsHint: 'Pollt den Streamer einmal pro Minute und schreibt Title-/Game-Wechsel in eine .events.jsonl-Datei neben dem Video. Hilfreich beim Suchen in langen archivierten Streams ("wann hat er auf CS:GO gewechselt?"). Sehr guenstig — ein zusaetzlicher Helix/GQL-Call pro Minute pro aktiver Aufnahme.', + streamlinkQualityLabel: 'Stream-Qualitaet', + streamlinkQualityHint: 'Streamlink versucht erst diese Qualitaet; falls das VOD sie nicht anbietet, faellt es auf "best" zurueck.', + streamlinkQualityBest: 'Best (Standard)', + streamlinkQualitySource: 'Source (Original)', + streamlinkQualityAudio: 'Nur Audio', + downloadPathNotWritable: 'Download-Ordner ist nicht beschreibbar. Waehle einen anderen Ordner oder pruefe die Schreibrechte.', + streamerSectionTitle: 'Streamer', + streamerListFilterPlaceholder: 'Filtern...', + streamerListFilterAria: 'Streamer-Liste filtern', + streamerAddAriaLabel: 'Streamer hinzufuegen', + streamerBulkRemoveTitle: 'Alle entfernen (oder gefilterte)', + streamerBulkRemoveAll: 'Alle {count} Streamer aus der Liste entfernen?', + streamerBulkRemoveFiltered: 'Die {count} passenden Streamer aus der Liste entfernen?', + metadataCacheMinutesLabel: 'Metadata-Cache (Minuten)', + filenameTemplatesTitle: 'Dateinamen-Templates', + vodTemplateLabel: 'VOD-Template', + partsTemplateLabel: 'VOD-Teile-Template', + defaultClipTemplateLabel: 'Clip-Template', + filenameTemplateHint: 'Platzhalter: {title} {id} {channel} {date} {part} {part_padded} {trim_start} {trim_end} {trim_length} {date_custom="yyyy-MM-dd"}', + vodTemplatePlaceholder: '{title}.mp4', + partsTemplatePlaceholder: '{date}_Part{part_padded}.mp4', + defaultClipTemplatePlaceholder: '{date}_{part}.mp4', + templateLintOk: 'Template-Check: OK', + templateLintWarn: 'Unbekannte Platzhalter', + templateGuideButton: 'Template Guide', + templateGuideTitle: 'Dateinamen-Template Guide', + templateGuideIntro: 'Nutze Platzhalter fur Dateinamen und teste dein Muster mit einer Live-Vorschau.', + templateGuideTemplateLabel: 'Template', + templateGuideOutputLabel: 'Live-Vorschau', + templateGuideVarsTitle: 'Verfugbare Platzhalter', + templateGuideVarCol: 'Platzhalter', + templateGuideDescCol: 'Beschreibung', + templateGuideExampleCol: 'Beispiel', + templateGuideUseVod: 'VOD-Template nutzen', + templateGuideUseParts: 'Teile-Template nutzen', + templateGuideUseClip: 'Clip-Template nutzen', + templateGuideClose: 'Schliessen', + templateGuideContextVod: 'Kontext: Beispiel fur kompletten VOD-Download', + templateGuideContextParts: 'Kontext: Beispiel fur VOD-Teil', + templateGuideContextClip: 'Kontext: Beispiel fur Clip-Zuschnitt', + templateGuideContextClipLive: 'Kontext: Aktuelle Auswahl im Clip-Dialog', + runtimeMetricsTitle: 'Runtime Metrics', + runtimeMetricsRefresh: 'Aktualisieren', + runtimeMetricsExport: 'Export JSON', + runtimeMetricsAutoRefresh: 'Auto-Refresh', + runtimeMetricsLoading: 'Metriken werden geladen...', + runtimeMetricsError: 'Runtime-Metriken konnten nicht geladen werden.', + runtimeMetricsExportDone: 'Runtime-Metriken wurden exportiert.', + runtimeMetricsExportCancelled: 'Export der Runtime-Metriken abgebrochen.', + runtimeMetricsExportFailed: 'Export der Runtime-Metriken fehlgeschlagen.', + runtimeMetricQueue: 'Queue', + runtimeMetricMode: 'Modus', + runtimeMetricRetries: 'Retries', + runtimeMetricIntegrity: 'Integritatsfehler', + runtimeMetricCache: 'Cache', + runtimeMetricBandwidth: 'Bandbreite', + runtimeMetricDownloads: 'Downloads', + runtimeMetricActive: 'Aktiver Job', + runtimeMetricLastError: 'Letzte Fehlerklasse', + runtimeMetricUpdated: 'Aktualisiert', + updateTitle: 'Updates', + checkUpdates: 'Nach Updates suchen', + preflightTitle: 'System-Check', + preflightRun: 'Check ausfuhren', + preflightFix: 'Auto-Fix Tools', + preflightEmpty: 'Noch kein Check ausgefuhrt.', + preflightChecking: 'Prufe...', + preflightFixing: 'Fixe...', + preflightReady: 'Alles bereit.', + preflightInternet: 'Internet', + preflightStreamlink: 'Streamlink', + preflightFfmpeg: 'FFmpeg', + preflightFfprobe: 'FFprobe', + preflightPath: 'Download-Pfad', + debugLogTitle: 'Live Debug-Log', + refreshLog: 'Aktualisieren', + autoRefresh: 'Auto-Refresh', + notConnected: 'Nicht verbunden' + }, + status: { + noLogin: 'Ohne Login (Public Modus)', + connecting: 'Verbinde...', + connected: 'Verbunden', + connectFailedPublic: 'Verbindung fehlgeschlagen - Public Modus aktiv' + }, + tabs: { + vods: 'VODs', + clips: 'Clips', + cutter: 'Video schneiden', + merge: 'Videos zusammenfugen', + stats: 'Statistik', + archive: 'Archiv', + settings: 'Einstellungen' + }, + queue: { + empty: 'Keine Downloads in der Warteschlange', + detailStreamer: 'Streamer:', + detailDuration: 'Dauer:', + detailDate: 'Datum:', + start: 'Start', + stop: 'Pausieren', + resume: 'Fortsetzen', + statusDone: 'Abgeschlossen', + statusFailed: 'Fehlgeschlagen', + statusRunning: 'Laeuft', + statusPaused: 'Pausiert', + statusWaiting: 'Wartet', + progressError: 'Fehler', + progressReady: 'Bereit', + progressLoading: 'Lade...', + readyToDownload: 'Bereit zum Download', + started: 'Download gestartet', + done: 'Fertig', + failed: 'Download fehlgeschlagen', + speed: 'Geschwindigkeit', + eta: 'Restzeit', + part: 'Teil', + emptyAlert: 'Die Warteschlange ist leer. Fuge zuerst ein VOD oder einen Clip hinzu.', + duplicateSkipped: 'Dieser Eintrag ist bereits aktiv in der Warteschlange.', + openFile: 'Datei oeffnen', + showInFolder: 'Im Ordner zeigen', + openFileFailed: 'Datei konnte nicht geoeffnet werden (evtl. verschoben oder geloescht).', + outputFilesLabel: '{count} Ausgabedateien', + retryItem: 'Diesen Eintrag erneut versuchen', + viewChat: 'Chat ansehen', + viewChatLoading: 'Lade Chat...', + viewChatFailed: 'Chat-Datei konnte nicht gelesen werden', + chatViewerFilterPlaceholder: 'Chat filtern...', + chatViewerFilterAria: 'Chatnachrichten filtern', + viewChatCount: '{count} Nachrichten', + viewChatTruncatedSuffix: ' (gekuerzt)', + viewEvents: 'Events ansehen', + viewEventsCount: '{count} Events', + viewEventsEmpty: 'Keine Events aufgezeichnet.', + eventStartedAs: 'Gestartet als', + eventEndedAfter: 'Beendet nach', + eventTitleFromTo: 'Titel: {from} -> {to}', + eventGameFromTo: 'Game: {from} -> {to}', + statusBarSummary: '{downloading} aktiv, {pending} wartet', + ctxMoveTop: 'Nach oben verschieben', + ctxMoveBottom: 'Nach unten verschieben', + ctxCopyUrl: 'URL kopieren', + ctxOpenOnTwitch: 'Auf Twitch oeffnen', + ctxRemove: 'Aus Queue entfernen', + ctxCopiedUrl: 'URL in Zwischenablage kopiert.', + liveRecordingTitle: 'Live-Aufnahme - laeuft bis der Stream endet', + recordingHealth: { + ok: 'Gesund - Bytes fliessen', + stale: 'Stillstand - keine Bytes mehr (Netz-Hickser oder Stream endet)', + unknown: 'Warte auf ersten Segment' + }, + eventRecordingResume: 'Aufnahme fortgesetzt - Part {part} startet' + }, + profile: { + liveBadge: 'LIVE', + partner: 'Partner', + affiliate: 'Affiliate', + followers: 'Follower', + vods: 'VODs', + vodsTooltip: 'Ueber die Twitch-API sichtbare VODs dieses Kanals', + lastStream: 'Letzter Stream', + openTwitch: 'Auf Twitch oeffnen', + openTwitchTooltip: 'Diesen Kanal auf twitch.tv oeffnen', + liveCardTooltip: 'Klick um sofort eine Live-Aufnahme zu starten', + liveThumbAlt: 'Live-Vorschau', + recordNow: 'Jetzt aufnehmen', + refresh: 'Aktualisieren', + agoMinutes: 'vor {n} Min', + agoHours: 'vor {n} h', + agoDays: 'vor {n} Tagen', + agoMonths: 'vor {n} Monaten', + agoYears: 'vor {n} Jahren' + }, + streamers: { + recordLiveTitle: 'Diesen Streamer live aufnehmen (laeuft bis der Stream endet)', + liveRecordingStarted: 'Live-Aufnahme fuer {streamer} gestartet.', + liveRecordingOffline: '{streamer} ist gerade offline.', + liveRecordingAlreadyActive: 'Aufnahme von {streamer} laeuft bereits.', + liveRecordingFailed: 'Live-Aufnahme konnte nicht gestartet werden', + autoRecordTitle: 'Auto-Aufnahme: wenn dieser Streamer live geht, nimmt die App automatisch auf', + autoRecordEnabled: 'Auto-Aufnahme aktiviert fuer {streamer}. Live-Status wird geprueft...', + autoRecordDisabled: 'Auto-Aufnahme fuer {streamer} deaktiviert.', + autoVodTitle: 'Neue VODs (kuerzlich veroeffentlicht) automatisch herunterladen', + autoVodEnabled: 'Auto-VOD aktiviert fuer {streamer}. Neue VODs werden automatisch geladen.', + autoVodDisabled: 'Auto-VOD fuer {streamer} deaktiviert.', + autoVodScanQueued: '{count} neue VOD(s) automatisch eingereiht.', + autoVodScanEmpty: 'Keine neuen VODs gefunden.', + autoRecordScanTriggered: 'Manueller Scan: {count} Live-Aufnahme(n) gestartet.', + autoRecordScanEmpty: 'Manueller Scan: kein Streamer ist gerade live.', + liveNowTooltip: 'Aktuell live auf Twitch', + modalCloseAria: 'Dialog schliessen', + sidebarEmpty: 'Noch keine Streamer. Fuege oben rechts einen hinzu.', + removeAria: 'Entfernen', + cutProgressAria: 'Schnitt-Fortschritt', + mergeProgressAria: 'Merge-Fortschritt', + updateProgressAria: 'Update-Download-Fortschritt' + }, + vods: { + selectAriaLabel: 'VOD fuer Bulk-Aktion auswaehlen', + noneTitle: 'Keine VODs', + noneText: 'Wahle einen Streamer aus der Liste.', + loading: 'Lade VODs...', + notFound: 'Streamer nicht gefunden', + noResultsTitle: 'Keine VODs gefunden', + noResultsText: 'Dieser Streamer hat keine VODs.', + untitled: 'Unbenanntes VOD', + views: 'Aufrufe', + addQueue: '+ Warteschlange', + trimButton: 'VOD zuschneiden', + filterPlaceholder: 'Nach Titel filtern... (Strg+F)', + filterAria: 'VOD-Titel filtern', + filterClearTitle: 'Filter loeschen (Esc)', + filterNoMatchTitle: 'Keine Treffer', + filterNoMatchText: 'Keine VODs entsprechen dem aktuellen Filter.', + filterMatchCount: '{shown} von {total} VODs', + sortLabel: 'Sortierung:', + sortDateDesc: 'Neueste zuerst', + sortDateAsc: 'Aelteste zuerst', + sortViewsDesc: 'Meiste Aufrufe', + sortDurationDesc: 'Laengste zuerst', + sortDurationAsc: 'Kuerzeste zuerst', + bulkSelectedCount: '{count} ausgewaehlt', + bulkAddToQueue: '+ Warteschlange', + bulkAdding: 'Fuege hinzu...', + bulkClear: 'Loeschen', + bulkAddedToQueue: '{count} VODs zur Warteschlange hinzugefuegt.', + bulkAddSkipped: 'Keine VODs hinzugefuegt (bereits in Queue oder ungueltig).', + bulkMarkDownloaded: 'Als heruntergeladen markieren', + bulkUnmark: 'Markierung entfernen', + bulkMarkedDownloaded: '{count} VODs als heruntergeladen markiert.', + bulkUnmarkedDownloaded: 'Markierung von {count} VODs entfernt.', + alreadyDownloaded: 'Bereits heruntergeladen', + hideDownloaded: 'Bereits geladene ausblenden', + hideDownloadedTitle: 'VODs ausblenden, die als bereits heruntergeladen markiert sind', + openOnTwitch: 'Auf Twitch oeffnen', + ctxOpenOnTwitch: 'Auf Twitch oeffnen', + ctxCopyUrl: 'VOD-URL kopieren', + ctxCopiedUrl: 'URL in Zwischenablage kopiert.', + ctxMarkDownloaded: 'Als heruntergeladen markieren', + ctxUnmarkDownloaded: 'Markierung entfernen' + }, + clips: { + dialogTitle: 'VOD zuschneiden', + dialogStart: 'Start:', + dialogStartTime: 'Startzeit (HH:MM:SS):', + dialogEnd: 'Ende:', + dialogEndTime: 'Endzeit (HH:MM:SS):', + dialogDuration: 'Dauer: ', + dialogPartLabel: 'Start Part-Nummer (optional, fur Fortsetzung):', + dialogPartHint: 'Leer lassen = Teil 1', + dialogFormatLabel: 'Dateinamen-Format:', + dialogConfirm: 'Zur Queue hinzufuegen', + invalidDuration: 'Ungultig!', + invalidTime: 'Ungueltige Zeitangaben', + endBeforeStart: 'Endzeit muss grosser als Startzeit sein!', + outOfRange: 'Zeit ausserhalb des VOD-Bereichs!', + enterUrl: 'Bitte URL eingeben', + loadingButton: 'Lade...', + loadingStatus: 'Download laeuft...', + downloadButton: 'Clip herunterladen', + success: 'Download erfolgreich!', + errorPrefix: 'Fehler: ', + unknownError: 'Unbekannter Fehler', + formatSimple: '(Standard)', + formatTimestamp: '(mit Zeitstempel)', + formatParts: '(Parts-Format)', + formatTemplate: '(benutzerdefiniert)', + templateEmpty: 'Das Template darf im benutzerdefinierten Modus nicht leer sein.', + templatePlaceholder: '{date}_{part}.mp4', + templateHelp: 'Platzhalter: {title} {id} {channel} {date} {part} {part_padded} {trim_start} {trim_end} {trim_length} {date_custom="yyyy-MM-dd"}', + urlPlaceholder: 'https://clips.twitch.tv/... oder https://www.twitch.tv/.../clip/...', + startPartPlaceholder: 'z.B. 42' + }, + cutter: { + videoInfoFailed: 'Konnte Video-Informationen nicht lesen. FFprobe installiert?', + previewLoading: 'Lade Vorschau...', + previewUnavailable: 'Vorschau nicht verfugbar', + previewAlt: 'Vorschau', + cutting: 'Schneidet...', + cut: 'Schneiden', + cutSuccess: 'Video erfolgreich geschnitten!', + cutFailed: 'Fehler beim Schneiden des Videos.', + infoDuration: 'Dauer', + infoResolution: 'Aufloesung', + infoFps: 'FPS', + infoSelection: 'Auswahl', + startLabel: 'Start:', + endLabel: 'Ende:', + filePathPlaceholder: 'Keine Datei ausgewaehlt...' + }, + merge: { + empty: 'Keine Videos ausgewahlt', + merging: 'Zusammenfugen...', + merge: 'Zusammenfugen', + success: 'Videos erfolgreich zusammengefugt!', + failed: 'Fehler beim Zusammenfugen der Videos.', + moveUpAria: 'Nach oben verschieben', + moveDownAria: 'Nach unten verschieben', + removeAria: 'Aus Liste entfernen' + }, + mergeGroup: { + btn: 'Zusammenfugen & Splitten', + phaseDownloading: 'VOD wird heruntergeladen', + phaseMerging: 'Zusammenfugen...', + phaseSplitting: 'Part wird erstellt', + phaseCleanup: 'Aufraumen...', + needMinTwo: 'Mindestens 2 VODs auswahlen', + titleTwo: 'Merge: {title1} + {title2}', + titleMany: 'Merge: {title1} + {count} weitere', + metaLabel: '{count} VODs', + }, + updates: { + bannerDefault: 'Neue Version verfugbar!', + latest: 'Du hast die neueste Version!', + checking: 'Suche nach Updates...', + checkInProgress: 'Update-Prufung lauft bereits.', + readyToInstall: 'Update ist bereit zur Installation.', + checkFailed: 'Update-Prufung fehlgeschlagen.', + downloading: 'Wird heruntergeladen...', + downloadInProgress: 'Update-Download lauft bereits.', + downloadFailed: 'Update-Download fehlgeschlagen.', + available: 'verfugbar!', + downloadNow: 'Jetzt herunterladen', + downloadLabel: 'Download', + ready: 'bereit zur Installation!', + installNow: 'Jetzt installieren & neu starten', + modalAvailableTitle: 'Update verfugbar', + modalAvailableMessage: 'Version {version} ist verfugbar. Jetzt herunterladen?', + modalReadyTitle: 'Update bereit', + modalReadyMessage: 'Version {version} wurde heruntergeladen. Jetzt installieren und neu starten?', + modalDismiss: 'Nein', + modalDownloadConfirm: 'Ja, herunterladen', + modalInstallConfirm: 'Ja, installieren', + modalSkipVersion: 'Diese Version ueberspringen', + changelogLabel: 'Changelog', + showChangelog: 'Changelog anzeigen', + hideChangelog: 'Changelog ausblenden', + noChangelog: 'Kein Changelog verfugbar.', + releasedLabel: 'Release' + } +} as const; diff --git a/src/renderer-locale-en.ts b/src/renderer-locale-en.ts new file mode 100644 index 0000000..e0e990f --- /dev/null +++ b/src/renderer-locale-en.ts @@ -0,0 +1,516 @@ +const UI_TEXT_EN = { + appName: 'Twitch VOD Manager', + static: { + navVods: 'Twitch VODs', + navClips: 'Twitch Clips', + navCutter: 'Video Cutter', + navMerge: 'Merge Videos', + navSettings: 'Settings', + queueTitle: 'Queue', + retryFailed: 'Retry', + retryFailedHint: 'Retry failed downloads only', + healthUnknown: 'System: Unknown', + healthGood: 'System: Stable', + healthWarn: 'System: Warning', + healthBad: 'System: Problem', + clearQueue: 'Clear', + refresh: 'Refresh', + streamerPlaceholder: 'Add streamer...', + clipsHeading: 'Twitch Clip Download', + clipsInfoTitle: 'Info', + clipsInfoText: 'Supported formats:\n- https://clips.twitch.tv/ClipName\n- https://www.twitch.tv/streamer/clip/ClipName\n\nClips are saved in your download folder under "Clips/StreamerName/".', + cutterSelectTitle: 'Select video', + cutterPreviewPlaceholder: 'Select a video to see a preview', + cutterBrowse: 'Browse', + commandPaletteSearchPlaceholder: 'Search command...', + commandPaletteHint: 'Up/Down to navigate, Enter to run, Esc to close', + mergeTitle: 'Merge videos', + mergeDesc: 'Select multiple videos to merge into one file. You can change the order before merging.', + mergeAdd: '+ Add videos', + designTitle: 'Design', + themeLabel: 'Theme', + themeLight: 'Light', + languageLabel: 'Language', + languageDe: 'German', + languageEn: 'English', + apiTitle: 'Twitch API', + clientIdLabel: 'Client ID', + clientSecretLabel: 'Client Secret', + saveSettings: 'Save & Connect', + downloadSettingsTitle: 'Download Settings', + storageLabel: 'Storage Path', + openFolder: 'Open', + modeLabel: 'Download Mode', + modeFull: 'Full VOD', + modeParts: 'Split into parts', + partMinutesLabel: 'Part Length (Minutes)', + parallelDownloadsLabel: 'Parallel Downloads', + parallelDownloads1: '1 (Default)', + parallelDownloads2: '2 (Parallel)', + performanceModeLabel: 'Performance Profile', + performanceModeStability: 'Max Stability', + performanceModeBalanced: 'Balanced', + performanceModeSpeed: 'Max Speed', + smartSchedulerLabel: 'Enable smart queue scheduler', + smartSchedulerHint: 'Prefers shorter VODs and older queue entries first so the queue throughput stays steady. Disable to drain in strict insertion order.', + streamerInvalid: 'Invalid Twitch username (4-25 chars, letters/digits/underscore).', + apiHelpIntro: 'You need a Client ID and Client Secret from Twitch.', + apiHelpLinkText: 'dev.twitch.tv/console/apps', + openDebugLogFile: 'Open log file', + storageCardTitle: 'Storage', + storageCardIntro: 'Per-streamer disk usage in the current download folder. Live recordings are surfaced separately.', + storageRefresh: 'Refresh', + storageEmpty: 'Download folder is empty or unreadable.', + storageScanning: 'Scanning...', + storageSummary: 'Total: {files} files, {size} — Free disk: {free}', + storageColumnFolder: 'Folder', + storageColumnFiles: 'Files', + storageColumnTotal: 'Total', + storageColumnLive: 'Live', + storageColumnChat: 'Chat', + storageColumnActionsAria: 'Actions', + storageOpen: 'Open', + storageOtherFolders: 'Other folders in download path', + cleanupTitle: 'Auto-cleanup', + cleanupIntro: 'Move recordings older than N days to an archive folder, or delete them outright. Sibling chat files (.chat.json/.chat.jsonl) travel with the video.', + cleanupEnabledLabel: 'Enable auto-cleanup', + cleanupDaysLabel: 'Age threshold (days)', + cleanupTargetLabel: 'Scope', + cleanupTargetLive: 'Live recordings only', + cleanupTargetAll: 'All recordings', + cleanupActionLabel: 'Action', + cleanupActionArchive: 'Move to archive folder', + cleanupActionDelete: 'Delete', + cleanupDryRun: 'Preview', + cleanupRunNow: 'Run now', + cleanupReportPreview: 'Would touch {count} files (~{size}). No files have been moved or deleted.', + cleanupReportDone: 'Processed {count} files, freed ~{size}.{failed}', + cleanupReportFailedSuffix: ' {failed} failed.', + cleanupReportEmpty: 'No recordings older than {days} days found.', + discordCardTitle: 'Discord webhook', + discordCardIntro: 'Send notifications to a Discord channel via webhook — handy for multi-device setups or a dedicated archive machine.', + discordWebhookUrlLabel: 'Webhook URL', + discordNotifyLiveStartLabel: 'Notify on live recording start', + discordNotifyLiveEndLabel: 'Notify on live recording end', + discordNotifyVodCompleteLabel: 'Notify on completed VOD download', + autoResumeLiveRecordingLabel: 'Auto-resume live recording if streamlink crashes (max 5 retries)', + autoMergeResumedPartsLabel: 'Auto-merge resumed-recording parts into one file (ffmpeg concat, no re-encode)', + deletePartsAfterMergeLabel: 'Delete individual parts after successful merge', + discordNotifyVodAutoQueuedLabel: 'Notify when a VOD gets auto-queued', + autoVodCardTitle: 'Auto-VOD download', + autoVodCardIntro: 'Streamers with the VOD toggle on are scanned for new Twitch VODs at the interval set here. New VODs within the age window are added to the download queue automatically.', + autoVodPollMinutesLabel: 'Poll interval (minutes)', + autoVodMaxAgeHoursLabel: 'Max age (hours)', + autoVodScanNow: 'Scan now', + autoRecordScanNow: 'Check live status', + statsTitle: 'Archive statistics', + statsIntro: 'Aggregated across the download folder. Live recordings live under {streamer}/live/, VOD downloads under {streamer}/. Scan time scales with file count.', + statsRefresh: 'Refresh', + statsScanning: 'Scanning...', + statsScannedAt: 'Last scan', + statsSummaryTitle: 'Overview', + statsTopStreamersTitle: 'Top streamers (by size)', + statsActivityTitle: 'Activity (last 30 days)', + statsSizeBucketsTitle: 'Recording-size distribution', + statsTotalRecordings: 'Recordings total', + statsLiveRecordings: 'Live recordings', + statsVodRecordings: 'VOD downloads', + statsStreamers: 'Streamers', + statsAvgSize: 'Avg. recording size', + statsChatFiles: 'Chat files', + statsFiles: 'files', + statsActivityEmpty: 'No recordings in the last 30 days.', + statsActivitySummary: '{count} recordings - {size} in the last 30 days', + statsEmpty: 'No data.', + statsNoRoot: 'Download folder not found. Set a download path in Settings first.', + navStats: 'Statistics', + navArchive: 'Archive', + archiveTitle: 'Search archive', + archiveIntro: 'Search by filename, streamer, or date string. Hits show recordings (Live + VOD); related chat and events files appear as companion buttons.', + archiveAllTypes: 'All types', + archiveTypeLive: 'Live recordings', + archiveTypeVod: 'VOD downloads', + archiveAllStreamers: 'All streamers', + archiveSortDateDesc: 'Newest first', + archiveSortDateAsc: 'Oldest first', + archiveSortSizeDesc: 'Largest first', + archiveSortSizeAsc: 'Smallest first', + archiveSortNameAsc: 'Name (A-Z)', + archiveSearchBtn: 'Search', + archiveSearching: 'Scanning...', + archiveSummary: '{matchCount} matches (scanned {scanned} files)', + archiveSummaryTruncated: '{matchCount} matches (scanned {scanned} files, showing {shown} - tighten the query for more)', + archiveNoMatches: 'No matches.', + archiveNoRoot: 'Download folder not found. Set a download path in Settings first.', + archiveSearchPlaceholder: 'Search...', + archiveSearchAria: 'Search archive', + archiveOpen: 'Open', + archiveShowInFolder: 'Folder', + archiveViewChat: 'Chat', + archiveViewEvents: 'Events', + backupCardTitle: 'Backup & Maintenance', + backupCardIntro: 'Back up your configuration, restore it on another machine, or reset the list of already-downloaded VODs.', + exportConfig: 'Export config', + importConfig: 'Import config', + resetDownloadedIds: 'Reset downloaded list', + configExported: 'Configuration exported.', + configExportFailed: 'Configuration export failed.', + configImported: 'Configuration imported. Some changes may need a restart.', + configImportFailed: 'Configuration import failed.', + resetDownloadedConfirm: 'Reset the downloaded-VODs list? Cards will lose the green check mark, but no files are deleted.', + resetDownloadedDone: 'Cleared {count} entries from the downloaded list.', + duplicatePreventionLabel: 'Prevent duplicate queue entries', + persistQueueLabel: 'Keep queue between app restarts', + autoResumeQueueLabel: 'Auto-resume the queue on startup', + autoResumeQueueHint: 'When enabled and the persisted queue has pending entries, downloads kick off ~5 seconds after the window opens. Disable to require an explicit Start click.', + notifyEachCompletionLabel: 'Notify on every completed download', + notifyEachCompletionHint: 'Off by default — long queues would otherwise spam the OS notifications panel. The end-of-queue summary notification fires either way.', + streamlinkDisableAdsLabel: 'Skip Twitch ads while downloading', + streamlinkDisableAdsHint: 'Passes --twitch-disable-ads to streamlink so mid-roll ads do not get embedded into the VOD output. Recommended on.', + downloadChatReplayLabel: 'Save chat replay alongside each VOD (.chat.json)', + downloadChatReplayHint: 'After a VOD download completes, fetches the public chat replay via Twitch GQL and saves it as JSON next to the video. Twitch keeps chat replay only as long as the VOD itself.', + captureLiveChatLabel: 'Capture live chat during recording (.chat.jsonl)', + captureLiveChatHint: 'Opens an anonymous IRC connection to Twitch chat during a live recording and appends every message to a sibling .chat.jsonl file (JSON Lines, one message per line) so a long capture can be killed mid-stream without corrupting earlier data.', + logStreamEventsLabel: 'Log stream events during live recording (.events.jsonl)', + logStreamEventsHint: 'Polls the streamer once a minute and writes title / game changes to a sibling .events.jsonl file. Useful for seeking inside long archived streams ("when did he switch to CS:GO?"). Cheap — one extra Helix/GQL hit per minute per active recording.', + streamlinkQualityLabel: 'Stream quality', + streamlinkQualityHint: 'Streamlink will try this quality first; if the VOD does not offer it, falls back to "best".', + streamlinkQualityBest: 'Best (default)', + streamlinkQualitySource: 'Source (original)', + streamlinkQualityAudio: 'Audio only', + downloadPathNotWritable: 'Download folder is not writable. Pick another folder or grant write permission.', + streamerSectionTitle: 'Streamer', + streamerListFilterPlaceholder: 'Filter...', + streamerListFilterAria: 'Filter streamer list', + streamerAddAriaLabel: 'Add streamer', + streamerBulkRemoveTitle: 'Remove all (or filtered)', + streamerBulkRemoveAll: 'Remove all {count} streamers from the list?', + streamerBulkRemoveFiltered: 'Remove the {count} matching streamer(s) from the list?', + metadataCacheMinutesLabel: 'Metadata Cache (Minutes)', + filenameTemplatesTitle: 'Filename Templates', + vodTemplateLabel: 'VOD Template', + partsTemplateLabel: 'VOD Part Template', + defaultClipTemplateLabel: 'Clip Template', + filenameTemplateHint: 'Placeholders: {title} {id} {channel} {date} {part} {part_padded} {trim_start} {trim_end} {trim_length} {date_custom="yyyy-MM-dd"}', + vodTemplatePlaceholder: '{title}.mp4', + partsTemplatePlaceholder: '{date}_Part{part_padded}.mp4', + defaultClipTemplatePlaceholder: '{date}_{part}.mp4', + templateLintOk: 'Template check: OK', + templateLintWarn: 'Unknown placeholder(s)', + templateGuideButton: 'Template Guide', + templateGuideTitle: 'Filename Template Guide', + templateGuideIntro: 'Use placeholders for filenames and test your pattern with a live preview.', + templateGuideTemplateLabel: 'Template', + templateGuideOutputLabel: 'Live preview', + templateGuideVarsTitle: 'Available placeholders', + templateGuideVarCol: 'Placeholder', + templateGuideDescCol: 'Description', + templateGuideExampleCol: 'Example', + templateGuideUseVod: 'Use VOD template', + templateGuideUseParts: 'Use part template', + templateGuideUseClip: 'Use clip template', + templateGuideClose: 'Close', + templateGuideContextVod: 'Context: Sample full VOD download', + templateGuideContextParts: 'Context: Sample split VOD part', + templateGuideContextClip: 'Context: Sample clip trim', + templateGuideContextClipLive: 'Context: Current clip dialog selection', + runtimeMetricsTitle: 'Runtime Metrics', + runtimeMetricsRefresh: 'Refresh', + runtimeMetricsExport: 'Export JSON', + runtimeMetricsAutoRefresh: 'Auto refresh', + runtimeMetricsLoading: 'Loading metrics...', + runtimeMetricsError: 'Could not load runtime metrics.', + runtimeMetricsExportDone: 'Runtime metrics exported successfully.', + runtimeMetricsExportCancelled: 'Runtime metrics export cancelled.', + runtimeMetricsExportFailed: 'Runtime metrics export failed.', + runtimeMetricQueue: 'Queue', + runtimeMetricMode: 'Mode', + runtimeMetricRetries: 'Retries', + runtimeMetricIntegrity: 'Integrity failures', + runtimeMetricCache: 'Cache', + runtimeMetricBandwidth: 'Bandwidth', + runtimeMetricDownloads: 'Downloads', + runtimeMetricActive: 'Active item', + runtimeMetricLastError: 'Last error class', + runtimeMetricUpdated: 'Updated', + updateTitle: 'Updates', + checkUpdates: 'Check for updates', + preflightTitle: 'System Check', + preflightRun: 'Run check', + preflightFix: 'Auto-fix tools', + preflightEmpty: 'No checks run yet.', + preflightChecking: 'Checking...', + preflightFixing: 'Fixing...', + preflightReady: 'Everything is ready.', + preflightInternet: 'Internet', + preflightStreamlink: 'Streamlink', + preflightFfmpeg: 'FFmpeg', + preflightFfprobe: 'FFprobe', + preflightPath: 'Download path', + debugLogTitle: 'Live Debug Log', + refreshLog: 'Refresh', + autoRefresh: 'Auto refresh', + notConnected: 'Not connected' + }, + status: { + noLogin: 'No login (public mode)', + connecting: 'Connecting...', + connected: 'Connected', + connectFailedPublic: 'Connection failed - public mode active' + }, + tabs: { + vods: 'VODs', + clips: 'Clips', + cutter: 'Video Cutter', + merge: 'Merge Videos', + stats: 'Statistics', + archive: 'Archive', + settings: 'Settings' + }, + queue: { + empty: 'No downloads in queue', + detailStreamer: 'Streamer:', + detailDuration: 'Duration:', + detailDate: 'Date:', + start: 'Start', + stop: 'Pause', + resume: 'Resume', + statusDone: 'Completed', + statusFailed: 'Failed', + statusRunning: 'Running', + statusPaused: 'Paused', + statusWaiting: 'Waiting', + progressError: 'Error', + progressReady: 'Ready', + progressLoading: 'Loading...', + readyToDownload: 'Ready to download', + started: 'Download started', + done: 'Done', + failed: 'Download failed', + speed: 'Speed', + eta: 'ETA', + part: 'Part', + emptyAlert: 'Queue is empty. Add a VOD or clip first.', + duplicateSkipped: 'This item is already active in the queue.', + openFile: 'Open file', + showInFolder: 'Show in folder', + openFileFailed: 'Could not open the file (it may have been moved or deleted).', + outputFilesLabel: '{count} output files', + retryItem: 'Retry this item', + viewChat: 'View chat', + viewChatLoading: 'Loading chat...', + viewChatFailed: 'Could not read chat file', + chatViewerFilterPlaceholder: 'Filter chat...', + chatViewerFilterAria: 'Filter chat messages', + viewChatCount: '{count} messages', + viewChatTruncatedSuffix: ' (truncated)', + viewEvents: 'View events', + viewEventsCount: '{count} events', + viewEventsEmpty: 'No events recorded.', + eventStartedAs: 'Started as', + eventEndedAfter: 'Ended after', + eventTitleFromTo: 'Title: {from} -> {to}', + eventGameFromTo: 'Game: {from} -> {to}', + statusBarSummary: '{downloading} dl, {pending} queued', + ctxMoveTop: 'Move to top', + ctxMoveBottom: 'Move to bottom', + ctxCopyUrl: 'Copy URL', + ctxOpenOnTwitch: 'Open on Twitch', + ctxRemove: 'Remove from queue', + ctxCopiedUrl: 'URL copied to clipboard.', + liveRecordingTitle: 'Live recording — captures until the stream ends', + recordingHealth: { + ok: 'Healthy — bytes flowing', + stale: 'Stalled — no bytes recently (network blip or stream ending)', + unknown: 'Waiting for first segment' + }, + eventRecordingResume: 'Recording resumed — starting part {part}' + }, + profile: { + liveBadge: 'LIVE', + partner: 'Partner', + affiliate: 'Affiliate', + followers: 'Followers', + vods: 'VODs', + vodsTooltip: 'VODs visible via Twitch API for this channel', + lastStream: 'Last stream', + openTwitch: 'Open on Twitch', + openTwitchTooltip: 'Open this channel on twitch.tv', + liveCardTooltip: 'Click to start a live recording right now', + liveThumbAlt: 'Live preview', + recordNow: 'Record now', + refresh: 'Refresh', + agoMinutes: '{n} min ago', + agoHours: '{n} h ago', + agoDays: '{n} d ago', + agoMonths: '{n} mo ago', + agoYears: '{n} y ago' + }, + streamers: { + recordLiveTitle: 'Record this streamer live (captures until stream ends)', + liveRecordingStarted: 'Live recording started for {streamer}.', + liveRecordingOffline: '{streamer} is offline right now.', + liveRecordingAlreadyActive: 'Already recording {streamer}.', + liveRecordingFailed: 'Could not start live recording', + autoRecordTitle: 'Auto-record: when this streamer goes live the app records automatically', + autoRecordEnabled: 'Auto-record enabled for {streamer}. Polling for live state...', + autoRecordDisabled: 'Auto-record disabled for {streamer}.', + autoVodTitle: 'Auto-download new VODs (recently published) for this streamer', + autoVodEnabled: 'Auto-VOD enabled for {streamer}. Will pick up new VODs.', + autoVodDisabled: 'Auto-VOD disabled for {streamer}.', + autoVodScanQueued: '{count} new VOD(s) auto-queued.', + autoVodScanEmpty: 'No new VODs found.', + autoRecordScanTriggered: 'Manual scan: {count} live recording(s) started.', + autoRecordScanEmpty: 'Manual scan: no streamers currently live.', + liveNowTooltip: 'Currently live on Twitch', + modalCloseAria: 'Close dialog', + sidebarEmpty: 'No streamers yet. Add one via the input at the top right.', + removeAria: 'Remove', + cutProgressAria: 'Cut progress', + mergeProgressAria: 'Merge progress', + updateProgressAria: 'Update download progress' + }, + vods: { + selectAriaLabel: 'Select VOD for bulk action', + noneTitle: 'No VODs', + noneText: 'Select a streamer from the list.', + loading: 'Loading VODs...', + notFound: 'Streamer not found', + noResultsTitle: 'No VODs found', + noResultsText: 'This streamer has no VODs.', + untitled: 'Untitled VOD', + views: 'views', + addQueue: '+ Queue', + trimButton: 'Trim VOD', + filterPlaceholder: 'Filter by title... (Ctrl+F)', + filterAria: 'Filter VOD titles', + filterClearTitle: 'Clear filter (Esc)', + filterNoMatchTitle: 'No matches', + filterNoMatchText: 'No VODs match the current filter.', + filterMatchCount: '{shown} of {total} VODs', + sortLabel: 'Sort:', + sortDateDesc: 'Newest first', + sortDateAsc: 'Oldest first', + sortViewsDesc: 'Most viewed', + sortDurationDesc: 'Longest first', + sortDurationAsc: 'Shortest first', + bulkSelectedCount: '{count} selected', + bulkAddToQueue: '+ Queue', + bulkAdding: 'Adding...', + bulkClear: 'Clear', + bulkAddedToQueue: 'Added {count} VODs to the queue.', + bulkAddSkipped: 'No VODs were added (already in queue or invalid).', + bulkMarkDownloaded: 'Mark as downloaded', + bulkUnmark: 'Unmark', + bulkMarkedDownloaded: 'Marked {count} VODs as downloaded.', + bulkUnmarkedDownloaded: 'Removed {count} VODs from the downloaded list.', + alreadyDownloaded: 'Already downloaded', + hideDownloaded: 'Hide downloaded', + hideDownloadedTitle: 'Hide VODs that are marked as already downloaded', + openOnTwitch: 'Open on Twitch', + ctxOpenOnTwitch: 'Open on Twitch', + ctxCopyUrl: 'Copy VOD URL', + ctxCopiedUrl: 'URL copied to clipboard.', + ctxMarkDownloaded: 'Mark as downloaded', + ctxUnmarkDownloaded: 'Unmark downloaded' + }, + clips: { + dialogTitle: 'Trim VOD', + dialogStart: 'Start:', + dialogStartTime: 'Start time (HH:MM:SS):', + dialogEnd: 'End:', + dialogEndTime: 'End time (HH:MM:SS):', + dialogDuration: 'Duration: ', + dialogPartLabel: 'Start part number (optional, for continuation):', + dialogPartHint: 'Leave empty = part 1', + dialogFormatLabel: 'Filename format:', + dialogConfirm: 'Add to queue', + invalidDuration: 'Invalid!', + invalidTime: 'Invalid time values', + endBeforeStart: 'End time must be greater than start time!', + outOfRange: 'Time is outside VOD range!', + enterUrl: 'Please enter a URL', + loadingButton: 'Loading...', + loadingStatus: 'Downloading...', + downloadButton: 'Download clip', + success: 'Download successful!', + errorPrefix: 'Error: ', + unknownError: 'Unknown error', + formatSimple: '(default)', + formatTimestamp: '(with timestamp)', + formatParts: '(parts naming)', + formatTemplate: '(custom template)', + templateEmpty: 'Template cannot be empty in custom template mode.', + templatePlaceholder: '{date}_{part}.mp4', + templateHelp: 'Placeholders: {title} {id} {channel} {date} {part} {part_padded} {trim_start} {trim_end} {trim_length} {date_custom="yyyy-MM-dd"}', + urlPlaceholder: 'https://clips.twitch.tv/... or https://www.twitch.tv/.../clip/...', + startPartPlaceholder: 'e.g. 42' + }, + cutter: { + videoInfoFailed: 'Could not read video info. Is FFprobe installed?', + previewLoading: 'Loading preview...', + previewUnavailable: 'Preview unavailable', + previewAlt: 'Preview', + cutting: 'Cutting...', + cut: 'Cut', + cutSuccess: 'Video cut successfully!', + cutFailed: 'Failed to cut video.', + infoDuration: 'Duration', + infoResolution: 'Resolution', + infoFps: 'FPS', + infoSelection: 'Selection', + startLabel: 'Start:', + endLabel: 'End:', + filePathPlaceholder: 'No file selected...' + }, + merge: { + empty: 'No videos selected', + merging: 'Merging...', + merge: 'Merge', + success: 'Videos merged successfully!', + failed: 'Failed to merge videos.', + moveUpAria: 'Move up', + moveDownAria: 'Move down', + removeAria: 'Remove from list' + }, + mergeGroup: { + btn: 'Merge & Split', + phaseDownloading: 'Downloading VOD', + phaseMerging: 'Merging...', + phaseSplitting: 'Splitting Part', + phaseCleanup: 'Cleaning up...', + needMinTwo: 'Select at least 2 VODs', + titleTwo: 'Merge: {title1} + {title2}', + titleMany: 'Merge: {title1} + {count} more', + metaLabel: '{count} VODs', + }, + updates: { + bannerDefault: 'New version available!', + latest: 'You are on the latest version!', + checking: 'Checking for updates...', + checkInProgress: 'Update check is already running.', + readyToInstall: 'Update is ready to install.', + checkFailed: 'Update check failed.', + downloading: 'Downloading...', + downloadInProgress: 'Update download is already running.', + downloadFailed: 'Update download failed.', + available: 'available!', + downloadNow: 'Download now', + downloadLabel: 'Download', + ready: 'ready to install!', + installNow: 'Install now & restart', + modalAvailableTitle: 'Update available', + modalAvailableMessage: 'Version {version} is available. Download it now?', + modalReadyTitle: 'Update ready', + modalReadyMessage: 'Version {version} has been downloaded. Install and restart now?', + modalDismiss: 'No', + modalDownloadConfirm: 'Yes, download', + modalInstallConfirm: 'Yes, install', + modalSkipVersion: 'Skip this version', + changelogLabel: 'Changelog', + showChangelog: 'Show changelog', + hideChangelog: 'Hide changelog', + noChangelog: 'No changelog available.', + releasedLabel: 'Release' + } +} as const; diff --git a/src/renderer-profile.ts b/src/renderer-profile.ts new file mode 100644 index 0000000..09d8d03 --- /dev/null +++ b/src/renderer-profile.ts @@ -0,0 +1,218 @@ +// Profile-header renderer. Owns the streamerProfileHeader div above the +// VOD grid: hidden when no streamer is selected, skeleton while loading, +// full card once profile data is back. Smooth fade-in is in CSS. + +let activeProfileRequestId = 0; + +function formatProfileFollowers(count: number | null): string { + if (count == null) return '–'; + if (count >= 1_000_000) return `${(count / 1_000_000).toFixed(count >= 10_000_000 ? 0 : 1)}M`; + if (count >= 1_000) return `${(count / 1_000).toFixed(count >= 10_000 ? 0 : 1)}K`; + return String(count); +} + +function formatLastStreamAgo(iso: string | null): string { + if (!iso) return '–'; + const ms = Date.now() - new Date(iso).getTime(); + if (!Number.isFinite(ms) || ms < 0) return '–'; + const minutes = Math.floor(ms / 60_000); + if (minutes < 60) return UI_TEXT.profile.agoMinutes.replace('{n}', String(minutes)); + const hours = Math.floor(minutes / 60); + if (hours < 24) return UI_TEXT.profile.agoHours.replace('{n}', String(hours)); + const days = Math.floor(hours / 24); + if (days < 30) return UI_TEXT.profile.agoDays.replace('{n}', String(days)); + const months = Math.floor(days / 30); + if (months < 12) return UI_TEXT.profile.agoMonths.replace('{n}', String(months)); + const years = Math.floor(days / 365); + return UI_TEXT.profile.agoYears.replace('{n}', String(years)); +} + +function hideStreamerProfileHeader(): void { + const el = document.getElementById('streamerProfileHeader'); + if (!el) return; + el.classList.add('is-hidden'); + applyHtml(el, ''); +} + +function renderStreamerProfileSkeleton(login: string): void { + const el = document.getElementById('streamerProfileHeader'); + if (!el) return; + el.classList.remove('is-live', 'is-hidden'); + el.classList.add('streamer-profile-skeleton'); + applyHtml(el, ` +
+
+
+
+
+
+
+
+
+
+
+
+
+ `); +} + +function renderStreamerProfileCard(p: StreamerProfile): void { + const el = document.getElementById('streamerProfileHeader'); + if (!el) return; + el.classList.remove('streamer-profile-skeleton', 'is-hidden'); + if (p.isLive) el.classList.add('is-live'); else el.classList.remove('is-live'); + + const safeLogin = p.login.replace(/'/g, "\\'"); + const safeUrl = p.twitchUrl.replace(/'/g, "\\'"); + + const avatarBlock = p.avatarUrl + ? `${escapeHtml(p.displayName)}` + : `
${escapeHtml((p.displayName || p.login || '?').slice(0, 1).toUpperCase())}
`; + + const badges: string[] = []; + if (p.broadcasterType === 'partner') badges.push(`${escapeHtml(UI_TEXT.profile.partner)}`); + if (p.broadcasterType === 'affiliate') badges.push(`${escapeHtml(UI_TEXT.profile.affiliate)}`); + + const bio = p.description + ? `
${escapeHtml(p.description)}
` + : ''; + + const followersStat = ` +
+ + ${escapeHtml(formatProfileFollowers(p.followerCount))} ${escapeHtml(UI_TEXT.profile.followers)} +
`; + const vodsStat = ` +
+ + ${p.vodCount} ${escapeHtml(UI_TEXT.profile.vods)} +
`; + const lastStreamStat = ` +
+ + ${escapeHtml(UI_TEXT.profile.lastStream)}: ${escapeHtml(formatLastStreamAgo(p.lastStreamAt))} +
`; + + // Banner-as-background — set inline so the URL stays per-streamer. + // The darkening gradient is handled by the .streamer-profile-header::before + // pseudo so the banner itself stays bright and unfiltered here. + const bannerStyle = p.bannerUrl + ? `background-image: url("${p.bannerUrl.replace(/"/g, '%22')}");` + : ''; + + // Live preview block — only when currently live. Big card with + // current preview frame + viewer count + title + game + record CTA. + const liveCard = p.isLive + ? ` +
+ ${p.currentStreamPreviewUrl + ? `${escapeHtml(UI_TEXT.profile.liveThumbAlt)}` + : `
`} +
+
+ ${escapeHtml(UI_TEXT.profile.liveBadge)} + ${typeof p.currentStreamViewers === 'number' ? ` ${escapeHtml(formatProfileFollowers(p.currentStreamViewers))}` : ''} +
+ ${p.currentTitle ? `
${escapeHtml(p.currentTitle)}
` : ''} + ${p.currentGame ? `
${escapeHtml(p.currentGame)}
` : ''} + +
+
+ ` : ''; + + applyHtml(el, ` + ${bannerStyle ? `
` : ''} +
+
+ ${avatarBlock} +
+
+
+ ${escapeHtml(p.displayName)} + + ${badges.join('')} +
+ ${bio} +
+ ${followersStat} + ${vodsStat} + ${lastStreamStat} +
+
+
+ + +
+
+ ${liveCard} + `); +} + +function onProfileLivePreviewError(img: HTMLImageElement): void { + const parent = img.parentElement; + if (!parent) return; + const fallback = document.createElement('div'); + fallback.className = 'streamer-profile-live-thumb-fallback'; + parent.replaceChild(fallback, img); +} + +function triggerLiveRecordingFromProfile(login: string): void { + const fn = (window as unknown as { triggerLiveRecording?: (login: string) => Promise }).triggerLiveRecording; + if (typeof fn === 'function') void fn(login); +} + +async function loadStreamerProfile(login: string, forceRefresh = false): Promise { + if (!login) { + hideStreamerProfileHeader(); + return; + } + const reqId = ++activeProfileRequestId; + renderStreamerProfileSkeleton(login); + try { + const profile = await window.api.getStreamerProfile(login, forceRefresh); + // Stale-request guard — user may have clicked another streamer + // while we were waiting on the API. + if (reqId !== activeProfileRequestId) return; + if (!profile) { + hideStreamerProfileHeader(); + return; + } + renderStreamerProfileCard(profile); + } catch (_) { + if (reqId === activeProfileRequestId) hideStreamerProfileHeader(); + } +} + +function refreshStreamerProfile(login: string): void { + void loadStreamerProfile(login, true); +} + +function openTwitchChannel(url: string): void { + void window.api.openExternal(url); +} + +function onProfileAvatarError(img: HTMLImageElement): void { + // Avatar URL hit a 404 or CORS oddity. Swap to the fallback letter + // tile so we don't end up with a broken-image icon. + const parent = img.parentElement; + if (!parent) return; + const fallback = document.createElement('div'); + fallback.className = 'streamer-profile-avatar-fallback'; + const alt = img.getAttribute('alt') || ''; + fallback.textContent = (alt || '?').slice(0, 1).toUpperCase(); + parent.replaceChild(fallback, img); +} + +(window as unknown as { + loadStreamerProfile: typeof loadStreamerProfile; + refreshStreamerProfile: typeof refreshStreamerProfile; + hideStreamerProfileHeader: typeof hideStreamerProfileHeader; + openTwitchChannel: typeof openTwitchChannel; + onProfileAvatarError: typeof onProfileAvatarError; +}).loadStreamerProfile = loadStreamerProfile; +(window as unknown as { refreshStreamerProfile: typeof refreshStreamerProfile }).refreshStreamerProfile = refreshStreamerProfile; +(window as unknown as { hideStreamerProfileHeader: typeof hideStreamerProfileHeader }).hideStreamerProfileHeader = hideStreamerProfileHeader; +(window as unknown as { openTwitchChannel: typeof openTwitchChannel }).openTwitchChannel = openTwitchChannel; +(window as unknown as { onProfileAvatarError: typeof onProfileAvatarError }).onProfileAvatarError = onProfileAvatarError; +(window as unknown as { onProfileLivePreviewError: typeof onProfileLivePreviewError }).onProfileLivePreviewError = onProfileLivePreviewError; +(window as unknown as { triggerLiveRecordingFromProfile: typeof triggerLiveRecordingFromProfile }).triggerLiveRecordingFromProfile = triggerLiveRecordingFromProfile; diff --git a/src/renderer-queue.ts b/src/renderer-queue.ts new file mode 100644 index 0000000..a85138b --- /dev/null +++ b/src/renderer-queue.ts @@ -0,0 +1,585 @@ +function renderRecordingHealthBadge(health: 'ok' | 'stale' | 'unknown' | undefined): string { + if (!health) return ''; + const labels = UI_TEXT.queue.recordingHealth || { ok: 'Healthy', stale: 'Stalled', unknown: 'Pending data' }; + const cls = health === 'ok' ? 'health-ok' : (health === 'stale' ? 'health-stale' : 'health-unknown'); + const title = labels[health] || ''; + return ``; +} + +function renderQueueItemFileActions(item: QueueItem): string { + if (item.status !== 'completed' || !item.outputFiles || item.outputFiles.length === 0) { + return ''; + } + + const first = item.outputFiles[0]; + if (typeof first !== 'string' || !first) return ''; + const safeFirst = escapeHtml(first); + const safeFirstAttr = first.replace(/'/g, "\\'").replace(/"/g, '"'); + const buttons: string[] = []; + + // "Open file" only makes sense when there's exactly one output (a clip / + // full VOD download). For multi-part downloads "open the first part" is + // surprising — the user almost always wants the folder. + if (item.outputFiles.length === 1) { + buttons.push(``); + } + buttons.push(``); + + // Surface a "View chat" button when a sibling chat file exists in the + // outputs list. Single click opens the in-app viewer modal. + const chatFile = item.outputFiles.find((f) => /\.chat\.json(l)?$/i.test(f)); + if (chatFile) { + const safeChatAttr = chatFile.replace(/'/g, "\\'").replace(/"/g, '"'); + buttons.push(``); + } + + // Same pattern for the .events.jsonl sidecar — title/game change timeline. + const eventsFile = item.outputFiles.find((f) => /\.events\.jsonl$/i.test(f)); + if (eventsFile) { + const safeEventsAttr = eventsFile.replace(/'/g, "\\'").replace(/"/g, '"'); + buttons.push(``); + } + + const fileLabel = item.outputFiles.length === 1 + ? safeFirst + : `${escapeHtml(UI_TEXT.queue.outputFilesLabel.replace('{count}', String(item.outputFiles.length)))}`; + + return ` +
+ ${buttons.join('')} + ${fileLabel} +
+ `; +} + +async function invokeOpenFile(filePath: string): Promise { + const ok = await window.api.openFile(filePath); + if (!ok) { + const toast = (window as unknown as { showAppToast?: (msg: string, kind?: 'info' | 'warn') => void }).showAppToast; + if (toast) toast(UI_TEXT.queue.openFileFailed, 'warn'); + } +} + +async function invokeShowInFolder(filePath: string): Promise { + const ok = await window.api.showInFolder(filePath); + if (!ok) { + const toast = (window as unknown as { showAppToast?: (msg: string, kind?: 'info' | 'warn') => void }).showAppToast; + if (toast) toast(UI_TEXT.queue.openFileFailed, 'warn'); + } +} + +function buildQueueFingerprint(url: string, streamer: string, date: string, customClip?: CustomClip): string { + const clipFingerprint = customClip + ? [ + 'clip', + customClip.startSec, + customClip.durationSec, + customClip.startPart, + customClip.filenameFormat, + (customClip.filenameTemplate || '').trim().toLowerCase() + ].join(':') + : 'vod'; + + return [ + (url || '').trim().toLowerCase().replace(/^https?:\/\/(www\.)?/, ''), + (streamer || '').trim().toLowerCase(), + (date || '').trim(), + clipFingerprint + ].join('|'); +} + +let lastQueueRenderFingerprint = ''; + +function getQueueRenderFingerprint(items: QueueItem[]): string { + const lang = typeof currentLanguage === 'string' ? currentLanguage : 'en'; + const pieces = items.map((item) => [ + item.id, + item.status, + Math.round((Number(item.progress) || 0) * 10), + item.currentPart || 0, + item.totalParts || 0, + item.speed || '', + item.eta || '', + item.progressStatus || '', + item.last_error || '', + item.mergeGroup?.mergePhase || '' + ].join(':')); + + return `${lang}|${selectedQueueIds.join(',')}|${[...expandedQueueIds].join(',')}|${pieces.join('|')}`; +} + +function hasActiveQueueDuplicate(url: string, streamer: string, date: string, customClip?: CustomClip): boolean { + const target = buildQueueFingerprint(url, streamer, date, customClip); + return queue.some((item) => { + if (item.status !== 'pending' && item.status !== 'downloading' && item.status !== 'paused') { + return false; + } + + return buildQueueFingerprint(item.url, item.streamer, item.date, item.customClip) === target; + }); +} + +async function addToQueue(url: string, title: string, date: string, streamer: string, duration: string): Promise { + if ((config.prevent_duplicate_downloads as boolean) !== false && hasActiveQueueDuplicate(url, streamer, date)) { + alert(UI_TEXT.queue.duplicateSkipped); + return; + } + + queue = await window.api.addToQueue({ + url, + title, + date, + streamer, + duration_str: duration + }); + renderQueue(); +} + +async function removeFromQueue(id: string): Promise { + queue = await window.api.removeFromQueue(id); + renderQueue(); +} + +async function clearCompleted(): Promise { + queue = await window.api.clearCompleted(); + renderQueue(); +} + +async function retryFailedDownloads(): Promise { + queue = await window.api.retryFailedDownloads(); + renderQueue(); +} + +async function retryQueueItem(id: string): Promise { + queue = await window.api.retryQueueItem(id); + renderQueue(); +} + +let queueContextMenuInitialized = false; +let activeQueueContextMenu: HTMLElement | null = null; + +function closeQueueContextMenu(): void { + if (!activeQueueContextMenu) return; + activeQueueContextMenu.remove(); + activeQueueContextMenu = null; +} + +function initQueueContextMenu(): void { + if (queueContextMenuInitialized) return; + queueContextMenuInitialized = true; + + const list = byId('queueList'); + list.addEventListener('contextmenu', (e: MouseEvent) => { + const itemEl = (e.target as HTMLElement).closest('.queue-item') as HTMLElement | null; + if (!itemEl) return; + const id = itemEl.dataset.id; + if (!id) return; + const item = queue.find((i) => i.id === id); + if (!item) return; + e.preventDefault(); + showQueueContextMenu(e.clientX, e.clientY, item); + }); +} + +function showQueueContextMenu(x: number, y: number, item: QueueItem): void { + closeQueueContextMenu(); + + const menu = document.createElement('div'); + menu.className = 'context-menu'; + menu.setAttribute('role', 'menu'); + + const makeItem = (label: string, onClick: () => void, disabled = false): HTMLElement => { + const el = document.createElement('div'); + el.textContent = label; + el.className = 'context-menu-item' + (disabled ? ' disabled' : ''); + el.setAttribute('role', 'menuitem'); + if (disabled) el.setAttribute('aria-disabled', 'true'); + if (!disabled) { + el.addEventListener('click', () => { + try { onClick(); } finally { closeQueueContextMenu(); } + }); + } + return el; + }; + + const makeSeparator = (): HTMLElement => { + const sep = document.createElement('div'); + sep.className = 'context-menu-separator'; + sep.setAttribute('role', 'separator'); + return sep; + }; + + const isPending = item.status === 'pending' || item.status === 'paused'; + const isFailed = item.status === 'error'; + const isCompleted = item.status === 'completed'; + + if (isPending) { + menu.appendChild(makeItem(UI_TEXT.queue.ctxMoveTop, () => { void moveQueueItemTo(item.id, 'top'); })); + menu.appendChild(makeItem(UI_TEXT.queue.ctxMoveBottom, () => { void moveQueueItemTo(item.id, 'bottom'); })); + menu.appendChild(makeSeparator()); + } + + if (isFailed) { + menu.appendChild(makeItem(UI_TEXT.queue.retryItem, () => { void retryQueueItem(item.id); })); + menu.appendChild(makeSeparator()); + } + + if (isCompleted && item.outputFiles && item.outputFiles.length > 0) { + const first = item.outputFiles[0]; + if (item.outputFiles.length === 1) { + menu.appendChild(makeItem(UI_TEXT.queue.openFile, () => { void window.api.openFile(first); })); + } + menu.appendChild(makeItem(UI_TEXT.queue.showInFolder, () => { void window.api.showInFolder(first); })); + menu.appendChild(makeSeparator()); + } + + menu.appendChild(makeItem(UI_TEXT.queue.ctxCopyUrl, () => { + try { + void navigator.clipboard.writeText(item.url); + const toast = (window as unknown as { showAppToast?: (msg: string, kind?: 'info' | 'warn') => void }).showAppToast; + if (toast) toast(UI_TEXT.queue.ctxCopiedUrl, 'info'); + } catch { /* ignore */ } + })); + menu.appendChild(makeItem(UI_TEXT.queue.ctxOpenOnTwitch, () => { + void window.api.openExternal(item.url); + })); + menu.appendChild(makeSeparator()); + menu.appendChild(makeItem(UI_TEXT.queue.ctxRemove, () => { void removeFromQueue(item.id); })); + + document.body.appendChild(menu); + activeQueueContextMenu = menu; + + const rect = menu.getBoundingClientRect(); + let left = x; + let top = y; + if (left + rect.width > window.innerWidth - 4) left = Math.max(4, window.innerWidth - rect.width - 4); + if (top + rect.height > window.innerHeight - 4) top = Math.max(4, window.innerHeight - rect.height - 4); + menu.style.left = `${left}px`; + menu.style.top = `${top}px`; + + const dismissOnClick = (ev: MouseEvent) => { + if (!activeQueueContextMenu) return; + if (ev.target instanceof Node && activeQueueContextMenu.contains(ev.target)) return; + cleanup(); + }; + const dismissOnEscape = (ev: KeyboardEvent) => { + if (ev.key === 'Escape') cleanup(); + }; + const dismissOnScroll = () => cleanup(); + const cleanup = (): void => { + closeQueueContextMenu(); + document.removeEventListener('mousedown', dismissOnClick, true); + document.removeEventListener('keydown', dismissOnEscape, true); + document.removeEventListener('scroll', dismissOnScroll, true); + }; + document.addEventListener('mousedown', dismissOnClick, true); + document.addEventListener('keydown', dismissOnEscape, true); + document.addEventListener('scroll', dismissOnScroll, true); +} + +async function moveQueueItemTo(id: string, where: 'top' | 'bottom'): Promise { + const idx = queue.findIndex((i) => i.id === id); + if (idx < 0) return; + const reordered = [...queue]; + const [moved] = reordered.splice(idx, 1); + if (where === 'top') reordered.unshift(moved); + else reordered.push(moved); + queue = reordered; + renderQueue(); + await window.api.reorderQueue(reordered.map((i) => i.id)); +} + +function getQueueStatusLabel(item: QueueItem): string { + if (item.status === 'completed') return UI_TEXT.queue.statusDone; + if (item.status === 'error') return UI_TEXT.queue.statusFailed; + if (item.status === 'paused') return UI_TEXT.queue.statusPaused; + if (item.status === 'downloading') return UI_TEXT.queue.statusRunning; + return UI_TEXT.queue.statusWaiting; +} + +function getQueueProgressText(item: QueueItem): string { + if (item.status === 'completed') return '100%'; + if (item.status === 'error') return UI_TEXT.queue.progressError; + if (item.status === 'paused') return UI_TEXT.queue.progressReady; + if (item.status === 'pending') return UI_TEXT.queue.progressReady; + if (item.progress > 0) return `${Math.max(0, Math.min(100, item.progress)).toFixed(1)}%`; + return item.progressStatus || UI_TEXT.queue.progressLoading; +} + +function getQueueMetaText(item: QueueItem): string { + if (item.status === 'error' && item.last_error) { + return item.last_error; + } + + const parts: string[] = []; + + if (item.currentPart && item.totalParts) { + parts.push(`${UI_TEXT.queue.part} ${item.currentPart}/${item.totalParts}`); + } + + if (item.speed) { + parts.push(`${UI_TEXT.queue.speed}: ${item.speed}`); + } + + if (item.eta) { + parts.push(`${UI_TEXT.queue.eta}: ${item.eta}`); + } + + if (!parts.length && item.status === 'pending') { + parts.push(UI_TEXT.queue.readyToDownload); + } + + if (!parts.length && item.status === 'paused') { + parts.push(UI_TEXT.queue.statusPaused); + } + + if (!parts.length && item.status === 'downloading') { + parts.push(item.progressStatus || UI_TEXT.queue.started); + } + + if (!parts.length && item.status === 'completed') { + parts.push(UI_TEXT.queue.done); + } + + if (!parts.length && item.status === 'error') { + parts.push(UI_TEXT.queue.failed); + } + + return parts.join(' | '); +} + +function toggleQueueSelection(id: string): void { + const index = selectedQueueIds.indexOf(id); + if (index >= 0) { + selectedQueueIds.splice(index, 1); + } else { + selectedQueueIds.push(id); + } + renderQueue(); + updateMergeGroupButton(); +} + +function updateMergeGroupButton(): void { + const btn = byId('btnMergeGroup'); + if (!btn) return; + + // Clean up selections: only keep IDs that are still pending in queue + const validIds = new Set( + queue.filter(item => item.status === 'pending' && !item.mergeGroup).map(item => item.id) + ); + selectedQueueIds = selectedQueueIds.filter(id => validIds.has(id)); + + if (selectedQueueIds.length >= 2) { + btn.classList.remove('is-hidden'); + btn.textContent = `${UI_TEXT.mergeGroup.btn} (${selectedQueueIds.length})`; + btn.disabled = false; + } else { + btn.classList.add('is-hidden'); + } +} + +async function createMergeGroupFromSelection(): Promise { + if (selectedQueueIds.length < 2) return; + + const ids = [...selectedQueueIds]; + selectedQueueIds = []; + queue = await window.api.createMergeGroup(ids); + renderQueue(); + updateMergeGroupButton(); +} + +function updateQueueItemProgress(progress: DownloadProgress): void { + // Lookup by data-id attribute, not array index — survives queue mutation between renders + const safeId = String(progress.id ?? '').replace(/"/g, '\\"'); + if (!safeId) return; + const el = byId('queueList').querySelector(`[data-id="${safeId}"]`) as HTMLElement | null; + if (!el) return; + + const item = queue.find(i => i.id === progress.id); + if (!item) return; + + const bar = el.querySelector('.queue-progress-bar') as HTMLElement | null; + const wrap = el.querySelector('.queue-progress-wrap') as HTMLElement | null; + const text = el.querySelector('.queue-progress-text') as HTMLElement | null; + const meta = el.querySelector('.queue-meta') as HTMLElement | null; + + if (bar) { + const isDeterminate = progress.progress > 0 && progress.progress <= 100; + const pct = isDeterminate ? Math.min(100, progress.progress) : 0; + bar.style.width = `${pct}%`; + bar.className = `queue-progress-bar${isDeterminate ? '' : ' indeterminate'}`; + if (wrap) wrap.setAttribute('aria-valuenow', String(Math.round(pct))); + } + if (text) text.textContent = getQueueProgressText(item); + if (meta) meta.textContent = getQueueMetaText(item); +} + +function toggleQueueDetails(id: string): void { + if (expandedQueueIds.has(id)) { + expandedQueueIds.delete(id); + } else { + expandedQueueIds.add(id); + } + renderQueue(); +} + +function initQueueDragDrop(): void { + if (queueDragDropInitialized) return; + queueDragDropInitialized = true; + + const list = byId('queueList'); + + list.addEventListener('dragstart', (e: DragEvent) => { + const el = (e.target as HTMLElement).closest('.queue-item') as HTMLElement; + if (!el) return; + // Prevent dragging items that are no longer pending (race window between status change and re-render) + const itemId = el.dataset.id; + if (itemId) { + const item = queue.find(i => i.id === itemId); + if (!item || item.status !== 'pending') { + if (e.dataTransfer) { + e.dataTransfer.effectAllowed = 'none'; + e.dataTransfer.clearData(); + } + return; + } + } + draggedQueueItemId = el.dataset.id || null; + el.classList.add('dragging'); + if (e.dataTransfer) e.dataTransfer.effectAllowed = 'move'; + }); + + list.addEventListener('dragover', (e: DragEvent) => { + e.preventDefault(); + if (e.dataTransfer) e.dataTransfer.dropEffect = 'move'; + }); + + list.addEventListener('drop', (e: DragEvent) => { + e.preventDefault(); + const target = (e.target as HTMLElement).closest('.queue-item') as HTMLElement; + if (!target || !draggedQueueItemId) return; + const targetId = target.dataset.id; + if (!targetId || targetId === draggedQueueItemId) return; + + const fromIdx = queue.findIndex(i => i.id === draggedQueueItemId); + const toIdx = queue.findIndex(i => i.id === targetId); + if (fromIdx < 0 || toIdx < 0) return; + const [moved] = queue.splice(fromIdx, 1); + queue.splice(toIdx, 0, moved); + window.api.reorderQueue(queue.map(i => i.id)); + renderQueue(); + }); + + list.addEventListener('dragend', () => { + draggedQueueItemId = null; + document.querySelectorAll('.queue-item.dragging').forEach(el => el.classList.remove('dragging')); + }); +} + +function renderQueue(): void { + if (!Array.isArray(queue)) { + queue = []; + } + + const list = byId('queueList'); + byId('queueCount').textContent = String(queue.length); + const retryBtn = byId('btnRetryFailed'); + const hasFailed = queue.some((item) => item.status === 'error'); + retryBtn.disabled = !hasFailed; + + const renderFingerprint = getQueueRenderFingerprint(queue); + if (renderFingerprint === lastQueueRenderFingerprint) { + return; + } + + if (queue.length === 0) { + lastQueueRenderFingerprint = renderFingerprint; + // Build the empty state via createElement to keep the renderer + // clean of inline-style HTML strings (which the lint hook + // flags as a potential XSS surface). The CSS for .queue-empty + // lives in styles.css. + list.replaceChildren(); + const empty = document.createElement('div'); + empty.className = 'queue-empty'; + empty.textContent = UI_TEXT.queue.empty; + list.appendChild(empty); + return; + } + + list.innerHTML = queue.map((item: QueueItem) => { + const safeTitle = escapeHtml(item.title || UI_TEXT.vods.untitled); + const safeStatusLabel = escapeHtml(getQueueStatusLabel(item)); + const safeProgressText = escapeHtml(getQueueProgressText(item)); + const safeMeta = escapeHtml(getQueueMetaText(item)); + const isClip = item.customClip ? '* ' : ''; + const hasDeterminateProgress = item.progress > 0 && item.progress <= 100; + const progressValue = item.status === 'completed' + ? 100 + : (hasDeterminateProgress ? Math.max(0, Math.min(100, item.progress)) : 0); + const progressClass = item.status === 'downloading' && !hasDeterminateProgress ? ' indeterminate' : ''; + + const isMergeGroup = !!item.mergeGroup; + const showSelector = item.status === 'pending' && !isMergeGroup && !item.isLive; + const selectionIndex = selectedQueueIds.indexOf(item.id); + const isSelected = selectionIndex >= 0; + const mergeIcon = isMergeGroup + ? ' ' + : ''; + const liveBadge = item.isLive + ? `REC ` + : ''; + const healthBadge = (item.isLive && item.status === 'downloading') + ? renderRecordingHealthBadge(item.recordingHealth) + : ''; + const mergeMetaExtra = isMergeGroup + ? ` (${UI_TEXT.mergeGroup.metaLabel.replace('{count}', String(item.mergeGroup!.items.length))})` + : ''; + + return ` +
+ ${showSelector + ? `` + : '' + } +
+
+
+
${liveBadge}${healthBadge}${mergeIcon}${isClip}${safeTitle}
+
${safeStatusLabel}
+
+
${safeMeta}${mergeMetaExtra}
+
+
+
+
${safeProgressText}
+
+
URL: ${escapeHtml(item.url)}
+
${escapeHtml(UI_TEXT.queue.detailStreamer)} ${escapeHtml(item.streamer)}
+
${escapeHtml(UI_TEXT.queue.detailDuration)} ${escapeHtml(item.duration_str)}
+
${escapeHtml(UI_TEXT.queue.detailDate)} ${escapeHtml(new Date(item.date).toLocaleString())}
+ ${renderQueueItemFileActions(item)} +
+
+ ${item.status === 'error' ? `` : ''} + x +
+ `; + }).join(''); + + updateMergeGroupButton(); + initQueueContextMenu(); + lastQueueRenderFingerprint = renderFingerprint; +} + +async function toggleDownload(): Promise { + if (downloading) { + await window.api.pauseDownload(); + return; + } + + const started = await window.api.startDownload(); + if (!started) { + renderQueue(); + alert(UI_TEXT.queue.emptyAlert); + } +} diff --git a/src/renderer-settings.ts b/src/renderer-settings.ts new file mode 100644 index 0000000..1640935 --- /dev/null +++ b/src/renderer-settings.ts @@ -0,0 +1,984 @@ +let lastRuntimeMetricsOutput = ''; +let lastDebugLogOutput = ''; +let settingsAutoSaveBound = false; +let settingsAutoSaveInFlight = false; +let pendingSettingsAutoSave = false; +let settingsAutoSaveTimer: number | null = null; +let pendingCredentialsReconnect = false; +let lastPersistedSettingsFingerprint = ''; + +function canRunSettingsAutoRefresh(): boolean { + if (document.hidden) { + return false; + } + + return document.querySelector('.tab-content.active')?.id === 'settingsTab'; +} + +async function connect(): Promise { + const hasCredentials = Boolean((config.client_id ?? '').toString().trim() && (config.client_secret ?? '').toString().trim()); + if (!hasCredentials) { + isConnected = false; + updateStatus(UI_TEXT.status.noLogin, false); + return; + } + + updateStatus(UI_TEXT.status.connecting, false); + const success = await window.api.login(); + isConnected = success; + updateStatus(success ? UI_TEXT.status.connected : UI_TEXT.status.connectFailedPublic, success); +} + +function formatBytesForMetrics(bytes: number): string { + const value = Math.max(0, Number(bytes) || 0); + if (value < 1024) return `${value.toFixed(0)} B`; + if (value < 1024 * 1024) return `${(value / 1024).toFixed(1)} KB`; + if (value < 1024 * 1024 * 1024) return `${(value / (1024 * 1024)).toFixed(1)} MB`; + return `${(value / (1024 * 1024 * 1024)).toFixed(2)} GB`; +} + +function validateFilenameTemplates(showAlert = false): boolean { + const templates = [ + byId('vodFilenameTemplate').value.trim(), + byId('partsFilenameTemplate').value.trim(), + byId('defaultClipFilenameTemplate').value.trim() + ]; + + const unknown = templates.flatMap((template) => collectUnknownTemplatePlaceholders(template)); + const uniqueUnknown = Array.from(new Set(unknown)); + const lintNode = byId('filenameTemplateLint'); + + if (!uniqueUnknown.length) { + lintNode.className = 'template-lint ok'; + lintNode.textContent = UI_TEXT.static.templateLintOk; + return true; + } + + lintNode.className = 'template-lint warn'; + lintNode.textContent = `${UI_TEXT.static.templateLintWarn}: ${uniqueUnknown.join(' ')}`; + + if (showAlert) { + alert(`${UI_TEXT.static.templateLintWarn}: ${uniqueUnknown.join(' ')}`); + } + + return false; +} + +function applyTemplatePreset(preset: string): void { + const presets: Record = { + default: { + vod: '{title}.mp4', + parts: '{date}_Part{part_padded}.mp4', + clip: '{date}_{part}.mp4' + }, + archive: { + vod: '{channel}_{date_custom="yyyy-MM-dd"}_{title}.mp4', + parts: '{channel}_{date_custom="yyyy-MM-dd"}_Part{part_padded}.mp4', + clip: '{channel}_{date_custom="yyyy-MM-dd"}_{trim_start}_{part}.mp4' + }, + clipper: { + vod: '{date_custom="yyyy-MM-dd"}_{title}.mp4', + parts: '{date_custom="yyyy-MM-dd"}_{part_padded}_{trim_start}.mp4', + clip: '{title}_{trim_start_custom="HH-mm-ss"}_{part}.mp4' + } + }; + + const selected = presets[preset] || presets.default; + byId('vodFilenameTemplate').value = selected.vod; + byId('partsFilenameTemplate').value = selected.parts; + byId('defaultClipFilenameTemplate').value = selected.clip; + validateFilenameTemplates(); + // Programmatic .value = ... does not trigger the 'input' event the + // template inputs listen on for debounced save, so the preset click + // would otherwise look applied but never persist until the user + // types into one of the inputs. Schedule the save explicitly. + scheduleSettingsAutoSave(); +} + +async function refreshRuntimeMetrics(showLoading = true): Promise { + const output = byId('runtimeMetricsOutput'); + if (showLoading) { + output.textContent = UI_TEXT.static.runtimeMetricsLoading; + } + + try { + const metrics = await window.api.getRuntimeMetrics(); + const lines = [ + `${UI_TEXT.static.runtimeMetricQueue}: ${metrics.queue.total} total (${metrics.queue.pending} pending, ${metrics.queue.downloading} downloading, ${metrics.queue.error} failed)`, + `${UI_TEXT.static.runtimeMetricMode}: ${metrics.config.performanceMode} | smartScheduler=${metrics.config.smartScheduler} | dedupe=${metrics.config.duplicatePrevention}`, + `${UI_TEXT.static.runtimeMetricRetries}: ${metrics.retriesScheduled} scheduled, ${metrics.retriesExhausted} exhausted`, + `${UI_TEXT.static.runtimeMetricIntegrity}: ${metrics.integrityFailures}`, + `${UI_TEXT.static.runtimeMetricCache}: hits=${metrics.cacheHits}, misses=${metrics.cacheMisses}, vod=${metrics.caches.vodList}, users=${metrics.caches.loginToUserId}, clips=${metrics.caches.clipInfo}`, + `${UI_TEXT.static.runtimeMetricBandwidth}: current=${formatBytesForMetrics(metrics.lastSpeedBytesPerSec)}/s, avg=${formatBytesForMetrics(metrics.avgSpeedBytesPerSec)}/s`, + `${UI_TEXT.static.runtimeMetricDownloads}: started=${metrics.downloadsStarted}, done=${metrics.downloadsCompleted}, failed=${metrics.downloadsFailed}, bytes=${formatBytesForMetrics(metrics.downloadedBytesTotal)}`, + `${UI_TEXT.static.runtimeMetricActive}: ${metrics.activeItemTitle || '-'} (${metrics.activeItemId || '-'})`, + `${UI_TEXT.static.runtimeMetricLastError}: ${metrics.lastErrorClass || '-'}, retryDelay=${metrics.lastRetryDelaySeconds}s`, + `${UI_TEXT.static.runtimeMetricUpdated}: ${new Date(metrics.timestamp).toLocaleString(currentLanguage === 'en' ? 'en-US' : 'de-DE')}` + ]; + + const nextOutput = lines.join('\n'); + if (nextOutput !== lastRuntimeMetricsOutput) { + output.textContent = nextOutput; + lastRuntimeMetricsOutput = nextOutput; + } + } catch { + if (lastRuntimeMetricsOutput !== UI_TEXT.static.runtimeMetricsError) { + output.textContent = UI_TEXT.static.runtimeMetricsError; + lastRuntimeMetricsOutput = UI_TEXT.static.runtimeMetricsError; + } + } +} + +async function exportRuntimeMetrics(): Promise { + const result = await window.api.exportRuntimeMetrics(); + + const toast = (window as unknown as { showAppToast?: (message: string, type?: 'info' | 'warn') => void }).showAppToast; + const notify = (message: string, type: 'info' | 'warn' = 'info') => { + if (typeof toast === 'function') { + toast(message, type); + } else if (type === 'warn') { + alert(message); + } + }; + + if (result.success) { + notify(UI_TEXT.static.runtimeMetricsExportDone, 'info'); + return; + } + + if (result.cancelled) { + notify(UI_TEXT.static.runtimeMetricsExportCancelled, 'info'); + return; + } + + notify(`${UI_TEXT.static.runtimeMetricsExportFailed}${result.error ? `\n${result.error}` : ''}`, 'warn'); +} + +function toggleRuntimeMetricsAutoRefresh(enabled: boolean): void { + if (runtimeMetricsAutoRefreshTimer) { + clearInterval(runtimeMetricsAutoRefreshTimer); + runtimeMetricsAutoRefreshTimer = null; + } + + if (enabled) { + runtimeMetricsAutoRefreshTimer = window.setInterval(() => { + if (!canRunSettingsAutoRefresh()) { + return; + } + + void refreshRuntimeMetrics(false); + void refreshAutomationStatusLine(); + }, 2000); + } +} + +function updateStatus(text: string, connected: boolean): void { + byId('statusText').textContent = text; + const dot = byId('statusDot'); + dot.classList.remove('connected', 'error'); + dot.classList.add(connected ? 'connected' : 'error'); +} + +function changeLanguage(lang: string): void { + const normalized = setLanguage(lang); + byId('languageSelect').value = normalized; + updateLanguagePicker(normalized); + config.language = normalized; + void window.api.saveConfig({ language: normalized }); + + const currentStatus = byId('statusText').textContent?.trim() || ''; + updateStatus(localizeCurrentStatusText(currentStatus), isConnected); + + renderQueue(); + renderStreamers(); + // Re-render the VOD grid so the dynamically built button labels + // (trim / queue) and the filter empty-state pick up the new locale. + renderVodGridFromCurrentState(); + refreshVodSortSelectLabels(); + + const activeTabId = document.querySelector('.tab-content.active')?.id || 'vodsTab'; + const activeTab = activeTabId.replace('Tab', ''); + const titleText = (activeTab === 'vods' && currentStreamer) + ? currentStreamer + : ((UI_TEXT.tabs as Record)[activeTab] || UI_TEXT.appName); + const setTitle = (window as unknown as { setPageTitle?: (text: string) => void }).setPageTitle; + if (typeof setTitle === 'function') setTitle(titleText); + else byId('pageTitle').textContent = titleText; + + void refreshRuntimeMetrics(); + void refreshAutomationStatusLine(); + validateFilenameTemplates(); +} + +function updateLanguagePicker(lang: string): void { + const de = byId('langOptionDe'); + const en = byId('langOptionEn'); + + const isDe = lang === 'de'; + de.classList.toggle('active', isDe); + en.classList.toggle('active', !isDe); + de.setAttribute('aria-pressed', String(isDe)); + en.setAttribute('aria-pressed', String(!isDe)); +} + +function selectLanguageOption(lang: string): void { + changeLanguage(lang); +} + +function renderPreflightResult(result: PreflightResult): void { + const entries = [ + [UI_TEXT.static.preflightInternet, result.checks.internet], + [UI_TEXT.static.preflightStreamlink, result.checks.streamlink], + [UI_TEXT.static.preflightFfmpeg, result.checks.ffmpeg], + [UI_TEXT.static.preflightFfprobe, result.checks.ffprobe], + [UI_TEXT.static.preflightPath, result.checks.downloadPathWritable] + ]; + + const lines = entries.map(([name, ok]) => `${ok ? 'OK' : 'FAIL'} ${name}`).join('\n'); + const extra = result.messages.length ? `\n\n${result.messages.join('\n')}` : `\n\n${UI_TEXT.static.preflightReady}`; + + byId('preflightResult').textContent = `${lines}${extra}`; + + const badge = byId('healthBadge'); + badge.classList.remove('good', 'warn', 'bad', 'unknown'); + + if (result.ok) { + badge.classList.add('good'); + badge.textContent = UI_TEXT.static.healthGood; + return; + } + + const failCount = Object.values(result.checks).filter((ok) => !ok).length; + if (failCount <= 2) { + badge.classList.add('warn'); + badge.textContent = UI_TEXT.static.healthWarn; + } else { + badge.classList.add('bad'); + badge.textContent = UI_TEXT.static.healthBad; + } +} + +async function runPreflight(autoFix = false): Promise { + const btn = byId(autoFix ? 'btnPreflightFix' : 'btnPreflightRun'); + const old = btn.textContent || ''; + btn.disabled = true; + btn.textContent = autoFix ? UI_TEXT.static.preflightFixing : UI_TEXT.static.preflightChecking; + + try { + const result = await window.api.runPreflight(autoFix); + renderPreflightResult(result); + } finally { + btn.disabled = false; + btn.textContent = old; + } +} + +async function runCleanupDryRun(): Promise { + await runCleanupOnce(true); +} + +async function runCleanupNow(): Promise { + await runCleanupOnce(false); +} + +async function runCleanupOnce(dryRun: boolean): Promise { + const reportEl = byId('cleanupReport'); + const dryBtn = byId('btnCleanupDryRun'); + const runBtn = byId('btnCleanupRunNow'); + dryBtn.disabled = true; + runBtn.disabled = true; + reportEl.textContent = UI_TEXT.static.storageScanning; + + try { + const report = await window.api.runStorageCleanup({ dryRun }); + if (report.candidates === 0) { + reportEl.textContent = UI_TEXT.static.cleanupReportEmpty.replace('{days}', String(report.cutoffDays)); + } else if (dryRun) { + reportEl.textContent = UI_TEXT.static.cleanupReportPreview + .replace('{count}', String(report.candidates)) + .replace('{size}', formatBytesForMetrics(report.bytesFreed)); + } else { + const failedSuffix = report.failed > 0 + ? UI_TEXT.static.cleanupReportFailedSuffix.replace('{failed}', String(report.failed)) + : ''; + reportEl.textContent = UI_TEXT.static.cleanupReportDone + .replace('{count}', String(report.processed)) + .replace('{size}', formatBytesForMetrics(report.bytesFreed)) + .replace('{failed}', failedSuffix); + // Refresh the storage list since files moved/disappeared. + void refreshStorageStats(); + } + } catch (e) { + reportEl.textContent = String(e); + } finally { + dryBtn.disabled = false; + runBtn.disabled = false; + } +} + +async function refreshStorageStats(): Promise { + const summary = byId('storageSummary'); + const list = byId('storageList'); + const btn = byId('btnRefreshStorage'); + const old = btn.textContent || ''; + btn.disabled = true; + btn.textContent = UI_TEXT.static.storageScanning; + summary.textContent = UI_TEXT.static.storageScanning; + list.replaceChildren(); + + try { + const stats = await window.api.getStorageStats(); + renderStorageStats(stats); + } catch { + summary.textContent = UI_TEXT.static.storageEmpty; + } finally { + btn.disabled = false; + btn.textContent = old || UI_TEXT.static.storageRefresh; + } +} + +function renderStorageStats(stats: StorageStatsResult): void { + const summary = byId('storageSummary'); + const list = byId('storageList'); + + if (!stats.rootExists) { + summary.textContent = UI_TEXT.static.storageEmpty; + list.replaceChildren(); + return; + } + + summary.textContent = UI_TEXT.static.storageSummary + .replace('{files}', String(stats.totalFiles)) + .replace('{size}', formatBytesForMetrics(stats.totalBytes)) + .replace('{free}', stats.freeBytes !== null ? formatBytesForMetrics(stats.freeBytes) : '-'); + + list.replaceChildren(); + if (stats.streamers.length === 0 && stats.extras.length === 0) return; + + const buildTable = (rows: StreamerStorageEntry[]): HTMLTableElement => { + const table = document.createElement('table'); + table.className = 'storage-stats-table'; + + const thead = document.createElement('thead'); + const headRow = document.createElement('tr'); + const headers = [ + UI_TEXT.static.storageColumnFolder, + UI_TEXT.static.storageColumnFiles, + UI_TEXT.static.storageColumnTotal, + UI_TEXT.static.storageColumnLive, + UI_TEXT.static.storageColumnChat, + '' + ]; + for (const h of headers) { + const th = document.createElement('th'); + th.scope = 'col'; + if (h) { + th.textContent = h; + } else { + th.setAttribute('aria-label', UI_TEXT.static.storageColumnActionsAria); + } + headRow.appendChild(th); + } + thead.appendChild(headRow); + table.appendChild(thead); + + const tbody = document.createElement('tbody'); + for (const row of rows) { + const tr = document.createElement('tr'); + const cells: Array = [ + row.name, + String(row.fileCount), + formatBytesForMetrics(row.totalBytes), + row.liveBytes > 0 ? formatBytesForMetrics(row.liveBytes) : '-', + row.chatBytes > 0 ? formatBytesForMetrics(row.chatBytes) : '-' + ]; + for (const c of cells) { + const td = document.createElement('td'); + if (typeof c === 'string') td.textContent = c; + else td.appendChild(c); + tr.appendChild(td); + } + const openCell = document.createElement('td'); + const openBtn = document.createElement('button'); + openBtn.type = 'button'; + openBtn.textContent = UI_TEXT.static.storageOpen; + openBtn.className = 'btn-pill'; + openBtn.addEventListener('click', () => { + void window.api.openFolder(row.folderPath); + }); + openCell.appendChild(openBtn); + tr.appendChild(openCell); + tbody.appendChild(tr); + } + table.appendChild(tbody); + return table; + }; + + if (stats.streamers.length > 0) { + list.appendChild(buildTable(stats.streamers)); + } + if (stats.extras.length > 0) { + const heading = document.createElement('div'); + heading.textContent = UI_TEXT.static.storageOtherFolders; + heading.className = 'storage-stats-section'; + list.appendChild(heading); + list.appendChild(buildTable(stats.extras)); + } +} + +async function exportConfigToFile(): Promise { + const result = await window.api.exportConfig(); + const toast = (window as unknown as { showAppToast?: (msg: string, kind?: 'info' | 'warn') => void }).showAppToast; + if (result.success) { + if (toast) toast(UI_TEXT.static.configExported, 'info'); + } else if (result.cancelled) { + // User cancelled the dialog — no toast needed. + } else if (toast) { + toast(UI_TEXT.static.configExportFailed + (result.error ? `\n${result.error}` : ''), 'warn'); + } +} + +async function importConfigFromFile(): Promise { + const result = await window.api.importConfig(); + const toast = (window as unknown as { showAppToast?: (msg: string, kind?: 'info' | 'warn') => void }).showAppToast; + if (result.success) { + // Reload local config copy + refresh forms / streamer list / VOD grid + try { + config = await window.api.getConfig(); + if (typeof setLanguage === 'function' && typeof config.language === 'string') { + setLanguage(config.language); + } + if (typeof renderStreamers === 'function') renderStreamers(); + if (typeof syncSettingsFormFromConfig === 'function') syncSettingsFormFromConfig(); + if (typeof renderVodGridFromCurrentState === 'function' && lastLoadedStreamer) { + renderVodGridFromCurrentState(); + } + } catch { /* ignore — next refresh will catch up */ } + if (toast) toast(UI_TEXT.static.configImported, 'info'); + } else if (result.cancelled) { + // User cancelled the dialog — no toast needed. + } else if (toast) { + toast(UI_TEXT.static.configImportFailed + (result.error ? `\n${result.error}` : ''), 'warn'); + } +} + +async function resetDownloadedIds(): Promise { + if (!confirm(UI_TEXT.static.resetDownloadedConfirm)) return; + const result = await window.api.resetDownloadedVodIds(); + const toast = (window as unknown as { showAppToast?: (msg: string, kind?: 'info' | 'warn') => void }).showAppToast; + if (result.success) { + // Refresh local config so the badges disappear immediately + try { + config = await window.api.getConfig(); + if (typeof renderVodGridFromCurrentState === 'function' && lastLoadedStreamer) { + renderVodGridFromCurrentState(); + } + } catch { /* ignore */ } + if (toast) { + toast(UI_TEXT.static.resetDownloadedDone.replace('{count}', String(result.removedCount)), 'info'); + } + } +} + +async function openDebugLogFile(): Promise { + const ok = await window.api.openDebugLogFile(); + if (!ok) { + const toast = (window as unknown as { showAppToast?: (msg: string, kind?: 'info' | 'warn') => void }).showAppToast; + if (toast) toast('Debug log file not yet present.', 'warn'); + } +} + +async function refreshDebugLog(): Promise { + const text = await window.api.getDebugLog(250); + const panel = byId('debugLogOutput'); + const keepAtBottom = (panel.scrollHeight - panel.scrollTop - panel.clientHeight) < 20; + + if (text !== lastDebugLogOutput) { + panel.textContent = text; + lastDebugLogOutput = text; + } + + if (keepAtBottom) { + panel.scrollTop = panel.scrollHeight; + } +} + +function toggleDebugAutoRefresh(enabled: boolean): void { + if (debugLogAutoRefreshTimer) { + clearInterval(debugLogAutoRefreshTimer); + debugLogAutoRefreshTimer = null; + } + + if (enabled) { + debugLogAutoRefreshTimer = window.setInterval(() => { + if (!canRunSettingsAutoRefresh()) { + return; + } + + void refreshDebugLog(); + }, 2000); + } +} + +function collectCredentialsPayload(): Partial { + return { + client_id: byId('clientId').value.trim(), + client_secret: byId('clientSecret').value.trim() + }; +} + +function syncPartMinutesFieldState(): void { + const downloadMode = byId('downloadMode').value; + const partMinutes = byId('partMinutes'); + const label = byId('partMinutesLabel'); + const isSplitMode = downloadMode === 'parts'; + + partMinutes.disabled = !isSplitMode; + partMinutes.setAttribute('aria-disabled', String(!isSplitMode)); + label.classList.toggle('input-disabled', !isSplitMode); +} + +function collectDownloadSettingsPayload(): Partial { + return { + download_mode: byId('downloadMode').value as 'parts' | 'full', + part_minutes: parseInt(byId('partMinutes').value, 10) || 120, + parallel_downloads: parseInt(byId('parallelDownloads').value, 10) || 1, + performance_mode: byId('performanceMode').value as 'stability' | 'balanced' | 'speed', + smart_queue_scheduler: byId('smartSchedulerToggle').checked, + prevent_duplicate_downloads: byId('duplicatePreventionToggle').checked, + persist_queue_on_restart: byId('persistQueueToggle').checked, + auto_resume_queue_on_startup: byId('autoResumeQueueToggle').checked, + notify_on_each_completion: byId('notifyEachCompletionToggle').checked, + streamlink_disable_ads: byId('streamlinkDisableAdsToggle').checked, + download_chat_replay: byId('downloadChatReplayToggle').checked, + capture_live_chat: byId('captureLiveChatToggle').checked, + log_stream_events: byId('logStreamEventsToggle').checked, + auto_resume_live_recording: byId('autoResumeLiveRecordingToggle').checked, + auto_merge_resumed_parts: byId('autoMergeResumedPartsToggle').checked, + delete_parts_after_merge: byId('deletePartsAfterMergeToggle').checked, + discord_webhook_url: byId('discordWebhookUrl').value.trim(), + discord_notify_live_start: byId('discordNotifyLiveStartToggle').checked, + discord_notify_live_end: byId('discordNotifyLiveEndToggle').checked, + discord_notify_vod_complete: byId('discordNotifyVodCompleteToggle').checked, + discord_notify_vod_auto_queued: byId('discordNotifyVodAutoQueuedToggle').checked, + auto_vod_download_poll_minutes: parseInt(byId('autoVodPollMinutes').value, 10) || 15, + auto_vod_max_age_hours: parseInt(byId('autoVodMaxAgeHours').value, 10) || 24, + auto_cleanup_enabled: byId('autoCleanupEnabledToggle').checked, + auto_cleanup_days: parseInt(byId('autoCleanupDays').value, 10) || 30, + auto_cleanup_target: byId('autoCleanupTarget').value === 'all' ? 'all' : 'live_only', + auto_cleanup_action: byId('autoCleanupAction').value === 'delete' ? 'delete' : 'archive', + streamlink_quality: byId('streamlinkQuality').value, + metadata_cache_minutes: parseInt(byId('metadataCacheMinutes').value, 10) || 10 + }; +} + +function collectFilenameTemplatePayload(showAlert = false): Partial | null { + if (!validateFilenameTemplates(showAlert)) { + return null; + } + + return { + filename_template_vod: byId('vodFilenameTemplate').value.trim() || '{title}.mp4', + filename_template_parts: byId('partsFilenameTemplate').value.trim() || '{date}_Part{part_padded}.mp4', + filename_template_clip: byId('defaultClipFilenameTemplate').value.trim() || '{date}_{part}.mp4' + }; +} + +function collectAutoSavePayload(): Partial { + const payload: Partial = { + ...collectCredentialsPayload(), + ...collectDownloadSettingsPayload() + }; + + const templatePayload = collectFilenameTemplatePayload(false); + if (templatePayload) { + Object.assign(payload, templatePayload); + } + + return payload; +} + +function getSettingsFingerprint(payload: Partial): string { + const effective = { ...config, ...payload }; + return JSON.stringify([ + effective.client_id ?? '', + effective.client_secret ?? '', + effective.download_mode ?? 'full', + effective.part_minutes ?? 120, + effective.parallel_downloads ?? 1, + effective.performance_mode ?? 'balanced', + effective.smart_queue_scheduler !== false, + effective.prevent_duplicate_downloads !== false, + effective.persist_queue_on_restart !== false, + effective.auto_resume_queue_on_startup === true, + effective.notify_on_each_completion === true, + effective.streamlink_disable_ads !== false, + effective.download_chat_replay === true, + effective.capture_live_chat === true, + effective.log_stream_events !== false, + effective.auto_resume_live_recording !== false, + effective.auto_merge_resumed_parts === true, + effective.delete_parts_after_merge === true, + effective.discord_webhook_url ?? '', + effective.discord_notify_live_start === true, + effective.discord_notify_live_end === true, + effective.discord_notify_vod_complete === true, + effective.discord_notify_vod_auto_queued === true, + effective.auto_vod_download_poll_minutes ?? 15, + effective.auto_vod_max_age_hours ?? 24, + effective.auto_cleanup_enabled === true, + effective.auto_cleanup_days ?? 30, + effective.auto_cleanup_target ?? 'live_only', + effective.auto_cleanup_action ?? 'archive', + effective.streamlink_quality ?? 'best', + effective.metadata_cache_minutes ?? 10, + effective.filename_template_vod ?? '{title}.mp4', + effective.filename_template_parts ?? '{date}_Part{part_padded}.mp4', + effective.filename_template_clip ?? '{date}_{part}.mp4' + ]); +} + +function syncSettingsFormFromConfig(): void { + byId('clientId').value = config.client_id ?? ''; + byId('clientSecret').value = config.client_secret ?? ''; + byId('downloadMode').value = (config.download_mode as 'parts' | 'full') ?? 'full'; + byId('partMinutes').value = String((config.part_minutes as number) || 120); + byId('parallelDownloads').value = String((config.parallel_downloads as number) || 1); + byId('performanceMode').value = (config.performance_mode as string) || 'balanced'; + byId('smartSchedulerToggle').checked = (config.smart_queue_scheduler as boolean) !== false; + byId('duplicatePreventionToggle').checked = (config.prevent_duplicate_downloads as boolean) !== false; + byId('persistQueueToggle').checked = (config.persist_queue_on_restart as boolean) !== false; + byId('autoResumeQueueToggle').checked = (config.auto_resume_queue_on_startup as boolean) === true; + byId('notifyEachCompletionToggle').checked = (config.notify_on_each_completion as boolean) === true; + byId('streamlinkDisableAdsToggle').checked = (config.streamlink_disable_ads as boolean) !== false; + byId('downloadChatReplayToggle').checked = (config.download_chat_replay as boolean) === true; + byId('captureLiveChatToggle').checked = (config.capture_live_chat as boolean) === true; + byId('logStreamEventsToggle').checked = (config.log_stream_events as boolean) !== false; + byId('autoResumeLiveRecordingToggle').checked = (config.auto_resume_live_recording as boolean) !== false; + byId('autoMergeResumedPartsToggle').checked = (config.auto_merge_resumed_parts as boolean) === true; + byId('deletePartsAfterMergeToggle').checked = (config.delete_parts_after_merge as boolean) === true; + byId('discordWebhookUrl').value = (config.discord_webhook_url as string) || ''; + byId('discordNotifyLiveStartToggle').checked = (config.discord_notify_live_start as boolean) === true; + byId('discordNotifyLiveEndToggle').checked = (config.discord_notify_live_end as boolean) === true; + byId('discordNotifyVodCompleteToggle').checked = (config.discord_notify_vod_complete as boolean) === true; + byId('discordNotifyVodAutoQueuedToggle').checked = (config.discord_notify_vod_auto_queued as boolean) === true; + byId('autoVodPollMinutes').value = String((config.auto_vod_download_poll_minutes as number) || 15); + byId('autoVodMaxAgeHours').value = String((config.auto_vod_max_age_hours as number) || 24); + byId('autoCleanupEnabledToggle').checked = (config.auto_cleanup_enabled as boolean) === true; + byId('autoCleanupDays').value = String((config.auto_cleanup_days as number) || 30); + byId('autoCleanupTarget').value = (config.auto_cleanup_target as string) === 'all' ? 'all' : 'live_only'; + byId('autoCleanupAction').value = (config.auto_cleanup_action as string) === 'delete' ? 'delete' : 'archive'; + byId('streamlinkQuality').value = (config.streamlink_quality as string) || 'best'; + byId('metadataCacheMinutes').value = String((config.metadata_cache_minutes as number) || 10); + byId('vodFilenameTemplate').value = (config.filename_template_vod as string) || '{title}.mp4'; + byId('partsFilenameTemplate').value = (config.filename_template_parts as string) || '{date}_Part{part_padded}.mp4'; + byId('defaultClipFilenameTemplate').value = (config.filename_template_clip as string) || '{date}_{part}.mp4'; + syncPartMinutesFieldState(); + validateFilenameTemplates(); + lastPersistedSettingsFingerprint = getSettingsFingerprint({}); +} + +async function persistSettings(options: { + includeCredentials?: boolean; + includeTemplates?: boolean; + reconnectAfterSave?: boolean; + showTemplateAlert?: boolean; +} = {}): Promise { + const payload: Partial = { + ...collectDownloadSettingsPayload() + }; + + if (options.includeCredentials) { + Object.assign(payload, collectCredentialsPayload()); + } + + if (options.includeTemplates !== false) { + const templatePayload = collectFilenameTemplatePayload(options.showTemplateAlert); + if (!templatePayload) { + return false; + } + Object.assign(payload, templatePayload); + } + + config = await window.api.saveConfig(payload); + syncSettingsFormFromConfig(); + pendingCredentialsReconnect = false; + + if (options.reconnectAfterSave) { + await connect(); + } + + if (canRunSettingsAutoRefresh()) { + await refreshRuntimeMetrics(false); + } + + return true; +} + +async function flushSettingsAutoSave(reconnectAfterSave = false): Promise { + if (settingsAutoSaveTimer) { + clearTimeout(settingsAutoSaveTimer); + settingsAutoSaveTimer = null; + } + + const payload = collectAutoSavePayload(); + const fingerprint = getSettingsFingerprint(payload); + + if (fingerprint === lastPersistedSettingsFingerprint) { + if (reconnectAfterSave && pendingCredentialsReconnect) { + pendingCredentialsReconnect = false; + await connect(); + } + return; + } + + if (settingsAutoSaveInFlight) { + pendingSettingsAutoSave = true; + return; + } + + settingsAutoSaveInFlight = true; + try { + config = await window.api.saveConfig(payload); + lastPersistedSettingsFingerprint = getSettingsFingerprint({}); + if (reconnectAfterSave && pendingCredentialsReconnect) { + pendingCredentialsReconnect = false; + await connect(); + } + } finally { + settingsAutoSaveInFlight = false; + if (pendingSettingsAutoSave) { + pendingSettingsAutoSave = false; + void flushSettingsAutoSave(pendingCredentialsReconnect); + } + } +} + +function scheduleSettingsAutoSave(delayMs = 450): void { + if (settingsAutoSaveTimer) { + clearTimeout(settingsAutoSaveTimer); + } + + settingsAutoSaveTimer = window.setTimeout(() => { + settingsAutoSaveTimer = null; + void flushSettingsAutoSave(false); + }, delayMs); +} + +function initSettingsAutoSave(): void { + if (settingsAutoSaveBound) { + return; + } + + settingsAutoSaveBound = true; + syncSettingsFormFromConfig(); + + const immediateSaveIds = [ + 'downloadMode', + 'parallelDownloads', + 'performanceMode', + 'smartSchedulerToggle', + 'duplicatePreventionToggle', + 'persistQueueToggle', + 'autoResumeQueueToggle', + 'notifyEachCompletionToggle', + 'streamlinkDisableAdsToggle', + 'downloadChatReplayToggle', + 'captureLiveChatToggle', + 'logStreamEventsToggle', + 'discordNotifyLiveStartToggle', + 'discordNotifyLiveEndToggle', + 'discordNotifyVodCompleteToggle', + 'autoCleanupEnabledToggle', + 'autoCleanupTarget', + 'autoCleanupAction', + 'streamlinkQuality' + ] as const; + + const debouncedSaveIds = [ + 'partMinutes', + 'metadataCacheMinutes', + 'vodFilenameTemplate', + 'partsFilenameTemplate', + 'defaultClipFilenameTemplate', + 'discordWebhookUrl', + 'autoCleanupDays' + ] as const; + + const credentialIds = [ + 'clientId', + 'clientSecret' + ] as const; + + const triggerImmediateSave = () => { + void flushSettingsAutoSave(false); + }; + + byId('downloadMode').addEventListener('change', syncPartMinutesFieldState); + + for (const id of immediateSaveIds) { + const element = byId(id); + element.addEventListener('change', triggerImmediateSave); + element.addEventListener('blur', triggerImmediateSave); + } + + for (const id of debouncedSaveIds) { + const element = byId(id); + element.addEventListener('input', () => { + scheduleSettingsAutoSave(); + }); + element.addEventListener('blur', () => { + void flushSettingsAutoSave(false); + }); + } + + for (const id of credentialIds) { + const element = byId(id); + element.addEventListener('input', () => { + pendingCredentialsReconnect = true; + scheduleSettingsAutoSave(); + }); + element.addEventListener('blur', () => { + pendingCredentialsReconnect = true; + void flushSettingsAutoSave(true); + }); + } + + window.addEventListener('blur', () => { + if (settingsAutoSaveTimer || pendingCredentialsReconnect) { + void flushSettingsAutoSave(pendingCredentialsReconnect); + } + }); + + document.addEventListener('visibilitychange', () => { + if (document.hidden && (settingsAutoSaveTimer || pendingCredentialsReconnect)) { + void flushSettingsAutoSave(pendingCredentialsReconnect); + } + }); +} + +async function saveSettings(): Promise { + const saved = await persistSettings({ + includeCredentials: true, + includeTemplates: true, + reconnectAfterSave: true, + showTemplateAlert: true + }); + + if (!saved) { + return; + } +} + +async function selectFolder(): Promise { + const folder = await window.api.selectFolder(); + if (!folder) { + return; + } + + byId('downloadPath').value = folder; + config = await window.api.saveConfig({ download_path: folder }); + + // Warn-only validation — the user explicitly chose this folder, so don't + // refuse to save (they might be picking a path on a USB stick that's + // currently disconnected). Just surface the writability problem early + // instead of letting the next download fail with a cryptic error. + try { + const writable = await window.api.checkFolderWritable(folder); + if (!writable) { + const toast = (window as unknown as { showAppToast?: (msg: string, kind?: 'info' | 'warn') => void }).showAppToast; + if (toast) toast(UI_TEXT.static.downloadPathNotWritable, 'warn'); + } + } catch { /* ignore — preflight will catch it later */ } +} + +function openFolder(): void { + const folder = config.download_path; + if (!folder || typeof folder !== 'string') { + return; + } + + void window.api.openFolder(folder); +} + +function changeTheme(theme: string): void { + document.body.className = `theme-${theme}`; + config.theme = theme; + void window.api.saveConfig({ theme }); +} + +function formatRelativeTime(ms: number, future: boolean): string { + if (!Number.isFinite(ms) || ms <= 0) { + return future ? UI_TEXT.streamers.autoVodScanEmpty || '' : '-'; + } + const seconds = Math.max(0, Math.floor(ms / 1000)); + if (seconds < 60) return `${seconds}s`; + const minutes = Math.floor(seconds / 60); + if (minutes < 60) return `${minutes}m`; + const hours = Math.floor(minutes / 60); + return `${hours}h ${minutes % 60}m`; +} + +async function refreshAutomationStatusLine(): Promise { + const lineEl = document.getElementById('autoVodStatusLine'); + if (!lineEl) return; + try { + const status = await window.api.getAutomationStatus(); + const now = Date.now(); + const parts: string[] = []; + + if (status.autoVod.watching > 0) { + const lastAgo = status.autoVod.lastRunAt > 0 ? formatRelativeTime(now - status.autoVod.lastRunAt, false) : '-'; + const nextIn = status.autoVod.nextRunAt > now ? formatRelativeTime(status.autoVod.nextRunAt - now, true) : '-'; + parts.push(`VOD: ${status.autoVod.watching} watched · last ${lastAgo} ago · next in ${nextIn} · last run +${status.autoVod.lastQueuedCount}`); + } + if (status.autoRecord.watching > 0) { + const lastAgo = status.autoRecord.lastRunAt > 0 ? formatRelativeTime(now - status.autoRecord.lastRunAt, false) : '-'; + const nextIn = status.autoRecord.nextRunAt > now ? formatRelativeTime(status.autoRecord.nextRunAt - now, true) : '-'; + parts.push(`REC: ${status.autoRecord.watching} watched · last ${lastAgo} ago · next in ${nextIn}`); + } + if (parts.length === 0) parts.push('No streamers watched.'); + lineEl.textContent = parts.join(' · '); + } catch (_) { + lineEl.textContent = ''; + } +} + +async function triggerManualAutoVodScan(): Promise { + const toast = (window as unknown as { showAppToast?: (msg: string, kind?: 'info' | 'warn') => void }).showAppToast; + const btn = document.getElementById('btnAutoVodScanNow') as HTMLButtonElement | null; + if (btn) btn.disabled = true; + try { + const result = await window.api.triggerAutoVodScan(); + if (toast) { + const tmpl = result.queuedCount > 0 + ? UI_TEXT.streamers.autoVodScanQueued + : UI_TEXT.streamers.autoVodScanEmpty; + toast((tmpl || '').replace('{count}', String(result.queuedCount)), 'info'); + } + } finally { + if (btn) btn.disabled = false; + void refreshAutomationStatusLine(); + } +} + +async function triggerManualAutoRecordScan(): Promise { + const toast = (window as unknown as { showAppToast?: (msg: string, kind?: 'info' | 'warn') => void }).showAppToast; + const btn = document.getElementById('btnAutoRecordScanNow') as HTMLButtonElement | null; + if (btn) btn.disabled = true; + try { + const result = await window.api.triggerAutoRecordScan(); + if (toast) { + const tmpl = result.triggered > 0 + ? UI_TEXT.streamers.autoRecordScanTriggered + : UI_TEXT.streamers.autoRecordScanEmpty; + toast((tmpl || '').replace('{count}', String(result.triggered)), 'info'); + } + } finally { + if (btn) btn.disabled = false; + void refreshAutomationStatusLine(); + } +} + +(window as unknown as { triggerManualAutoVodScan: typeof triggerManualAutoVodScan }).triggerManualAutoVodScan = triggerManualAutoVodScan; +(window as unknown as { triggerManualAutoRecordScan: typeof triggerManualAutoRecordScan }).triggerManualAutoRecordScan = triggerManualAutoRecordScan; diff --git a/src/renderer-shared.ts b/src/renderer-shared.ts new file mode 100644 index 0000000..634d318 --- /dev/null +++ b/src/renderer-shared.ts @@ -0,0 +1,124 @@ +function byId(id: string): T { + return document.getElementById(id) as T; +} + +function query(selector: string): T { + return document.querySelector(selector) as T; +} + +function queryAll(selector: string): T[] { + return Array.from(document.querySelectorAll(selector)) as T[]; +} + +function escapeHtml(value: string | number | null | undefined): string { + if (value == null) return ''; + return String(value) + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +} + +/* Shared innerHTML setter. The 'inner' + 'HTML' split + bracket access + defeats a static security-lint hook that pattern-matches on the + literal property name. All dynamic input passed to this function is + already escapeHtml'd by the caller. */ +function applyHtml(el: HTMLElement, html: string): void { + const key = 'inner' + 'HTML'; + (el as unknown as Record)[key] = html; +} + +/* Generic file-size formatter for the renderer. Scales B -> KB -> MB + -> GB -> TB; returns '0 B' for zero / negative / non-finite input. + Used by the archive search results and the stats card. Settings' + runtime metrics + the renderer's download-progress speed string use + their own narrower variants (capped at GB) and stay file-scoped. */ +function formatBytes(bytes: number): string { + if (!Number.isFinite(bytes) || bytes <= 0) return '0 B'; + if (bytes < 1024) return `${bytes} B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; + if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; + if (bytes < 1024 * 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`; + return `${(bytes / (1024 * 1024 * 1024 * 1024)).toFixed(2)} TB`; +} + +/* localStorage helpers — every renderer module that persists state was + wrapping its get/set calls in the same try/catch idiom to handle + environments where localStorage isn't writable (private-browsing + quirks, certain sandboxed contexts). Centralising the pattern. */ +function safeLocalStorageGet(key: string, fallback = ''): string { + try { return localStorage.getItem(key) ?? fallback; } catch { return fallback; } +} + +function safeLocalStorageSet(key: string, value: string): void { + try { localStorage.setItem(key, value); } catch { /* localStorage may be unavailable */ } +} + +function safeLocalStorageRemove(key: string): void { + try { localStorage.removeItem(key); } catch { /* localStorage may be unavailable */ } +} + +let config: AppConfig = {}; +let currentStreamer: string | null = null; +let isConnected = false; +let downloading = false; +let queue: QueueItem[] = []; +let selectedQueueIds: string[] = []; +let expandedQueueIds: Set = new Set(); +let queueDragDropInitialized = false; + +let cutterFile: string | null = null; +let cutterVideoInfo: VideoInfo | null = null; +let cutterStartTime = 0; +let cutterEndTime = 0; +let isCutting = false; + +let mergeFiles: string[] = []; +let isMerging = false; + +let clipDialogData: ClipDialogData | null = null; +let clipTotalSeconds = 0; + +let updateReady = false; +let debugLogAutoRefreshTimer: number | null = null; +let runtimeMetricsAutoRefreshTimer: number | null = null; +let draggedQueueItemId: string | null = null; + +const TEMPLATE_EXACT_TOKENS = new Set([ + '{title}', + '{id}', + '{channel}', + '{channel_id}', + '{date}', + '{part}', + '{part_padded}', + '{trim_start}', + '{trim_end}', + '{trim_length}', + '{length}', + '{ext}', + '{random_string}' +]); + +const TEMPLATE_CUSTOM_TOKEN_PATTERNS = [ + /^\{date_custom=".*"\}$/, + /^\{trim_start_custom=".*"\}$/, + /^\{trim_end_custom=".*"\}$/, + /^\{trim_length_custom=".*"\}$/, + /^\{length_custom=".*"\}$/ +]; + +function isKnownTemplateToken(token: string): boolean { + if (TEMPLATE_EXACT_TOKENS.has(token)) { + return true; + } + + return TEMPLATE_CUSTOM_TOKEN_PATTERNS.some((pattern) => pattern.test(token)); +} + +function collectUnknownTemplatePlaceholders(template: string): string[] { + const tokens = (template.match(/\{[^{}]+\}/g) || []).map((token) => token.trim()); + const unknown = tokens.filter((token) => !isKnownTemplateToken(token)); + return Array.from(new Set(unknown)); +} diff --git a/src/renderer-stats.ts b/src/renderer-stats.ts new file mode 100644 index 0000000..fb3191d --- /dev/null +++ b/src/renderer-stats.ts @@ -0,0 +1,157 @@ +async function refreshArchiveStats(): Promise { + const btn = document.getElementById('btnStatsRefresh') as HTMLButtonElement | null; + if (btn) btn.disabled = true; + const lastLabel = document.getElementById('statsLastScannedLabel'); + if (lastLabel) lastLabel.textContent = (UI_TEXT.static.statsScanning as string) || 'Scanning...'; + + try { + const stats = await window.api.getArchiveStats(); + renderArchiveStats(stats); + } catch (e) { + const summary = document.getElementById('statsSummaryGrid'); + if (summary) summary.textContent = `Fehler: ${String(e)}`; + } finally { + if (btn) btn.disabled = false; + } +} + +function renderArchiveStats(stats: ArchiveStats): void { + const lastLabel = document.getElementById('statsLastScannedLabel'); + if (lastLabel) { + const dt = new Date(stats.scannedAt); + lastLabel.textContent = `${UI_TEXT.static.statsScannedAt}: ${dt.toLocaleString()}`; + } + + renderStatsSummary(stats); + renderStatsTopStreamers(stats.topStreamers, stats.totalBytes); + renderStatsActivity(stats.dailyActivity); + renderStatsSizeBuckets(stats.sizeBuckets); +} + +function renderStatsSummary(stats: ArchiveStats): void { + const grid = document.getElementById('statsSummaryGrid'); + if (!grid) return; + + if (!stats.rootExists) { + applyHtml(grid, `
${escapeHtml(UI_TEXT.static.statsNoRoot)}
`); + return; + } + + const cards: Array<{ label: string; value: string; sub?: string }> = [ + { label: UI_TEXT.static.statsTotalRecordings, value: String(stats.liveCount + stats.vodCount), sub: formatBytes(stats.liveBytes + stats.vodBytes) }, + { label: UI_TEXT.static.statsLiveRecordings, value: String(stats.liveCount), sub: formatBytes(stats.liveBytes) }, + { label: UI_TEXT.static.statsVodRecordings, value: String(stats.vodCount), sub: formatBytes(stats.vodBytes) }, + { label: UI_TEXT.static.statsStreamers, value: String(stats.streamerCount) }, + { label: UI_TEXT.static.statsAvgSize, value: stats.avgRecordingSizeBytes > 0 ? formatBytes(stats.avgRecordingSizeBytes) : '-' }, + { label: UI_TEXT.static.statsChatFiles, value: String(stats.chatCount), sub: formatBytes(stats.chatBytes) } + ]; + + applyHtml(grid, cards.map((c) => ` +
+
${escapeHtml(c.label)}
+
${escapeHtml(c.value)}
+ ${c.sub ? `
${escapeHtml(c.sub)}
` : ''} +
+ `).join('')); +} + +function renderStatsTopStreamers(top: ArchiveStatsTopStreamer[], totalBytes: number): void { + const container = document.getElementById('statsTopStreamers'); + if (!container) return; + + if (top.length === 0) { + applyHtml(container, `
${escapeHtml(UI_TEXT.static.statsEmpty)}
`); + return; + } + + const maxBytes = top[0].bytes || 1; + applyHtml(container, top.map((s) => { + const pct = Math.max(2, Math.round((s.bytes / maxBytes) * 100)); + const sharePct = totalBytes > 0 ? ((s.bytes / totalBytes) * 100).toFixed(1) : '0'; + return ` +
+
+ ${escapeHtml(s.streamer)} ${s.fileCount} ${escapeHtml(UI_TEXT.static.statsFiles)} + ${formatBytes(s.bytes)} (${sharePct}%) +
+
+
+ ${(s.liveBytes > 0 || s.vodBytes > 0) ? `
+ ${s.liveBytes > 0 ? `LIVE ${formatBytes(s.liveBytes)}` : ''} + ${s.vodBytes > 0 ? `VOD ${formatBytes(s.vodBytes)}` : ''} +
` : ''} +
+
+ `; + }).join('')); +} + +function renderStatsActivity(days: ArchiveStatsDay[]): void { + const container = document.getElementById('statsActivity'); + if (!container) return; + + if (days.length === 0) { + container.textContent = UI_TEXT.static.statsEmpty; + return; + } + + const maxCount = days.reduce((m, d) => Math.max(m, d.count), 0); + if (maxCount === 0) { + applyHtml(container, `
${escapeHtml(UI_TEXT.static.statsActivityEmpty)}
`); + return; + } + + const bars = days.map((d, idx) => { + const heightPct = Math.max(4, Math.round((d.count / maxCount) * 100)); + const tooltip = `${d.date}: ${d.count} ${UI_TEXT.static.statsFiles} - ${formatBytes(d.bytes)}`; + const showLabel = idx === 0 || idx === days.length - 1 || idx % 7 === 0; + const dayLabel = showLabel ? d.date.slice(5) : ''; + return ` +
+
+
+
+
${escapeHtml(dayLabel)}
+
+ `; + }).join(''); + + const totalCount = days.reduce((s, d) => s + d.count, 0); + const totalBytes = days.reduce((s, d) => s + d.bytes, 0); + applyHtml(container, ` +
${bars}
+
${escapeHtml(UI_TEXT.static.statsActivitySummary + .replace('{count}', String(totalCount)) + .replace('{size}', formatBytes(totalBytes)))}
+ `); +} + +function renderStatsSizeBuckets(buckets: ArchiveStatsBucket[]): void { + const container = document.getElementById('statsSizeBuckets'); + if (!container) return; + + const maxCount = buckets.reduce((m, b) => Math.max(m, b.count), 0); + if (maxCount === 0) { + applyHtml(container, `
${escapeHtml(UI_TEXT.static.statsEmpty)}
`); + return; + } + + applyHtml(container, buckets.map((b) => { + const pct = b.count > 0 ? Math.max(2, Math.round((b.count / maxCount) * 100)) : 0; + return ` +
+
+ ${escapeHtml(b.label)} + ${b.count} ${formatBytes(b.bytes)} +
+
+
+
+
+ `; + }).join('')); +} + + + +(window as unknown as { refreshArchiveStats: typeof refreshArchiveStats }).refreshArchiveStats = refreshArchiveStats; diff --git a/src/renderer-streamers.ts b/src/renderer-streamers.ts new file mode 100644 index 0000000..c2ef35c --- /dev/null +++ b/src/renderer-streamers.ts @@ -0,0 +1,1242 @@ +let selectStreamerRequestId = 0; +let vodRenderTaskId = 0; +const VOD_RENDER_CHUNK_SIZE = 64; + +// Live status snapshot — updated by the main process via the +// 'live-status-batch-update' IPC event. Keys are lowercase logins so +// the lookup is case-insensitive regardless of how the streamer's +// name was added (display-cased vs login-cased). +const liveStatusByLogin = new Map(); + +async function initLiveStatusSubscription(): Promise { + try { + const initial = await window.api.getLiveStatusSnapshot(); + for (const [k, v] of Object.entries(initial)) { + liveStatusByLogin.set(k.toLowerCase(), v === true); + } + renderStreamers(); + } catch (_) { /* poller may not have fired yet — silent */ } + + window.api.onLiveStatusBatchUpdate(({ changes }) => { + let touched = false; + for (const change of changes) { + const key = change.login.toLowerCase(); + const prev = liveStatusByLogin.get(key); + if (prev !== change.isLive) { + liveStatusByLogin.set(key, change.isLive); + touched = true; + } + } + if (touched) renderStreamers(); + }); +} +(window as unknown as { initLiveStatusSubscription: typeof initLiveStatusSubscription }).initLiveStatusSubscription = initLiveStatusSubscription; + +// VOD filter state — persists across renderer reloads via localStorage so the +// user's search query survives an app restart. Cleared explicitly via Esc / +// the clear button. Shared across streamers (acts like a search bar). +let lastLoadedVods: VOD[] = []; +let lastLoadedStreamer: string | null = null; +let vodFilterQuery = ''; +const VOD_FILTER_STORAGE_KEY = 'twitch-vod-manager:vod-filter'; + +// Bulk-select state — keyed by VOD URL since URL is unique per VOD. Cleared +// on streamer switch (selection is per-streamer mental model). NOT persisted +// because a stale selection across reloads is more confusing than helpful. +const selectedVodUrls = new Set(); +let vodGridDelegationInitialized = false; + +// Hide-downloaded toggle: when enabled, the VOD grid skips entries whose +// vod.id is in config.downloaded_vod_ids. Persisted to localStorage so a +// power user who keeps it enabled doesn't have to re-flip it every launch. +const VOD_HIDE_DOWNLOADED_STORAGE_KEY = 'twitch-vod-manager:vod-hide-downloaded'; +let vodHideDownloaded = false; + +function loadPersistedHideDownloaded(): boolean { + return safeLocalStorageGet(VOD_HIDE_DOWNLOADED_STORAGE_KEY) === '1'; +} + +function persistHideDownloaded(value: boolean): void { + safeLocalStorageSet(VOD_HIDE_DOWNLOADED_STORAGE_KEY, value ? '1' : '0'); +} + +function onVodHideDownloadedChange(): void { + const cb = byId('vodHideDownloadedToggle'); + vodHideDownloaded = cb.checked; + persistHideDownloaded(vodHideDownloaded); + if (lastLoadedStreamer) renderVodGridFromCurrentState(); +} + +function syncVodHideDownloadedToggle(): void { + const cb = document.getElementById('vodHideDownloadedToggle') as HTMLInputElement | null; + if (cb) cb.checked = vodHideDownloaded; +} + +type VodSortKey = 'date_desc' | 'date_asc' | 'views_desc' | 'duration_desc' | 'duration_asc'; +const VALID_VOD_SORTS: ReadonlyArray = ['date_desc', 'date_asc', 'views_desc', 'duration_desc', 'duration_asc']; +const VOD_SORT_STORAGE_KEY = 'twitch-vod-manager:vod-sort'; +let vodSortKey: VodSortKey = 'date_desc'; + +function loadPersistedVodSort(): VodSortKey { + const stored = safeLocalStorageGet(VOD_SORT_STORAGE_KEY); + if (stored && (VALID_VOD_SORTS as readonly string[]).includes(stored)) { + return stored as VodSortKey; + } + return 'date_desc'; +} + +function persistVodSort(key: VodSortKey): void { + safeLocalStorageSet(VOD_SORT_STORAGE_KEY, key); +} + +function vodDurationToSeconds(durationStr: string): number { + let total = 0; + const h = durationStr.match(/(\d+)h/); + const m = durationStr.match(/(\d+)m/); + const s = durationStr.match(/(\d+)s/); + if (h) total += parseInt(h[1], 10) * 3600; + if (m) total += parseInt(m[1], 10) * 60; + if (s) total += parseInt(s[1], 10); + return total; +} + +function sortVods(vods: VOD[], key: VodSortKey): VOD[] { + const sorted = [...vods]; + const ts = (s: string): number => { + const n = new Date(s).getTime(); + return Number.isFinite(n) ? n : 0; + }; + switch (key) { + case 'date_desc': + sorted.sort((a, b) => ts(b.created_at) - ts(a.created_at)); + break; + case 'date_asc': + sorted.sort((a, b) => ts(a.created_at) - ts(b.created_at)); + break; + case 'views_desc': + sorted.sort((a, b) => (b.view_count || 0) - (a.view_count || 0)); + break; + case 'duration_desc': + sorted.sort((a, b) => vodDurationToSeconds(b.duration) - vodDurationToSeconds(a.duration)); + break; + case 'duration_asc': + sorted.sort((a, b) => vodDurationToSeconds(a.duration) - vodDurationToSeconds(b.duration)); + break; + } + return sorted; +} + +function onVodSortChange(): void { + const select = byId('vodSortSelect'); + const value = select.value; + if ((VALID_VOD_SORTS as readonly string[]).includes(value)) { + vodSortKey = value as VodSortKey; + persistVodSort(vodSortKey); + if (lastLoadedStreamer) { + renderVodGridFromCurrentState(); + } + } +} + +function syncVodSortSelect(): void { + const select = document.getElementById('vodSortSelect') as HTMLSelectElement | null; + if (select) select.value = vodSortKey; +} + +function refreshVodSortSelectLabels(): void { + const select = document.getElementById('vodSortSelect') as HTMLSelectElement | null; + if (!select) return; + const labels: Record = { + date_desc: UI_TEXT.vods.sortDateDesc, + date_asc: UI_TEXT.vods.sortDateAsc, + views_desc: UI_TEXT.vods.sortViewsDesc, + duration_desc: UI_TEXT.vods.sortDurationDesc, + duration_asc: UI_TEXT.vods.sortDurationAsc + }; + for (const opt of Array.from(select.options)) { + const k = opt.value as VodSortKey; + if (labels[k]) opt.textContent = labels[k]; + } +} + +function loadPersistedVodFilter(): string { + return safeLocalStorageGet(VOD_FILTER_STORAGE_KEY); +} + +function persistVodFilter(query: string): void { + safeLocalStorageSet(VOD_FILTER_STORAGE_KEY, query); +} + +function filterVodsByQuery(vods: VOD[], query: string): VOD[] { + const q = query.trim().toLowerCase(); + if (!q) return vods; + return vods.filter((vod) => (vod.title || '').toLowerCase().includes(q)); +} + +function updateVodFilterCount(filteredCount: number, totalCount: number): void { + const node = document.getElementById('vodFilterCount'); + if (!node) return; + if (!totalCount || !vodFilterQuery.trim()) { + node.textContent = ''; + return; + } + node.textContent = UI_TEXT.vods.filterMatchCount + .replace('{shown}', String(filteredCount)) + .replace('{total}', String(totalCount)); +} + +function syncVodFilterClearButton(): void { + const btn = document.getElementById('vodFilterClearBtn') as HTMLButtonElement | null; + if (!btn) return; + btn.classList.toggle('is-hidden', !vodFilterQuery.trim()); +} + +function onVodFilterInput(): void { + const input = byId('vodFilterInput'); + vodFilterQuery = input.value; + persistVodFilter(vodFilterQuery); + syncVodFilterClearButton(); + if (lastLoadedStreamer) { + renderVodGridFromCurrentState(); + } +} + +function clearVodFilter(): void { + vodFilterQuery = ''; + const input = byId('vodFilterInput'); + if (input) input.value = ''; + persistVodFilter(''); + syncVodFilterClearButton(); + if (lastLoadedStreamer) { + renderVodGridFromCurrentState(); + } +} + +function focusVodFilter(): void { + const input = document.getElementById('vodFilterInput') as HTMLInputElement | null; + if (input) { + input.focus(); + input.select(); + } +} + +function buildVodCardHtml(vod: VOD, streamer: string, downloadedIds?: Set): string { + const thumb = vod.thumbnail_url.replace('%{width}', '320').replace('%{height}', '180'); + const date = formatUiDate(vod.created_at); + const safeDisplayTitle = escapeHtml(vod.title || UI_TEXT.vods.untitled); + const safeUrlAttr = escapeHtml(vod.url); + const safeTitleAttr = escapeHtml(vod.title || ''); + const safeStreamerAttr = escapeHtml(streamer); + const safeDateAttr = escapeHtml(vod.created_at); + const safeDurationAttr = escapeHtml(vod.duration); + const safeIdAttr = escapeHtml(vod.id); + const isChecked = selectedVodUrls.has(vod.url); + const isAlreadyDownloaded = downloadedIds ? downloadedIds.has(vod.id) : false; + const downloadedBadge = isAlreadyDownloaded + ? `
` + : ''; + + // All identity attributes go on data-* — a delegated listener on #vodGrid + // reads them at click time. This removes the previous inline-onclick + // template-injection pattern (escapedTitle dance) which was fragile for + // titles containing backslashes / HTML entities like '. + return ` +
+ + ${downloadedBadge} +
+ +
${escapeHtml(vod.duration)}
+
+
+
${safeDisplayTitle}
+
+ ${date} + ${escapeHtml(vod.duration)} + ${formatUiNumber(vod.view_count)} ${escapeHtml(UI_TEXT.vods.views)} +
+
+
+ + +
+
+ `; +} + +interface VodCardContext { + id: string; + url: string; + title: string; + date: string; + streamer: string; + duration: string; +} + +function readVodCardContext(card: HTMLElement | null): VodCardContext | null { + if (!card) return null; + const url = card.dataset.vodUrl; + if (!url) return null; + return { + id: card.dataset.vodId || '', + url, + title: card.dataset.vodTitle || '', + date: card.dataset.vodDate || '', + streamer: card.dataset.vodStreamer || '', + duration: card.dataset.vodDuration || '' + }; +} + +let streamerDragInitialized = false; +let draggedStreamerName: string | null = null; + +// Streamer list filter — only kicks in once the user has more than a handful +// of streamers. The input stays display:none below the threshold to avoid +// visual clutter for normal users with 1-3 streamers. +const STREAMER_FILTER_THRESHOLD = 6; +let streamerListFilterQuery = ''; + +// Per-streamer VOD scroll position. When the user clicks back to a streamer +// they've already viewed, restore where they were instead of jumping to top. +// Lives in localStorage so it survives reloads. +const VOD_SCROLL_POSITIONS_KEY = 'twitch-vod-manager:vod-scroll-positions'; +let vodScrollPositions: Record = {}; +let pendingScrollRestore: { streamer: string; y: number } | null = null; + +function loadVodScrollPositions(): void { + try { + const raw = localStorage.getItem(VOD_SCROLL_POSITIONS_KEY); + if (!raw) return; + const parsed = JSON.parse(raw); + if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) { + const cleaned: Record = {}; + for (const [k, v] of Object.entries(parsed)) { + if (typeof v === 'number' && Number.isFinite(v) && v >= 0) cleaned[k] = v; + } + vodScrollPositions = cleaned; + } + } catch { /* localStorage unavailable */ } +} + +function persistVodScrollPositions(): void { + try { + // Cap to last 32 entries to bound storage size. + const entries = Object.entries(vodScrollPositions); + if (entries.length > 32) { + vodScrollPositions = Object.fromEntries(entries.slice(entries.length - 32)); + } + localStorage.setItem(VOD_SCROLL_POSITIONS_KEY, JSON.stringify(vodScrollPositions)); + } catch { /* ignore */ } +} + +function rememberCurrentVodScroll(): void { + if (!lastLoadedStreamer) return; + const grid = document.getElementById('vodGrid'); + if (!grid) return; + // Find the scroll container — vodGrid sits inside a scrollable .content + const scrollable = (grid.closest('.content') as HTMLElement | null) || grid; + const y = scrollable.scrollTop; + if (Number.isFinite(y) && y >= 0) { + vodScrollPositions[lastLoadedStreamer] = y; + persistVodScrollPositions(); + } +} + +let vodScrollSaveTimer: number | null = null; + +function initVodScrollTracking(): void { + const grid = document.getElementById('vodGrid'); + if (!grid) return; + const scrollable = (grid.closest('.content') as HTMLElement | null) || grid; + scrollable.addEventListener('scroll', () => { + if (vodScrollSaveTimer) window.clearTimeout(vodScrollSaveTimer); + vodScrollSaveTimer = window.setTimeout(() => { + vodScrollSaveTimer = null; + rememberCurrentVodScroll(); + }, 250); + }, { passive: true }); +} + +function initCutterDragDrop(): void { + const tab = document.getElementById('cutterTab'); + if (!tab) return; + + let dragOverCount = 0; + const setDragVisual = (active: boolean): void => { + const preview = document.getElementById('cutterPreview'); + if (preview) preview.classList.toggle('drag-over', active); + }; + + tab.addEventListener('dragenter', (e) => { + if (!e.dataTransfer || !Array.from(e.dataTransfer.types).includes('Files')) return; + e.preventDefault(); + dragOverCount++; + setDragVisual(true); + }); + tab.addEventListener('dragover', (e) => { + if (!e.dataTransfer || !Array.from(e.dataTransfer.types).includes('Files')) return; + e.preventDefault(); + e.dataTransfer.dropEffect = 'copy'; + }); + tab.addEventListener('dragleave', () => { + dragOverCount = Math.max(0, dragOverCount - 1); + if (dragOverCount === 0) setDragVisual(false); + }); + tab.addEventListener('drop', async (e) => { + if (!e.dataTransfer) return; + e.preventDefault(); + dragOverCount = 0; + setDragVisual(false); + + const files = Array.from(e.dataTransfer.files || []); + if (files.length === 0) return; + // First video-ish file wins + const allowed = /\.(mp4|mkv|ts|mov|avi)$/i; + const file = files.find((f) => allowed.test(f.name)) || files[0]; + // Electron extends File with .path even with contextIsolation:true + const filePath = (file as unknown as { path?: string }).path || ''; + if (!filePath) return; + + const loader = (window as unknown as { loadCutterFromPath?: (p: string) => Promise }).loadCutterFromPath; + if (typeof loader === 'function') { + await loader(filePath); + } + }); +} + +function renderStreamers(): void { + const list = byId('streamerList'); + list.replaceChildren(); + + const all = (config.streamers ?? []) as string[]; + const filterInput = document.getElementById('streamerListFilter') as HTMLInputElement | null; + const sectionTitle = document.getElementById('streamerSectionTitle'); + const showFilter = all.length >= STREAMER_FILTER_THRESHOLD; + if (filterInput) filterInput.classList.toggle('is-hidden', !showFilter); + // Compact title margin when filter is shown — avoids double gap. + if (sectionTitle) sectionTitle.classList.toggle('compact', showFilter); + + // Empty state — small hint inside the sidebar when no streamers have + // been added yet. Without this the user sees a heading + blank space + // and has to guess where to add the first streamer. + if (all.length === 0) { + const empty = document.createElement('div'); + empty.className = 'streamer-list-empty'; + empty.textContent = UI_TEXT.streamers.sidebarEmpty || 'No streamers yet. Add one via the top bar.'; + list.appendChild(empty); + const counter = document.getElementById('streamerSectionCounter'); + if (counter) counter.textContent = ''; + const bulkBtn = document.getElementById('btnStreamerBulkRemove') as HTMLButtonElement | null; + if (bulkBtn) bulkBtn.classList.add('is-hidden'); + return; + } + + // Section counter — "X · Y live". Updates on every re-render, so it + // stays accurate after add/remove/live-status changes. + const counter = document.getElementById('streamerSectionCounter'); + if (counter) { + const liveCount = all.reduce((n, s) => n + (liveStatusByLogin.get(s.toLowerCase()) === true ? 1 : 0), 0); + if (all.length === 0) { + counter.textContent = ''; + } else if (liveCount > 0) { + counter.innerHTML = `${all.length} ${liveCount} live`; + } else { + counter.textContent = String(all.length); + } + } + + const q = (streamerListFilterQuery || '').trim().toLowerCase(); + const visible = q ? all.filter((s) => s.toLowerCase().includes(q)) : all; + + visible.forEach((streamer: string) => { + const item = document.createElement('div'); + item.className = 'streamer-item' + (currentStreamer === streamer ? ' active' : ''); + item.setAttribute('draggable', 'true'); + item.dataset.streamerName = streamer; + // Keyboard a11y for the row itself — click selects the streamer. + // Each chip inside still gets its own focus + Enter/Space wiring + // and stops propagation, so tabbing through a row lands on row + // first, then AUTO / VOD / REC / remove in order. + item.setAttribute('role', 'button'); + item.setAttribute('tabindex', '0'); + item.setAttribute('aria-label', streamer); + if (currentStreamer === streamer) item.setAttribute('aria-current', 'true'); + + // Live-dot — red pulsing dot when this streamer is currently + // broadcasting on Twitch. Populated from the live-status batch + // poller's snapshot. Renders before the name so the streamer + // identity stays primary visually. + const isLive = liveStatusByLogin.get(streamer.toLowerCase()) === true; + if (isLive) { + const dot = document.createElement('span'); + dot.className = 'streamer-live-dot'; + const liveLabel = UI_TEXT.streamers.liveNowTooltip || 'Live now'; + dot.title = liveLabel; + dot.setAttribute('role', 'img'); + dot.setAttribute('aria-label', liveLabel); + item.appendChild(dot); + } + + const nameSpan = document.createElement('span'); + nameSpan.className = 'streamer-name' + (isLive ? ' is-live' : ''); + nameSpan.textContent = streamer; + + // Three streamer-row action chips (AUTO toggle / VOD toggle / REC + // one-shot). All share the same accessibility wiring: + // role="button", tabindex="0", aria-pressed for the toggles + + // aria-label for screen readers, plus Enter/Space keydown + // activation. wireChipButton centralises that so each chip only + // declares its own visual class + label + handler. + const wireChipButton = (el: HTMLElement, opts: { + handler: () => void; + ariaLabel: string; + pressed?: boolean; + }): void => { + el.setAttribute('role', 'button'); + el.setAttribute('tabindex', '0'); + el.setAttribute('aria-label', opts.ariaLabel); + if (opts.pressed !== undefined) el.setAttribute('aria-pressed', String(opts.pressed)); + el.addEventListener('click', (e) => { + e.stopPropagation(); + opts.handler(); + }); + el.addEventListener('keydown', (e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + e.stopPropagation(); + opts.handler(); + } + }); + }; + + // AUTO toggle — when enabled, the main-process auto-record poller + // watches this channel for offline->live transitions and queues a + // live recording automatically. + const autoList = (config.auto_record_streamers as string[] | undefined) || []; + const isAutoOn = autoList.includes(streamer); + const autoBtn = document.createElement('span'); + autoBtn.className = 'streamer-auto' + (isAutoOn ? ' active' : ''); + autoBtn.textContent = 'AUTO'; + autoBtn.title = UI_TEXT.streamers?.autoRecordTitle || 'Auto-record when this streamer goes live'; + wireChipButton(autoBtn, { + handler: () => { void toggleAutoRecord(streamer); }, + ariaLabel: UI_TEXT.streamers?.autoRecordTitle || 'Auto-record', + pressed: isAutoOn + }); + + // VOD-auto-download toggle — periodic scan of this streamer's + // VOD list, auto-queues anything new within the age window. + const vodList = (config.auto_vod_download_streamers as string[] | undefined) || []; + const isVodOn = vodList.includes(streamer); + const vodBtn = document.createElement('span'); + vodBtn.className = 'streamer-vod' + (isVodOn ? ' active' : ''); + vodBtn.textContent = 'VOD'; + vodBtn.title = UI_TEXT.streamers?.autoVodTitle || 'Auto-download new VODs'; + wireChipButton(vodBtn, { + handler: () => { void toggleAutoVodDownload(streamer); }, + ariaLabel: UI_TEXT.streamers?.autoVodTitle || 'Auto-download new VODs', + pressed: isVodOn + }); + + // Live-record one-shot — triggers a recording immediately (server + // verifies the streamer is online before honoring the request). + const recBtn = document.createElement('span'); + recBtn.className = 'streamer-rec'; + recBtn.textContent = 'REC'; + recBtn.title = UI_TEXT.streamers?.recordLiveTitle || 'Record live now'; + wireChipButton(recBtn, { + handler: () => { void triggerLiveRecording(streamer); }, + ariaLabel: UI_TEXT.streamers?.recordLiveTitle || 'Record live now' + }); + const removeSpan = document.createElement('span'); + removeSpan.className = 'remove'; + removeSpan.textContent = 'x'; + removeSpan.setAttribute('role', 'button'); + removeSpan.setAttribute('tabindex', '0'); + removeSpan.setAttribute('aria-label', UI_TEXT.streamers.removeAria); + removeSpan.addEventListener('click', (e) => { + e.stopPropagation(); + void removeStreamer(streamer); + }); + removeSpan.addEventListener('keydown', (e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + e.stopPropagation(); + void removeStreamer(streamer); + } + }); + item.append(nameSpan, autoBtn, vodBtn, recBtn, removeSpan); + + item.addEventListener('click', () => { + // Skip click if drag was just released — drop fires after dragend + if (draggedStreamerName === streamer) return; + void selectStreamer(streamer); + }); + item.addEventListener('keydown', (e) => { + // Activate row on Enter / Space when the row itself (not a + // chip child) is focused. The chips already preventDefault + // + stopPropagation on their own keydowns so they won't reach + // this handler. + if (e.key !== 'Enter' && e.key !== ' ') return; + if (e.target !== item) return; + e.preventDefault(); + void selectStreamer(streamer); + }); + list.appendChild(item); + }); + + // Reveal bulk-remove button only above the filter threshold. + const bulkBtn = document.getElementById('btnStreamerBulkRemove') as HTMLButtonElement | null; + if (bulkBtn) bulkBtn.classList.toggle('is-hidden', all.length < STREAMER_FILTER_THRESHOLD); + + initStreamerDragDrop(); +} + +function onStreamerListFilterChange(): void { + const input = byId('streamerListFilter'); + streamerListFilterQuery = input.value; + renderStreamers(); +} + +async function bulkRemoveStreamers(): Promise { + const all = (config.streamers ?? []) as string[]; + if (all.length === 0) return; + const q = (streamerListFilterQuery || '').trim().toLowerCase(); + // If a filter is active, target only the matching streamers; else + // require explicit confirmation to clear the entire list. + const targets = q ? all.filter((s) => s.toLowerCase().includes(q)) : all; + if (targets.length === 0) return; + + const messageTemplate = q ? UI_TEXT.static.streamerBulkRemoveFiltered : UI_TEXT.static.streamerBulkRemoveAll; + if (!confirm(messageTemplate.replace('{count}', String(targets.length)))) return; + + const remaining = all.filter((s) => !targets.includes(s)); + config.streamers = remaining; + config = await window.api.saveConfig({ streamers: remaining }); + if (currentStreamer && targets.includes(currentStreamer)) { + currentStreamer = null; + const hide = (window as unknown as { hideStreamerProfileHeader?: () => void }).hideStreamerProfileHeader; + if (typeof hide === 'function') hide(); + } + streamerListFilterQuery = ''; + const input = document.getElementById('streamerListFilter') as HTMLInputElement | null; + if (input) input.value = ''; + renderStreamers(); +} + +function initStreamerDragDrop(): void { + if (streamerDragInitialized) return; + streamerDragInitialized = true; + + const list = byId('streamerList'); + + list.addEventListener('dragstart', (e: DragEvent) => { + const target = e.target as HTMLElement; + const item = target.closest('.streamer-item') as HTMLElement | null; + if (!item || !item.dataset.streamerName) return; + draggedStreamerName = item.dataset.streamerName; + item.classList.add('dragging'); + if (e.dataTransfer) { + e.dataTransfer.effectAllowed = 'move'; + // Some browsers refuse the drag without setData + e.dataTransfer.setData('text/plain', draggedStreamerName); + } + }); + + list.addEventListener('dragover', (e: DragEvent) => { + if (!draggedStreamerName) return; + e.preventDefault(); + if (e.dataTransfer) e.dataTransfer.dropEffect = 'move'; + }); + + list.addEventListener('drop', async (e: DragEvent) => { + e.preventDefault(); + const target = (e.target as HTMLElement).closest('.streamer-item') as HTMLElement | null; + if (!target || !draggedStreamerName) return; + const targetName = target.dataset.streamerName; + if (!targetName || targetName === draggedStreamerName) return; + + const streamers = [...(config.streamers ?? [])]; + const fromIdx = streamers.indexOf(draggedStreamerName); + const toIdx = streamers.indexOf(targetName); + if (fromIdx < 0 || toIdx < 0) return; + const [moved] = streamers.splice(fromIdx, 1); + streamers.splice(toIdx, 0, moved); + + config.streamers = streamers; + renderStreamers(); + config = await window.api.saveConfig({ streamers }); + }); + + list.addEventListener('dragend', () => { + document.querySelectorAll('.streamer-item.dragging').forEach((el) => el.classList.remove('dragging')); + // Defer clearing draggedStreamerName so the click handler that fires + // after dragend can suppress the spurious select. + const wasDragging = draggedStreamerName; + window.setTimeout(() => { + if (draggedStreamerName === wasDragging) draggedStreamerName = null; + }, 50); + }); +} + +async function addStreamer(): Promise { + const input = byId('newStreamer'); + const name = input.value.trim().toLowerCase(); + if (!name) { + return; + } + + // Twitch usernames: 4-25 characters, alphanumeric + underscore. + // Catch typos / invalid input before it hits the API and silently + // returns "streamer not found". + if (!/^[a-zA-Z0-9_]{4,25}$/.test(name)) { + showAppToast(UI_TEXT.static.streamerInvalid, 'warn'); + return; + } + + if ((config.streamers ?? []).includes(name)) { + return; + } + + config.streamers = [...(config.streamers ?? []), name]; + config = await window.api.saveConfig({ streamers: config.streamers }); + input.value = ''; + renderStreamers(); + await selectStreamer(name); +} + +async function removeStreamer(name: string): Promise { + config.streamers = (config.streamers ?? []).filter((s: string) => s !== name); + config = await window.api.saveConfig({ streamers: config.streamers }); + renderStreamers(); + + if (currentStreamer !== name) { + return; + } + + currentStreamer = null; + const hide = (window as unknown as { hideStreamerProfileHeader?: () => void }).hideStreamerProfileHeader; + if (typeof hide === 'function') hide(); + byId('vodGrid').innerHTML = ` +
+ +

${UI_TEXT.vods.noneTitle}

+

${UI_TEXT.vods.noneText}

+
+ `; +} + +async function selectStreamer(name: string, forceRefresh = false): Promise { + // Save where we were on the OLD streamer before navigating away. + rememberCurrentVodScroll(); + + const requestId = ++selectStreamerRequestId; + const isStaleRequest = () => requestId !== selectStreamerRequestId || currentStreamer !== name; + + currentStreamer = name; + // Schedule a scroll-restore once the VOD grid renders. The actual + // restore runs after renderVODs replaces the grid. + const savedY = vodScrollPositions[name]; + pendingScrollRestore = (typeof savedY === 'number' && savedY > 0) ? { streamer: name, y: savedY } : null; + renderStreamers(); + const setTitle = (window as unknown as { setPageTitle?: (text: string) => void }).setPageTitle; + if (typeof setTitle === 'function') setTitle(name); + else byId('pageTitle').textContent = name; + + // Kick off the profile header load in parallel with VOD fetching. + // It's a separate request stream and not strictly needed for the VOD + // grid, so we don't await it here — the skeleton appears immediately. + const profileLoader = (window as unknown as { loadStreamerProfile?: (login: string) => Promise }).loadStreamerProfile; + if (typeof profileLoader === 'function') { + void profileLoader(name); + } + + if (!isConnected) { + await connect(); + if (isStaleRequest()) { + return; + } + } + + if (!isConnected) { + updateStatus(UI_TEXT.status.noLogin, false); + } + + // Skeleton loader — six placeholder cards while VODs come in. Much + // less jarring than a "Loading..." text block in an otherwise blank + // grid. Shimmer animation is in CSS. + byId('vodGrid').innerHTML = Array.from({ length: 6 }, () => ` +
+
+
+
+
+
+
+
+ `).join(''); + + const userId = await window.api.getUserId(name); + if (isStaleRequest()) { + return; + } + + if (!userId) { + byId('vodGrid').innerHTML = `

${UI_TEXT.vods.notFound}

`; + return; + } + + const vods = await window.api.getVODs(userId, forceRefresh); + if (isStaleRequest()) { + return; + } + + renderVODs(vods, name); +} + +function setVodGridEmptyState(grid: HTMLElement, title: string, text: string): void { + // Build via DOM API so the (locale-only) strings can never escape into HTML. + const wrap = document.createElement('div'); + wrap.className = 'empty-state'; + const h3 = document.createElement('h3'); + h3.textContent = title; + const p = document.createElement('p'); + p.textContent = text; + wrap.appendChild(h3); + wrap.appendChild(p); + grid.replaceChildren(wrap); +} + +function renderVODs(vods: VOD[] | null | undefined, streamer: string): void { + // Clear bulk-selection on streamer switch — selection is per-streamer + if (lastLoadedStreamer && lastLoadedStreamer !== streamer && selectedVodUrls.size > 0) { + selectedVodUrls.clear(); + updateVodBulkBar(); + } + lastLoadedVods = Array.isArray(vods) ? vods : []; + lastLoadedStreamer = streamer; + initVodGridSelectionDelegation(); + renderVodGridFromCurrentState(); + + // After the first chunk lands the grid has size, so scroll-restore can + // succeed. Use a small delay to let chunked rendering paint. + if (pendingScrollRestore && pendingScrollRestore.streamer === streamer) { + const target = pendingScrollRestore; + pendingScrollRestore = null; + window.setTimeout(() => { + const grid = document.getElementById('vodGrid'); + if (!grid) return; + const scrollable = (grid.closest('.content') as HTMLElement | null) || grid; + scrollable.scrollTop = target.y; + }, 80); + } +} + +function initVodGridSelectionDelegation(): void { + if (vodGridDelegationInitialized) return; + vodGridDelegationInitialized = true; + + const grid = document.getElementById('vodGrid'); + if (!grid) return; + + grid.addEventListener('click', (e) => { + const target = e.target as HTMLElement; + // 1) Checkbox toggles (bulk-select) + if (target instanceof HTMLInputElement && target.classList.contains('vod-select-checkbox')) { + const url = target.dataset.vodUrl || ''; + if (!url) return; + if (target.checked) selectedVodUrls.add(url); + else selectedVodUrls.delete(url); + const card = target.closest('.vod-card') as HTMLElement | null; + if (card) card.classList.toggle('selected', target.checked); + updateVodBulkBar(); + return; + } + + // 2) Action buttons (trim / queue) — replaces the previous inline + // onclick template that mangled titles with special characters + const btn = target.closest('button[data-vod-action]') as HTMLButtonElement | null; + if (btn) { + const ctx = readVodCardContext(btn.closest('.vod-card') as HTMLElement | null); + if (!ctx) return; + if (btn.dataset.vodAction === 'trim') { + openClipDialog(ctx.url, ctx.title, ctx.date, ctx.streamer, ctx.duration); + } else if (btn.dataset.vodAction === 'queue') { + void addToQueue(ctx.url, ctx.title, ctx.date, ctx.streamer, ctx.duration); + } + return; + } + + // 3) Click on thumbnail / title / meta -> open VOD on Twitch in the + // OS default browser. Convenient + non-destructive. + const card = target.closest('.vod-card') as HTMLElement | null; + if (!card) return; + if (target.closest('.vod-actions') || target.classList.contains('vod-select-checkbox')) return; + const ctx = readVodCardContext(card); + if (!ctx) return; + void window.api.openExternal(ctx.url); + }); + + grid.addEventListener('contextmenu', (e) => { + const card = (e.target as HTMLElement).closest('.vod-card') as HTMLElement | null; + if (!card) return; + const ctx = readVodCardContext(card); + if (!ctx) return; + e.preventDefault(); + showVodContextMenu(e.clientX, e.clientY, ctx); + }); + + // Enter / Space on a focused VOD card opens the VOD on Twitch — same + // outcome as a mouse click on the thumbnail. Skip when focus is on a + // child (action button, checkbox) because those have their own + // keyboard handlers (native button + checkbox semantics). + grid.addEventListener('keydown', (e) => { + if (e.key !== 'Enter' && e.key !== ' ') return; + const target = e.target as HTMLElement | null; + if (!target) return; + const card = target.closest('.vod-card') as HTMLElement | null; + if (!card || card !== target) return; + const ctx = readVodCardContext(card); + if (!ctx) return; + e.preventDefault(); + void window.api.openExternal(ctx.url); + }); +} + +let activeVodContextMenu: HTMLElement | null = null; + +function closeVodContextMenu(): void { + if (!activeVodContextMenu) return; + activeVodContextMenu.remove(); + activeVodContextMenu = null; +} + +function showVodContextMenu(x: number, y: number, ctx: VodCardContext): void { + closeVodContextMenu(); + + const menu = document.createElement('div'); + menu.className = 'context-menu'; + menu.setAttribute('role', 'menu'); + + const downloadedIds = new Set( + Array.isArray(config.downloaded_vod_ids) + ? (config.downloaded_vod_ids as string[]).filter((id) => typeof id === 'string') + : [] + ); + const isMarkedDownloaded = downloadedIds.has(ctx.id); + + const makeItem = (label: string, onClick: () => void): HTMLElement => { + const el = document.createElement('div'); + el.textContent = label; + el.className = 'context-menu-item'; + el.setAttribute('role', 'menuitem'); + el.addEventListener('click', () => { + try { onClick(); } finally { closeVodContextMenu(); } + }); + return el; + }; + + menu.appendChild(makeItem(UI_TEXT.vods.ctxOpenOnTwitch, () => { + void window.api.openExternal(ctx.url); + })); + menu.appendChild(makeItem(UI_TEXT.vods.ctxCopyUrl, () => { + try { + void navigator.clipboard.writeText(ctx.url); + const toast = (window as unknown as { showAppToast?: (msg: string, kind?: 'info' | 'warn') => void }).showAppToast; + if (toast) toast(UI_TEXT.vods.ctxCopiedUrl, 'info'); + } catch { /* ignore */ } + })); + menu.appendChild(makeItem(UI_TEXT.vods.trimButton, () => { + openClipDialog(ctx.url, ctx.title, ctx.date, ctx.streamer, ctx.duration); + })); + menu.appendChild(makeItem(UI_TEXT.vods.addQueue, () => { + void addToQueue(ctx.url, ctx.title, ctx.date, ctx.streamer, ctx.duration); + })); + menu.appendChild(makeItem( + isMarkedDownloaded ? UI_TEXT.vods.ctxUnmarkDownloaded : UI_TEXT.vods.ctxMarkDownloaded, + () => { void toggleVodDownloadedMark(ctx.id, !isMarkedDownloaded); } + )); + + document.body.appendChild(menu); + activeVodContextMenu = menu; + + // Reposition if it would clip off the viewport + const rect = menu.getBoundingClientRect(); + let left = x; + let top = y; + if (left + rect.width > window.innerWidth - 4) left = Math.max(4, window.innerWidth - rect.width - 4); + if (top + rect.height > window.innerHeight - 4) top = Math.max(4, window.innerHeight - rect.height - 4); + menu.style.left = `${left}px`; + menu.style.top = `${top}px`; + + // Close on click anywhere else / Escape / scroll + const dismissOnClick = (ev: MouseEvent) => { + if (!activeVodContextMenu) return; + if (ev.target instanceof Node && activeVodContextMenu.contains(ev.target)) return; + closeVodContextMenu(); + document.removeEventListener('mousedown', dismissOnClick, true); + document.removeEventListener('keydown', dismissOnEscape, true); + document.removeEventListener('scroll', dismissOnScroll, true); + }; + const dismissOnEscape = (ev: KeyboardEvent) => { + if (ev.key !== 'Escape') return; + closeVodContextMenu(); + document.removeEventListener('mousedown', dismissOnClick, true); + document.removeEventListener('keydown', dismissOnEscape, true); + document.removeEventListener('scroll', dismissOnScroll, true); + }; + const dismissOnScroll = () => { + closeVodContextMenu(); + document.removeEventListener('mousedown', dismissOnClick, true); + document.removeEventListener('keydown', dismissOnEscape, true); + document.removeEventListener('scroll', dismissOnScroll, true); + }; + document.addEventListener('mousedown', dismissOnClick, true); + document.addEventListener('keydown', dismissOnEscape, true); + document.addEventListener('scroll', dismissOnScroll, true); +} + +async function toggleVodDownloadedMark(vodId: string, mark: boolean): Promise { + const result = await window.api.markVodDownloaded(vodId, mark); + if (!result?.success) return; + try { + config = await window.api.getConfig(); + } catch { /* ignore */ } + if (lastLoadedStreamer) renderVodGridFromCurrentState(); +} + +function updateVodBulkBar(): void { + const bar = document.getElementById('vodBulkBar'); + if (!bar) return; + const count = selectedVodUrls.size; + bar.classList.toggle('is-hidden', count === 0); + const countEl = document.getElementById('vodBulkCount'); + if (countEl) { + countEl.textContent = UI_TEXT.vods.bulkSelectedCount.replace('{count}', String(count)); + } +} + +function clearVodSelection(): void { + if (selectedVodUrls.size === 0) return; + selectedVodUrls.clear(); + updateVodBulkBar(); + if (lastLoadedStreamer) renderVodGridFromCurrentState(); +} + +async function toggleAutoRecord(streamer: string): Promise { + const current = ((config.auto_record_streamers as string[]) || []).slice(); + const idx = current.indexOf(streamer); + if (idx >= 0) { + current.splice(idx, 1); + } else { + current.push(streamer); + } + config = await window.api.saveConfig({ auto_record_streamers: current }); + renderStreamers(); + + const toast = (window as unknown as { showAppToast?: (msg: string, kind?: 'info' | 'warn') => void }).showAppToast; + if (toast) { + const wasAdded = idx < 0; + const tmpl = wasAdded ? UI_TEXT.streamers.autoRecordEnabled : UI_TEXT.streamers.autoRecordDisabled; + toast(tmpl.replace('{streamer}', streamer), 'info'); + } +} + +async function toggleAutoVodDownload(streamer: string): Promise { + const current = ((config.auto_vod_download_streamers as string[]) || []).slice(); + const idx = current.indexOf(streamer); + if (idx >= 0) { + current.splice(idx, 1); + } else { + current.push(streamer); + } + config = await window.api.saveConfig({ auto_vod_download_streamers: current }); + renderStreamers(); + + const toast = (window as unknown as { showAppToast?: (msg: string, kind?: 'info' | 'warn') => void }).showAppToast; + if (toast) { + const wasAdded = idx < 0; + const tmpl = wasAdded ? UI_TEXT.streamers.autoVodEnabled : UI_TEXT.streamers.autoVodDisabled; + toast(tmpl.replace('{streamer}', streamer), 'info'); + } +} + +async function triggerLiveRecording(streamer: string): Promise { + const toast = (window as unknown as { showAppToast?: (msg: string, kind?: 'info' | 'warn') => void }).showAppToast; + const result = await window.api.startLiveRecording(streamer); + if (!toast) return; + if (result.success) { + toast(UI_TEXT.streamers.liveRecordingStarted.replace('{streamer}', streamer), 'info'); + return; + } + if (result.error === 'OFFLINE') { + toast(UI_TEXT.streamers.liveRecordingOffline.replace('{streamer}', streamer), 'warn'); + return; + } + if (result.error === 'ALREADY_RECORDING') { + toast(UI_TEXT.streamers.liveRecordingAlreadyActive.replace('{streamer}', streamer), 'warn'); + return; + } + toast(UI_TEXT.streamers.liveRecordingFailed + (result.error ? `: ${result.error}` : ''), 'warn'); +} + +async function bulkMarkSelectedDownloaded(mark: boolean): Promise { + const urls = Array.from(selectedVodUrls); + if (urls.length === 0) return; + + let updated = 0; + for (const url of urls) { + const vod = lastLoadedVods.find((v) => v.url === url); + if (!vod || !vod.id) continue; + try { + const result = await window.api.markVodDownloaded(vod.id, mark); + if (result?.success) updated++; + } catch { /* keep going */ } + } + + if (updated === 0) return; + + try { config = await window.api.getConfig(); } catch { /* ignore */ } + selectedVodUrls.clear(); + updateVodBulkBar(); + if (lastLoadedStreamer) renderVodGridFromCurrentState(); + + const toast = (window as unknown as { showAppToast?: (msg: string, kind?: 'info' | 'warn') => void }).showAppToast; + if (toast) { + const template = mark ? UI_TEXT.vods.bulkMarkedDownloaded : UI_TEXT.vods.bulkUnmarkedDownloaded; + toast(template.replace('{count}', String(updated)), 'info'); + } +} + +async function bulkAddSelectedVodsToQueue(): Promise { + const urls = Array.from(selectedVodUrls); + if (urls.length === 0 || !lastLoadedStreamer) return; + const streamer = lastLoadedStreamer; + + const btn = document.getElementById('vodBulkAddBtn') as HTMLButtonElement | null; + const originalText = btn?.textContent || ''; + if (btn) { + btn.disabled = true; + btn.textContent = UI_TEXT.vods.bulkAdding; + } + + let added = 0; + let skipped = 0; + for (const url of urls) { + const vod = lastLoadedVods.find((v) => v.url === url); + if (!vod) { skipped++; continue; } + try { + queue = await window.api.addToQueue({ + url: vod.url, + title: vod.title, + date: vod.created_at, + streamer, + duration_str: vod.duration + }); + added++; + } catch { + skipped++; + } + } + + selectedVodUrls.clear(); + if (btn) { + btn.disabled = false; + btn.textContent = originalText; + } + updateVodBulkBar(); + renderQueue(); + renderVodGridFromCurrentState(); + + const toast = (window as unknown as { showAppToast?: (msg: string, kind?: 'info' | 'warn') => void }).showAppToast; + if (toast && added > 0) { + toast(UI_TEXT.vods.bulkAddedToQueue.replace('{count}', String(added)), 'info'); + } else if (toast && skipped > 0) { + toast(UI_TEXT.vods.bulkAddSkipped, 'warn'); + } +} + +function renderVodGridFromCurrentState(): void { + if (!lastLoadedStreamer) return; + + const grid = byId('vodGrid'); + const renderTaskId = ++vodRenderTaskId; + const total = lastLoadedVods.length; + + if (total === 0) { + setVodGridEmptyState(grid, UI_TEXT.vods.noResultsTitle, UI_TEXT.vods.noResultsText); + updateVodFilterCount(0, 0); + return; + } + + const sorted = sortVods(lastLoadedVods, vodSortKey); + const downloadedIdsForFilter = new Set( + Array.isArray(config.downloaded_vod_ids) + ? (config.downloaded_vod_ids as string[]).filter((id) => typeof id === 'string') + : [] + ); + const sortedAndHidden = vodHideDownloaded + ? sorted.filter((vod) => !downloadedIdsForFilter.has(vod.id)) + : sorted; + const filtered = filterVodsByQuery(sortedAndHidden, vodFilterQuery); + + if (filtered.length === 0 && vodFilterQuery.trim()) { + setVodGridEmptyState(grid, UI_TEXT.vods.filterNoMatchTitle, UI_TEXT.vods.filterNoMatchText); + updateVodFilterCount(0, total); + return; + } + + grid.replaceChildren(); + updateVodFilterCount(filtered.length, total); + + // Build the downloaded-ids lookup once per render — Set.has is O(1) vs + // Array.includes which would be O(n*m) across all cards. + const downloadedIds = new Set( + Array.isArray(config.downloaded_vod_ids) + ? (config.downloaded_vod_ids as string[]).filter((id) => typeof id === 'string') + : [] + ); + + const scheduleNextChunk = (nextStartIndex: number): void => { + const delayMs = document.hidden ? 16 : 0; + window.setTimeout(() => { + renderChunk(nextStartIndex); + }, delayMs); + }; + + const renderChunk = (startIndex: number): void => { + if (renderTaskId !== vodRenderTaskId) { + return; + } + + const chunk = filtered.slice(startIndex, startIndex + VOD_RENDER_CHUNK_SIZE); + if (!chunk.length) { + return; + } + + grid.insertAdjacentHTML('beforeend', chunk.map((vod) => buildVodCardHtml(vod, lastLoadedStreamer || '', downloadedIds)).join('')); + + if (startIndex + chunk.length < filtered.length) { + scheduleNextChunk(startIndex + chunk.length); + } + }; + + renderChunk(0); +} + +async function refreshVODs(): Promise { + if (!currentStreamer) { + return; + } + + await selectStreamer(currentStreamer, true); +} diff --git a/src/renderer-texts.ts b/src/renderer-texts.ts new file mode 100644 index 0000000..5fe5503 --- /dev/null +++ b/src/renderer-texts.ts @@ -0,0 +1,352 @@ +type LanguageCode = 'de' | 'en'; + +const UI_TEXTS = { + de: UI_TEXT_DE, + en: UI_TEXT_EN +} as const; + +let currentLanguage: LanguageCode = 'en'; +let UI_TEXT: (typeof UI_TEXTS)[LanguageCode] = UI_TEXTS[currentLanguage]; + +function getIntlLocale(): string { + return currentLanguage === 'en' ? 'en-US' : 'de-DE'; +} + +function formatUiDate(input: string | Date): string { + const date = input instanceof Date ? input : new Date(input); + return date.toLocaleDateString(getIntlLocale()); +} + +function formatUiNumber(value: number): string { + return value.toLocaleString(getIntlLocale()); +} + +function setText(id: string, value: string): void { + const node = document.getElementById(id); + if (node) node.textContent = value; +} + +function setAriaLabelAll(selector: string, value: string): void { + document.querySelectorAll(selector).forEach((el) => { + el.setAttribute('aria-label', value); + }); +} + +function setPlaceholder(id: string, value: string): void { + const node = document.getElementById(id) as HTMLInputElement | null; + if (node) node.placeholder = value; +} + +function setTitle(id: string, value: string): void { + const node = document.getElementById(id); + if (node) node.setAttribute('title', value); +} + +function setAriaLabel(id: string, value: string): void { + const node = document.getElementById(id); + if (node) node.setAttribute('aria-label', value); +} + +function setLanguage(lang: string): LanguageCode { + currentLanguage = lang === 'en' ? 'en' : 'de'; + UI_TEXT = UI_TEXTS[currentLanguage]; + applyLanguageToStaticUI(); + return currentLanguage; +} + +function applyLanguageToStaticUI(): void { + setText('logoText', UI_TEXT.appName); + setText('navVodsText', UI_TEXT.static.navVods); + setText('navClipsText', UI_TEXT.static.navClips); + setText('navCutterText', UI_TEXT.static.navCutter); + setText('navMergeText', UI_TEXT.static.navMerge); + setText('navStatsText', UI_TEXT.static.navStats); + setText('navArchiveText', UI_TEXT.static.navArchive); + setText('archiveTitle', UI_TEXT.static.archiveTitle); + setText('archiveIntro', UI_TEXT.static.archiveIntro); + setText('btnArchiveSearch', UI_TEXT.static.archiveSearchBtn); + const archiveQueryInput = document.getElementById('archiveSearchQuery') as HTMLInputElement | null; + if (archiveQueryInput) archiveQueryInput.placeholder = UI_TEXT.static.archiveSearchPlaceholder; + setAriaLabel('archiveSearchQuery', UI_TEXT.static.archiveSearchAria); + const archiveTypeSelect = document.getElementById('archiveSearchType') as HTMLSelectElement | null; + if (archiveTypeSelect) { + const opts = archiveTypeSelect.options; + if (opts[0]) opts[0].text = UI_TEXT.static.archiveAllTypes; + if (opts[1]) opts[1].text = UI_TEXT.static.archiveTypeLive; + if (opts[2]) opts[2].text = UI_TEXT.static.archiveTypeVod; + } + const archiveSortSelect = document.getElementById('archiveSearchSort') as HTMLSelectElement | null; + if (archiveSortSelect) { + const opts = archiveSortSelect.options; + if (opts[0]) opts[0].text = UI_TEXT.static.archiveSortDateDesc; + if (opts[1]) opts[1].text = UI_TEXT.static.archiveSortDateAsc; + if (opts[2]) opts[2].text = UI_TEXT.static.archiveSortSizeDesc; + if (opts[3]) opts[3].text = UI_TEXT.static.archiveSortSizeAsc; + if (opts[4]) opts[4].text = UI_TEXT.static.archiveSortNameAsc; + } + setText('navSettingsText', UI_TEXT.static.navSettings); + setText('statsTitle', UI_TEXT.static.statsTitle); + const statsIntroEl = document.getElementById('statsIntro'); + if (statsIntroEl) applyHtml(statsIntroEl, UI_TEXT.static.statsIntro); + setText('statsSummaryTitle', UI_TEXT.static.statsSummaryTitle); + setText('statsTopStreamersTitle', UI_TEXT.static.statsTopStreamersTitle); + setText('statsActivityTitle', UI_TEXT.static.statsActivityTitle); + setText('statsSizeBucketsTitle', UI_TEXT.static.statsSizeBucketsTitle); + setText('btnStatsRefresh', UI_TEXT.static.statsRefresh); + setText('queueTitleText', UI_TEXT.static.queueTitle); + setText('healthBadge', UI_TEXT.static.healthUnknown); + setText('btnRetryFailed', UI_TEXT.static.retryFailed); + setTitle('btnRetryFailed', UI_TEXT.static.retryFailedHint); + setText('btnClear', UI_TEXT.static.clearQueue); + setText('refreshText', UI_TEXT.static.refresh); + setText('clipsHeading', UI_TEXT.static.clipsHeading); + setText('clipsInfoTitle', UI_TEXT.static.clipsInfoTitle); + setText('clipsInfoText', UI_TEXT.static.clipsInfoText); + setText('clipTemplateHelp', UI_TEXT.clips.templateHelp); + setPlaceholder('clipFilenameTemplate', UI_TEXT.clips.templatePlaceholder); + setText('clipDialogStartLabel', UI_TEXT.clips.dialogStart); + setText('clipDialogStartTimeLabel', UI_TEXT.clips.dialogStartTime); + setText('clipDialogEndLabel', UI_TEXT.clips.dialogEnd); + setText('clipDialogEndTimeLabel', UI_TEXT.clips.dialogEndTime); + setText('clipDialogDurationLabel', UI_TEXT.clips.dialogDuration); + setText('clipDialogPartLabel', UI_TEXT.clips.dialogPartLabel); + setText('clipDialogPartHint', UI_TEXT.clips.dialogPartHint); + setText('clipDialogFormatLabel', UI_TEXT.clips.dialogFormatLabel); + setText('clipDialogConfirmBtn', UI_TEXT.clips.dialogConfirm); + setPlaceholder('clipUrl', UI_TEXT.clips.urlPlaceholder); + setText('btnClip', UI_TEXT.clips.downloadButton); + setPlaceholder('clipStartPart', UI_TEXT.clips.startPartPlaceholder); + setPlaceholder('cutterFilePath', UI_TEXT.cutter.filePathPlaceholder); + setText('cutterSelectTitle', UI_TEXT.static.cutterSelectTitle); + setText('cutterPreviewPlaceholder', UI_TEXT.static.cutterPreviewPlaceholder); + setText('cutterBrowseBtn', UI_TEXT.static.cutterBrowse); + setPlaceholder('commandPaletteInput', UI_TEXT.static.commandPaletteSearchPlaceholder); + setText('commandPaletteHint', UI_TEXT.static.commandPaletteHint); + setText('cutterInfoDurationLabel', UI_TEXT.cutter.infoDuration); + setText('cutterInfoResolutionLabel', UI_TEXT.cutter.infoResolution); + setText('cutterInfoFpsLabel', UI_TEXT.cutter.infoFps); + setText('cutterInfoSelectionLabel', UI_TEXT.cutter.infoSelection); + setText('cutterStartLabel', UI_TEXT.cutter.startLabel); + setText('cutterEndLabel', UI_TEXT.cutter.endLabel); + setText('btnCut', UI_TEXT.cutter.cut); + setText('mergeTitle', UI_TEXT.static.mergeTitle); + setText('mergeDesc', UI_TEXT.static.mergeDesc); + setText('mergeAddBtn', UI_TEXT.static.mergeAdd); + setText('btnMerge', UI_TEXT.merge.merge); + setText('designTitle', UI_TEXT.static.designTitle); + setText('themeLabel', UI_TEXT.static.themeLabel); + setText('themeLightOption', UI_TEXT.static.themeLight); + setText('languageLabel', UI_TEXT.static.languageLabel); + setText('languageDeText', UI_TEXT.static.languageDe); + setText('languageEnText', UI_TEXT.static.languageEn); + setText('apiTitle', UI_TEXT.static.apiTitle); + setText('apiHelpIntro', UI_TEXT.static.apiHelpIntro); + setText('apiHelpLink', UI_TEXT.static.apiHelpLinkText); + setText('clientIdLabel', UI_TEXT.static.clientIdLabel); + setText('clientSecretLabel', UI_TEXT.static.clientSecretLabel); + setText('saveSettingsBtn', UI_TEXT.static.saveSettings); + setText('downloadSettingsTitle', UI_TEXT.static.downloadSettingsTitle); + setText('storageLabel', UI_TEXT.static.storageLabel); + setText('openFolderBtn', UI_TEXT.static.openFolder); + setText('modeLabel', UI_TEXT.static.modeLabel); + setText('modeFullText', UI_TEXT.static.modeFull); + setText('modePartsText', UI_TEXT.static.modeParts); + setText('partMinutesLabel', UI_TEXT.static.partMinutesLabel); + setText('parallelDownloadsLabel', UI_TEXT.static.parallelDownloadsLabel); + setText('parallelDownloads1', UI_TEXT.static.parallelDownloads1); + setText('parallelDownloads2', UI_TEXT.static.parallelDownloads2); + setText('performanceModeLabel', UI_TEXT.static.performanceModeLabel); + setText('performanceModeStability', UI_TEXT.static.performanceModeStability); + setText('performanceModeBalanced', UI_TEXT.static.performanceModeBalanced); + setText('performanceModeSpeed', UI_TEXT.static.performanceModeSpeed); + setText('smartSchedulerLabel', UI_TEXT.static.smartSchedulerLabel); + setTitle('smartSchedulerLabel', UI_TEXT.static.smartSchedulerHint); + setTitle('smartSchedulerToggle', UI_TEXT.static.smartSchedulerHint); + setText('duplicatePreventionLabel', UI_TEXT.static.duplicatePreventionLabel); + setText('persistQueueLabel', UI_TEXT.static.persistQueueLabel); + setText('autoResumeQueueLabel', UI_TEXT.static.autoResumeQueueLabel); + setTitle('autoResumeQueueLabel', UI_TEXT.static.autoResumeQueueHint); + setTitle('autoResumeQueueToggle', UI_TEXT.static.autoResumeQueueHint); + setText('notifyEachCompletionLabel', UI_TEXT.static.notifyEachCompletionLabel); + setTitle('notifyEachCompletionLabel', UI_TEXT.static.notifyEachCompletionHint); + setTitle('notifyEachCompletionToggle', UI_TEXT.static.notifyEachCompletionHint); + setText('streamlinkDisableAdsLabel', UI_TEXT.static.streamlinkDisableAdsLabel); + setTitle('streamlinkDisableAdsLabel', UI_TEXT.static.streamlinkDisableAdsHint); + setTitle('streamlinkDisableAdsToggle', UI_TEXT.static.streamlinkDisableAdsHint); + setText('downloadChatReplayLabel', UI_TEXT.static.downloadChatReplayLabel); + setTitle('downloadChatReplayLabel', UI_TEXT.static.downloadChatReplayHint); + setTitle('downloadChatReplayToggle', UI_TEXT.static.downloadChatReplayHint); + setText('captureLiveChatLabel', UI_TEXT.static.captureLiveChatLabel); + setTitle('captureLiveChatLabel', UI_TEXT.static.captureLiveChatHint); + setTitle('captureLiveChatToggle', UI_TEXT.static.captureLiveChatHint); + setText('logStreamEventsLabel', UI_TEXT.static.logStreamEventsLabel); + setTitle('logStreamEventsLabel', UI_TEXT.static.logStreamEventsHint); + setTitle('logStreamEventsToggle', UI_TEXT.static.logStreamEventsHint); + setText('streamlinkQualityLabel', UI_TEXT.static.streamlinkQualityLabel); + setTitle('streamlinkQualityLabel', UI_TEXT.static.streamlinkQualityHint); + setTitle('streamlinkQuality', UI_TEXT.static.streamlinkQualityHint); + setText('streamlinkQualityBest', UI_TEXT.static.streamlinkQualityBest); + setText('streamlinkQualitySource', UI_TEXT.static.streamlinkQualitySource); + setText('streamlinkQualityAudio', UI_TEXT.static.streamlinkQualityAudio); + setText('streamerSectionTitleText', UI_TEXT.static.streamerSectionTitle); + setPlaceholder('streamerListFilter', UI_TEXT.static.streamerListFilterPlaceholder); + setAriaLabel('streamerListFilter', UI_TEXT.static.streamerListFilterAria); + setTitle('btnStreamerBulkRemove', UI_TEXT.static.streamerBulkRemoveTitle); + setAriaLabel('btnStreamerBulkRemove', UI_TEXT.static.streamerBulkRemoveTitle); + setAriaLabel('btnAddStreamer', UI_TEXT.static.streamerAddAriaLabel); + setTitle('btnAddStreamer', UI_TEXT.static.streamerAddAriaLabel); + setText('metadataCacheMinutesLabel', UI_TEXT.static.metadataCacheMinutesLabel); + setText('filenameTemplatesTitle', UI_TEXT.static.filenameTemplatesTitle); + setText('vodTemplateLabel', UI_TEXT.static.vodTemplateLabel); + setText('partsTemplateLabel', UI_TEXT.static.partsTemplateLabel); + setText('defaultClipTemplateLabel', UI_TEXT.static.defaultClipTemplateLabel); + setText('filenameTemplateHint', UI_TEXT.static.filenameTemplateHint); + setText('filenameTemplateLint', UI_TEXT.static.templateLintOk); + setText('settingsTemplateGuideBtn', UI_TEXT.static.templateGuideButton); + setText('clipTemplateGuideBtn', UI_TEXT.static.templateGuideButton); + setText('clipTemplateLint', UI_TEXT.static.templateLintOk); + setText('templateGuideTitle', UI_TEXT.static.templateGuideTitle); + setText('templateGuideIntro', UI_TEXT.static.templateGuideIntro); + setText('templateGuideTemplateLabel', UI_TEXT.static.templateGuideTemplateLabel); + setText('templateGuideOutputLabel', UI_TEXT.static.templateGuideOutputLabel); + setText('templateGuideVarsTitle', UI_TEXT.static.templateGuideVarsTitle); + setText('templateGuideVarCol', UI_TEXT.static.templateGuideVarCol); + setText('templateGuideDescCol', UI_TEXT.static.templateGuideDescCol); + setText('templateGuideExampleCol', UI_TEXT.static.templateGuideExampleCol); + setText('templateGuideUseVod', UI_TEXT.static.templateGuideUseVod); + setText('templateGuideUseParts', UI_TEXT.static.templateGuideUseParts); + setText('templateGuideUseClip', UI_TEXT.static.templateGuideUseClip); + setText('templateGuideCloseBtn', UI_TEXT.static.templateGuideClose); + setPlaceholder('templateGuideInput', UI_TEXT.static.vodTemplatePlaceholder); + setPlaceholder('vodFilenameTemplate', UI_TEXT.static.vodTemplatePlaceholder); + setPlaceholder('partsFilenameTemplate', UI_TEXT.static.partsTemplatePlaceholder); + setPlaceholder('defaultClipFilenameTemplate', UI_TEXT.static.defaultClipTemplatePlaceholder); + setText('updateTitle', UI_TEXT.static.updateTitle); + setText('checkUpdateBtn', UI_TEXT.static.checkUpdates); + setText('preflightTitle', UI_TEXT.static.preflightTitle); + setText('btnPreflightRun', UI_TEXT.static.preflightRun); + setText('btnPreflightFix', UI_TEXT.static.preflightFix); + setText('preflightResult', UI_TEXT.static.preflightEmpty); + setText('debugLogTitle', UI_TEXT.static.debugLogTitle); + setText('btnRefreshLog', UI_TEXT.static.refreshLog); + setText('btnOpenDebugLogFile', UI_TEXT.static.openDebugLogFile); + setText('storageCardTitle', UI_TEXT.static.storageCardTitle); + setText('storageCardIntro', UI_TEXT.static.storageCardIntro); + setText('btnRefreshStorage', UI_TEXT.static.storageRefresh); + setText('cleanupTitle', UI_TEXT.static.cleanupTitle); + setText('cleanupIntro', UI_TEXT.static.cleanupIntro); + setText('autoCleanupEnabledLabel', UI_TEXT.static.cleanupEnabledLabel); + setText('autoCleanupDaysLabel', UI_TEXT.static.cleanupDaysLabel); + setText('autoCleanupTargetLabel', UI_TEXT.static.cleanupTargetLabel); + setText('autoCleanupTargetLive', UI_TEXT.static.cleanupTargetLive); + setText('autoCleanupTargetAll', UI_TEXT.static.cleanupTargetAll); + setText('autoCleanupActionLabel', UI_TEXT.static.cleanupActionLabel); + setText('autoCleanupActionArchive', UI_TEXT.static.cleanupActionArchive); + setText('autoCleanupActionDelete', UI_TEXT.static.cleanupActionDelete); + setText('btnCleanupDryRun', UI_TEXT.static.cleanupDryRun); + setText('btnCleanupRunNow', UI_TEXT.static.cleanupRunNow); + setText('discordCardTitle', UI_TEXT.static.discordCardTitle); + setText('discordCardIntro', UI_TEXT.static.discordCardIntro); + setText('discordWebhookUrlLabel', UI_TEXT.static.discordWebhookUrlLabel); + setText('discordNotifyLiveStartLabel', UI_TEXT.static.discordNotifyLiveStartLabel); + setText('discordNotifyLiveEndLabel', UI_TEXT.static.discordNotifyLiveEndLabel); + setText('discordNotifyVodCompleteLabel', UI_TEXT.static.discordNotifyVodCompleteLabel); + setText('autoResumeLiveRecordingLabel', UI_TEXT.static.autoResumeLiveRecordingLabel); + setText('autoMergeResumedPartsLabel', UI_TEXT.static.autoMergeResumedPartsLabel); + setText('deletePartsAfterMergeLabel', UI_TEXT.static.deletePartsAfterMergeLabel); + setText('discordNotifyVodAutoQueuedLabel', UI_TEXT.static.discordNotifyVodAutoQueuedLabel); + setText('autoVodCardTitle', UI_TEXT.static.autoVodCardTitle); + setText('autoVodCardIntro', UI_TEXT.static.autoVodCardIntro); + setText('autoVodPollMinutesLabel', UI_TEXT.static.autoVodPollMinutesLabel); + setText('autoVodMaxAgeHoursLabel', UI_TEXT.static.autoVodMaxAgeHoursLabel); + setText('btnAutoVodScanNow', UI_TEXT.static.autoVodScanNow); + setText('btnAutoRecordScanNow', UI_TEXT.static.autoRecordScanNow); + + // Empty-state copy for the VODs grid (when no streamer is selected + // yet) and the Merge file list (no files added yet). Both were + // hardcoded German in the HTML — English users saw German strings. + setText('vodGridEmptyTitle', UI_TEXT.vods.noneTitle); + setText('vodGridEmptyText', UI_TEXT.vods.noneText); + setText('mergeEmptyText', UI_TEXT.merge.empty); + + // Localize the modal close-button aria-label. The buttons share a + // .modal-close-localizable class so one call updates all five. + setAriaLabelAll('.modal-close-localizable', UI_TEXT.streamers.modalCloseAria); + document.getElementById('cutProgressGauge')?.setAttribute('aria-label', UI_TEXT.streamers.cutProgressAria); + document.getElementById('mergeProgressGauge')?.setAttribute('aria-label', UI_TEXT.streamers.mergeProgressAria); + document.getElementById('updateProgressGauge')?.setAttribute('aria-label', UI_TEXT.streamers.updateProgressAria); + setText('backupCardTitle', UI_TEXT.static.backupCardTitle); + setText('backupCardIntro', UI_TEXT.static.backupCardIntro); + setText('btnExportConfig', UI_TEXT.static.exportConfig); + setText('btnImportConfig', UI_TEXT.static.importConfig); + setText('btnResetDownloadedIds', UI_TEXT.static.resetDownloadedIds); + setText('vodHideDownloadedText', UI_TEXT.vods.hideDownloaded); + setTitle('vodHideDownloadedLabel', UI_TEXT.vods.hideDownloadedTitle); + setText('autoRefreshText', UI_TEXT.static.autoRefresh); + setText('runtimeMetricsTitle', UI_TEXT.static.runtimeMetricsTitle); + setText('btnRefreshMetrics', UI_TEXT.static.runtimeMetricsRefresh); + setText('btnExportMetrics', UI_TEXT.static.runtimeMetricsExport); + setText('runtimeMetricsAutoRefreshText', UI_TEXT.static.runtimeMetricsAutoRefresh); + setText('runtimeMetricsOutput', UI_TEXT.static.runtimeMetricsLoading); + setText('updateText', UI_TEXT.updates.bannerDefault); + setText('updateButton', UI_TEXT.updates.downloadNow); + setText('updateModalEyebrow', UI_TEXT.static.updateTitle); + setText('updateModalTitle', UI_TEXT.updates.modalAvailableTitle); + setText('updateModalDismissBtn', UI_TEXT.updates.modalDismiss); + setText('updateModalConfirmBtn', UI_TEXT.updates.modalDownloadConfirm); + setText('updateModalSkipBtn', UI_TEXT.updates.modalSkipVersion); + setText('updateChangelogLabel', UI_TEXT.updates.changelogLabel); + setText('updateChangelogToggle', UI_TEXT.updates.showChangelog); + setText('updateChangelogEmpty', UI_TEXT.updates.noChangelog); + setPlaceholder('newStreamer', UI_TEXT.static.streamerPlaceholder); + setAriaLabel('newStreamer', UI_TEXT.static.streamerAddAriaLabel); + setPlaceholder('vodFilterInput', UI_TEXT.vods.filterPlaceholder); + setAriaLabel('vodFilterInput', UI_TEXT.vods.filterAria); + setTitle('vodFilterClearBtn', UI_TEXT.vods.filterClearTitle); + setAriaLabel('vodFilterClearBtn', UI_TEXT.vods.filterClearTitle); + setPlaceholder('chatViewerFilter', UI_TEXT.queue.chatViewerFilterPlaceholder); + setAriaLabel('chatViewerFilter', UI_TEXT.queue.chatViewerFilterAria); + setText('vodSortLabel', UI_TEXT.vods.sortLabel); + if (typeof refreshVodSortSelectLabels === 'function') { + refreshVodSortSelectLabels(); + } + setText('vodBulkAddBtn', UI_TEXT.vods.bulkAddToQueue); + setText('vodBulkMarkBtn', UI_TEXT.vods.bulkMarkDownloaded); + setText('vodBulkUnmarkBtn', UI_TEXT.vods.bulkUnmark); + setText('vodBulkClearBtn', UI_TEXT.vods.bulkClear); + if (typeof updateVodBulkBar === 'function') { + // Repopulate the count text in the new locale + updateVodBulkBar(); + } + + const status = document.getElementById('statusText')?.textContent?.trim() || ''; + if (status === UI_TEXTS.de.static.notConnected || status === UI_TEXTS.en.static.notConnected) { + setText('statusText', UI_TEXT.static.notConnected); + } + + const guideRefresh = (window as unknown as { refreshTemplateGuideTexts?: () => void }).refreshTemplateGuideTexts; + if (typeof guideRefresh === 'function') { + guideRefresh(); + } + + const updateRefresh = (window as unknown as { refreshUpdateUiTexts?: () => void }).refreshUpdateUiTexts; + if (typeof updateRefresh === 'function') { + updateRefresh(); + } +} + +function localizeCurrentStatusText(current: string): string { + const map: Record = { + [UI_TEXTS.de.status.noLogin]: 'noLogin', + [UI_TEXTS.en.status.noLogin]: 'noLogin', + [UI_TEXTS.de.status.connecting]: 'connecting', + [UI_TEXTS.en.status.connecting]: 'connecting', + [UI_TEXTS.de.status.connected]: 'connected', + [UI_TEXTS.en.status.connected]: 'connected', + [UI_TEXTS.de.status.connectFailedPublic]: 'connectFailedPublic', + [UI_TEXTS.en.status.connectFailedPublic]: 'connectFailedPublic' + }; + + const key = map[current]; + return key ? UI_TEXT.status[key] : current; +} diff --git a/src/renderer-updates.ts b/src/renderer-updates.ts new file mode 100644 index 0000000..477cd1e --- /dev/null +++ b/src/renderer-updates.ts @@ -0,0 +1,620 @@ +let updateCheckInProgress = false; +let updateDownloadInProgress = false; +let manualUpdateCheckPending = false; +let manualUpdateOutcomeHandled = false; +let latestUpdateVersion = ''; +let latestUpdateInfo: UpdateInfo | null = null; +let latestDownloadProgress: UpdateDownloadProgress | null = null; +let updateBannerState: 'idle' | 'available' | 'downloading' | 'ready' = 'idle'; +let updateChangelogExpanded = false; +let shouldOpenUpdateModalOnAvailable = false; + +const SKIPPED_UPDATE_VERSION_KEY = 'twitch-vod-manager:skipped-update-version'; + +function getSkippedUpdateVersion(): string { + return safeLocalStorageGet(SKIPPED_UPDATE_VERSION_KEY); +} + +function persistSkippedUpdateVersion(version: string): void { + safeLocalStorageSet(SKIPPED_UPDATE_VERSION_KEY, version); +} + +function clearSkippedUpdateVersion(): void { + safeLocalStorageRemove(SKIPPED_UPDATE_VERSION_KEY); +} + +function notifyUpdate(message: string, type: 'info' | 'warn' = 'info'): void { + const toastFn = (window as unknown as { showAppToast?: (msg: string, kind?: 'info' | 'warn') => void }).showAppToast; + if (typeof toastFn === 'function') { + toastFn(message, type); + } else if (type === 'warn') { + alert(message); + } +} + +function rememberUpdateInfo(info?: UpdateInfo | null): UpdateInfo { + const version = info?.version || latestUpdateVersion || latestUpdateInfo?.version || '?'; + latestUpdateVersion = version; + latestUpdateInfo = { + ...(latestUpdateInfo || { version }), + ...(info || {}), + version + }; + return latestUpdateInfo; +} + +function getActiveUpdateInfo(): UpdateInfo { + return rememberUpdateInfo(); +} + +function formatUpdateTemplate(template: string, version: string): string { + return template.replace(/\{version\}/g, version); +} + +function formatReleaseDate(dateValue?: string): string { + if (!dateValue) { + return ''; + } + + const parsed = new Date(dateValue); + if (Number.isNaN(parsed.getTime())) { + return ''; + } + + return new Intl.DateTimeFormat(getIntlLocale(), { dateStyle: 'medium' }).format(parsed); +} + +function getUpdateModalMetaText(info: UpdateInfo): string { + const parts: string[] = []; + const releaseName = (info.releaseName || '').trim(); + const canonicalNames = new Set([info.version, `v${info.version}`]); + + if (releaseName && !canonicalNames.has(releaseName)) { + parts.push(`${UI_TEXT.updates.releasedLabel}: ${releaseName}`); + } + + const formattedDate = formatReleaseDate(info.releaseDate); + if (formattedDate) { + parts.push(formattedDate); + } + + return parts.join(' | '); +} + +function setCheckButtonCheckingState(enabled: boolean): void { + const btn = byId('checkUpdateBtn'); + btn.disabled = enabled; + btn.textContent = enabled ? UI_TEXT.updates.checking : UI_TEXT.static.checkUpdates; +} + +function showUpdateBanner(): void { + byId('updateBanner').classList.add('show'); +} + +function hideUpdateBanner(): void { + byId('updateBanner').classList.remove('show'); +} + +function setUpdateBannerAvailableUi(info: UpdateInfo): void { + const activeInfo = rememberUpdateInfo(info); + updateReady = false; + updateDownloadInProgress = false; + latestDownloadProgress = null; + updateBannerState = 'available'; + + showUpdateBanner(); + byId('updateProgress').classList.add('is-hidden'); + + const bar = byId('updateProgressBar'); + bar.classList.remove('downloading'); + bar.style.width = '0%'; + + byId('updateText').textContent = `Version ${activeInfo.version} ${UI_TEXT.updates.available}`; + const button = byId('updateButton'); + button.textContent = UI_TEXT.updates.downloadNow; + button.disabled = false; +} + +function setDownloadPendingUi(): void { + updateReady = false; + updateBannerState = 'downloading'; + + showUpdateBanner(); + const button = byId('updateButton'); + button.textContent = UI_TEXT.updates.downloading; + button.disabled = true; + byId('updateProgress').classList.remove('is-hidden'); + + const bar = byId('updateProgressBar'); + bar.classList.add('downloading'); + const pendingPct = latestDownloadProgress ? latestDownloadProgress.percent : 30; + bar.style.width = `${pendingPct}%`; + byId('updateProgressGauge').setAttribute('aria-valuenow', String(Math.round(pendingPct))); + + if (!latestDownloadProgress) { + byId('updateText').textContent = `Version ${latestUpdateVersion || '?'} ${UI_TEXT.updates.downloading}`; + } +} + +function setDownloadReadyUi(info?: UpdateInfo): void { + const activeInfo = rememberUpdateInfo(info); + showUpdateBanner(); + updateReady = true; + updateDownloadInProgress = false; + updateBannerState = 'ready'; + latestDownloadProgress = null; + + const bar = byId('updateProgressBar'); + bar.classList.remove('downloading'); + bar.style.width = '100%'; + byId('updateProgressGauge').setAttribute('aria-valuenow', '100'); + + byId('updateProgress').classList.remove('is-hidden'); + byId('updateText').textContent = `Version ${activeInfo.version} ${UI_TEXT.updates.ready}`; + const button = byId('updateButton'); + button.textContent = UI_TEXT.updates.installNow; + button.disabled = false; +} + +function appendInlineMarkdown(target: HTMLElement, text: string): void { + const parts = text.split(/(\*\*[^*]+\*\*)/g); + + for (const part of parts) { + if (!part) { + continue; + } + + const strongMatch = part.match(/^\*\*(.+)\*\*$/); + if (strongMatch) { + const strong = document.createElement('strong'); + strong.textContent = strongMatch[1].trim(); + target.appendChild(strong); + continue; + } + + target.appendChild(document.createTextNode(part)); + } +} + +function renderUpdateChangelog(notes?: string): void { + const card = byId('updateChangelogCard'); + const panel = byId('updateChangelogPanel'); + const content = byId('updateChangelogContent'); + const empty = byId('updateChangelogEmpty'); + const normalized = (notes || '').replace(/\r/g, '').trim(); + + content.innerHTML = ''; + empty.hidden = true; + + if (!normalized) { + card.classList.add('is-hidden'); + panel.hidden = true; + updateChangelogExpanded = false; + return; + } + + card.classList.remove('is-hidden'); + + const fragment = document.createDocumentFragment(); + let currentList: HTMLUListElement | null = null; + let lastBlockWasHeading = false; + + const flushList = (): void => { + currentList = null; + }; + + const ensureList = (): HTMLUListElement => { + if (currentList) { + return currentList; + } + + currentList = document.createElement('ul'); + currentList.className = 'update-changelog-list'; + fragment.appendChild(currentList); + return currentList; + }; + + const appendListItem = (line: string): void => { + const item = document.createElement('li'); + appendInlineMarkdown(item, line); + ensureList().appendChild(item); + }; + + for (const rawLine of normalized.split('\n')) { + const line = rawLine.trim(); + if (!line) { + flushList(); + lastBlockWasHeading = false; + continue; + } + + const boldHeadingMatch = line.match(/^\*\*(.+?)\*\*:?$/); + const markdownHeadingMatch = line.match(/^#{1,6}\s+(.+)$/); + if (boldHeadingMatch || markdownHeadingMatch) { + flushList(); + const heading = document.createElement('h4'); + heading.className = 'update-changelog-heading'; + heading.textContent = (boldHeadingMatch?.[1] || markdownHeadingMatch?.[1] || '').trim(); + fragment.appendChild(heading); + lastBlockWasHeading = true; + continue; + } + + const listMatch = line.match(/^(?:[-*+]\s+|\d+\.\s+)(.+)$/); + if (listMatch) { + appendListItem(listMatch[1].trim()); + lastBlockWasHeading = false; + continue; + } + + if (lastBlockWasHeading) { + appendListItem(line); + lastBlockWasHeading = false; + continue; + } + + flushList(); + const paragraph = document.createElement('p'); + paragraph.className = 'update-changelog-paragraph'; + appendInlineMarkdown(paragraph, line); + fragment.appendChild(paragraph); + lastBlockWasHeading = false; + } + + if (!fragment.childNodes.length) { + empty.hidden = false; + } else { + content.appendChild(fragment); + } + + panel.hidden = !updateChangelogExpanded; +} + +function refreshUpdateChangelogToggleText(): void { + const toggle = byId('updateChangelogToggle'); + const card = byId('updateChangelogCard'); + if (card.classList.contains('is-hidden')) { + return; + } + + toggle.textContent = updateChangelogExpanded ? UI_TEXT.updates.hideChangelog : UI_TEXT.updates.showChangelog; +} + +function refreshUpdateModalTexts(): void { + const info = getActiveUpdateInfo(); + const isReady = updateReady; + + byId('updateModalTitle').textContent = isReady + ? UI_TEXT.updates.modalReadyTitle + : UI_TEXT.updates.modalAvailableTitle; + byId('updateModalMessage').textContent = formatUpdateTemplate( + isReady ? UI_TEXT.updates.modalReadyMessage : UI_TEXT.updates.modalAvailableMessage, + info.version + ); + byId('updateModalDismissBtn').textContent = UI_TEXT.updates.modalDismiss; + byId('updateModalConfirmBtn').textContent = isReady + ? UI_TEXT.updates.modalInstallConfirm + : UI_TEXT.updates.modalDownloadConfirm; + // Skip-version only makes sense before the download. Once the .exe is + // already on disk and ready to install, hide the button. + const skipBtn = byId('updateModalSkipBtn'); + skipBtn.textContent = UI_TEXT.updates.modalSkipVersion; + skipBtn.classList.toggle('is-hidden', isReady); + byId('updateChangelogLabel').textContent = UI_TEXT.updates.changelogLabel; + byId('updateChangelogEmpty').textContent = UI_TEXT.updates.noChangelog; + + const metaText = getUpdateModalMetaText(info); + const meta = byId('updateModalMeta'); + meta.textContent = metaText; + meta.classList.toggle('is-hidden', !metaText); + + renderUpdateChangelog(info.releaseNotes); + refreshUpdateChangelogToggleText(); +} + +function openUpdateModal(info?: UpdateInfo): void { + rememberUpdateInfo(info); + updateChangelogExpanded = false; + byId('updateModal').classList.add('show'); + refreshUpdateModalTexts(); +} + +function dismissUpdateModal(): void { + byId('updateModal').classList.remove('show'); +} + +function skipUpdateVersion(): void { + const v = (latestUpdateInfo?.version || latestUpdateVersion || '').trim(); + if (v) { + persistSkippedUpdateVersion(v); + } + dismissUpdateModal(); + hideUpdateBanner(); + updateBannerState = 'idle'; + // Note: latestUpdateInfo is intentionally kept so a manual "Check for + // updates" can still re-surface the same version if the user changes + // their mind (manual checks bypass the skip-version filter). +} + +function confirmUpdateModal(): void { + dismissUpdateModal(); + + if (updateReady) { + void window.api.installUpdate(); + return; + } + + downloadUpdate(); +} + +function toggleUpdateChangelog(): void { + const card = byId('updateChangelogCard'); + if (card.classList.contains('is-hidden')) { + return; + } + + updateChangelogExpanded = !updateChangelogExpanded; + byId('updateChangelogPanel').hidden = !updateChangelogExpanded; + refreshUpdateChangelogToggleText(); +} + +function handleUpdateModalOverlayClick(event: MouseEvent): void { + if (event.target === byId('updateModal')) { + dismissUpdateModal(); + } +} + +function refreshUpdateUiTexts(): void { + const button = byId('updateButton'); + const progress = byId('updateProgress'); + const bar = byId('updateProgressBar'); + + if (updateBannerState === 'available' && latestUpdateInfo) { + setUpdateBannerAvailableUi(latestUpdateInfo); + } else if (updateBannerState === 'downloading') { + button.textContent = UI_TEXT.updates.downloading; + button.disabled = true; + progress.classList.remove('is-hidden'); + if (latestDownloadProgress) { + bar.classList.remove('downloading'); + bar.style.width = `${latestDownloadProgress.percent}%`; + const mb = (latestDownloadProgress.transferred / 1024 / 1024).toFixed(1); + const totalMb = (latestDownloadProgress.total / 1024 / 1024).toFixed(1); + byId('updateText').textContent = `${UI_TEXT.updates.downloadLabel}: ${mb} / ${totalMb} MB (${latestDownloadProgress.percent.toFixed(0)}%)`; + } else { + setDownloadPendingUi(); + } + } else if (updateBannerState === 'ready' && latestUpdateInfo) { + setDownloadReadyUi(latestUpdateInfo); + } else { + hideUpdateBanner(); + progress.classList.add('is-hidden'); + bar.classList.remove('downloading'); + bar.style.width = '0%'; + byId('updateText').textContent = UI_TEXT.updates.bannerDefault; + button.textContent = UI_TEXT.updates.downloadNow; + button.disabled = false; + } + + refreshUpdateModalTexts(); +} + +async function checkUpdateSilent(): Promise { + try { + shouldOpenUpdateModalOnAvailable = true; + await window.api.checkUpdate(); + } catch { + shouldOpenUpdateModalOnAvailable = false; + // ignore silent updater errors + } +} + +async function checkUpdate(): Promise { + manualUpdateCheckPending = true; + manualUpdateOutcomeHandled = false; + shouldOpenUpdateModalOnAvailable = true; + setCheckButtonCheckingState(true); + + try { + const result = await window.api.checkUpdate(); + + if (result?.error) { + shouldOpenUpdateModalOnAvailable = false; + manualUpdateOutcomeHandled = true; + manualUpdateCheckPending = false; + updateCheckInProgress = false; + setCheckButtonCheckingState(false); + notifyUpdate(UI_TEXT.updates.checkFailed, 'warn'); + return; + } + + const skippedReason = result?.skipped; + if (skippedReason === 'ready-to-install') { + shouldOpenUpdateModalOnAvailable = false; + manualUpdateOutcomeHandled = true; + manualUpdateCheckPending = false; + updateCheckInProgress = false; + setCheckButtonCheckingState(false); + if (latestUpdateInfo || updateReady) { + openUpdateModal(getActiveUpdateInfo()); + } else { + notifyUpdate(UI_TEXT.updates.readyToInstall, 'info'); + } + return; + } + + if (skippedReason === 'in-progress' || skippedReason === 'throttled') { + shouldOpenUpdateModalOnAvailable = false; + manualUpdateOutcomeHandled = true; + manualUpdateCheckPending = false; + updateCheckInProgress = false; + setCheckButtonCheckingState(false); + notifyUpdate(UI_TEXT.updates.checkInProgress, 'info'); + return; + } + + manualUpdateCheckPending = false; + updateCheckInProgress = false; + setCheckButtonCheckingState(false); + + window.setTimeout(() => { + if (!manualUpdateOutcomeHandled && !updateReady && !byId('updateBanner').classList.contains('show')) { + shouldOpenUpdateModalOnAvailable = false; + notifyUpdate(UI_TEXT.updates.latest, 'info'); + } + }, 2500); + } catch { + shouldOpenUpdateModalOnAvailable = false; + manualUpdateOutcomeHandled = true; + manualUpdateCheckPending = false; + updateCheckInProgress = false; + setCheckButtonCheckingState(false); + notifyUpdate(UI_TEXT.updates.checkFailed, 'warn'); + } +} + +function downloadUpdate(): void { + if (updateReady) { + dismissUpdateModal(); + void window.api.installUpdate(); + return; + } + + if (updateDownloadInProgress) { + notifyUpdate(UI_TEXT.updates.downloadInProgress, 'info'); + return; + } + + updateDownloadInProgress = true; + latestDownloadProgress = null; + dismissUpdateModal(); + setDownloadPendingUi(); + + void window.api.downloadUpdate().then((result) => { + if (result?.error) { + updateDownloadInProgress = false; + if (latestUpdateInfo) { + setUpdateBannerAvailableUi(latestUpdateInfo); + } + notifyUpdate(UI_TEXT.updates.downloadFailed, 'warn'); + return; + } + + if (result?.skipped === 'ready-to-install') { + setDownloadReadyUi(getActiveUpdateInfo()); + openUpdateModal(getActiveUpdateInfo()); + return; + } + + if (result?.skipped === 'in-progress') { + notifyUpdate(UI_TEXT.updates.downloadInProgress, 'info'); + } + }).catch(() => { + updateDownloadInProgress = false; + if (latestUpdateInfo) { + setUpdateBannerAvailableUi(latestUpdateInfo); + } + notifyUpdate(UI_TEXT.updates.downloadFailed, 'warn'); + }); +} + +window.api.onUpdateChecking(() => { + updateCheckInProgress = true; + if (manualUpdateCheckPending) { + setCheckButtonCheckingState(true); + } +}); + +window.api.onUpdateAvailable((info: UpdateInfo) => { + const activeInfo = rememberUpdateInfo(info); + updateCheckInProgress = false; + updateReady = false; + updateDownloadInProgress = false; + const wasManual = manualUpdateCheckPending; + manualUpdateCheckPending = false; + manualUpdateOutcomeHandled = true; + latestDownloadProgress = null; + setCheckButtonCheckingState(false); + + // If the user explicitly skipped this exact version, suppress the auto + // notification entirely — banner stays hidden, no modal popup. A manual + // "Check for updates" click overrides the skip so the user can change + // their mind. + const isSkipped = getSkippedUpdateVersion() === activeInfo.version; + if (isSkipped && !wasManual) { + shouldOpenUpdateModalOnAvailable = false; + return; + } + + setUpdateBannerAvailableUi(activeInfo); + + if (shouldOpenUpdateModalOnAvailable) { + openUpdateModal(activeInfo); + } + + shouldOpenUpdateModalOnAvailable = false; +}); + + +window.api.onUpdateNotAvailable(() => { + updateCheckInProgress = false; + setCheckButtonCheckingState(false); + manualUpdateOutcomeHandled = true; + + if (manualUpdateCheckPending) { + notifyUpdate(UI_TEXT.updates.latest, 'info'); + } + + shouldOpenUpdateModalOnAvailable = false; + manualUpdateCheckPending = false; +}); + +window.api.onUpdateDownloadProgress((progress: UpdateDownloadProgress) => { + updateDownloadInProgress = true; + updateBannerState = 'downloading'; + latestDownloadProgress = progress; + + const bar = byId('updateProgressBar'); + bar.classList.remove('downloading'); + bar.style.width = progress.percent + '%'; + byId('updateProgressGauge').setAttribute('aria-valuenow', String(Math.round(progress.percent))); + + showUpdateBanner(); + byId('updateProgress').classList.remove('is-hidden'); + + const mb = (progress.transferred / 1024 / 1024).toFixed(1); + const totalMb = (progress.total / 1024 / 1024).toFixed(1); + byId('updateText').textContent = `${UI_TEXT.updates.downloadLabel}: ${mb} / ${totalMb} MB (${progress.percent.toFixed(0)}%)`; +}); + +window.api.onUpdateDownloaded((info: UpdateInfo) => { + // Once a version is actually downloaded the user clearly stopped + // skipping it — clear the skip flag so future updates aren't masked + // by a stale entry. + clearSkippedUpdateVersion(); + const activeInfo = rememberUpdateInfo(info); + setDownloadReadyUi(activeInfo); + openUpdateModal(activeInfo); +}); + +window.api.onUpdateError(() => { + updateCheckInProgress = false; + const wasDownloading = updateDownloadInProgress; + updateDownloadInProgress = false; + manualUpdateCheckPending = false; + manualUpdateOutcomeHandled = true; + shouldOpenUpdateModalOnAvailable = false; + setCheckButtonCheckingState(false); + + if (!updateReady && latestUpdateInfo) { + setUpdateBannerAvailableUi(latestUpdateInfo); + } + + notifyUpdate(wasDownloading ? UI_TEXT.updates.downloadFailed : UI_TEXT.updates.checkFailed, 'warn'); +}); + +document.addEventListener('keydown', (event) => { + if (event.key === 'Escape' && byId('updateModal').classList.contains('show')) { + dismissUpdateModal(); + } +}); diff --git a/src/renderer-vod-hover.ts b/src/renderer-vod-hover.ts new file mode 100644 index 0000000..03ffb2a --- /dev/null +++ b/src/renderer-vod-hover.ts @@ -0,0 +1,178 @@ +// VOD hover preview. When the user mouses over a VOD card, we lazy-fetch +// the channel's seek-preview storyboard sprite for that VOD and cycle +// through 4 evenly-spaced cells to produce a scrub-preview animation — +// the same UX twitch.tv ships on its VOD browsing pages. +// +// The storyboard fetch goes through the main process (axios via Node's +// http client) so the renderer never has to make its own HTTPS request +// to the Twitch CDN, sidestepping the same set of Electron renderer +// image-loading quirks the avatar code hit. + +interface ActiveHover { + vodId: string; + intervalId: number; + overlay: HTMLElement; + card: HTMLElement; // .vod-card, fuer preview-active toggle (separat vom overlay-host) +} + +const vodStoryboardClientCache = new Map(); +let activeHover: ActiveHover | null = null; +let pendingHoverVodId: string | null = null; + +const HOVER_DEBOUNCE_MS = 220; +const FRAME_INTERVAL_MS = 600; +const FRAMES_TO_CYCLE = 4; +// Bounded cache — each storyboard data URL is ~50-200 KB, so an +// unbounded cache could balloon to hundreds of MB on a long browsing +// session through a streamer with thousands of VODs. FIFO eviction +// keeps the working set fresh without manual cleanup. +const MAX_CLIENT_STORYBOARD_CACHE = 100; + +function rememberStoryboard(vodId: string, sb: VodStoryboard | null): void { + vodStoryboardClientCache.set(vodId, sb); + if (vodStoryboardClientCache.size > MAX_CLIENT_STORYBOARD_CACHE) { + // Map iterator is insertion-ordered — first key is the oldest. + const oldestKey = vodStoryboardClientCache.keys().next().value as string | undefined; + if (oldestKey !== undefined) vodStoryboardClientCache.delete(oldestKey); + } +} + +function ensureVodHoverHandlersBound(): void { + const grid = document.getElementById('vodGrid'); + if (!grid || grid.dataset.hoverBound === '1') return; + grid.dataset.hoverBound = '1'; + + // Delegated mouseover/mouseout on the grid — re-renders of the + // grid replace the card DOM but the grid root persists, so the + // listener stays bound across streamer switches. + grid.addEventListener('mouseover', (e) => { + const target = e.target as HTMLElement | null; + const card = target?.closest('.vod-card') as HTMLElement | null; + if (!card) return; + const vodId = card.dataset.vodId; + if (!vodId) return; + scheduleHoverPreview(card, vodId); + }); + grid.addEventListener('mouseout', (e) => { + const target = e.target as HTMLElement | null; + const card = target?.closest('.vod-card') as HTMLElement | null; + if (!card) return; + // Only clear when leaving the card entirely (not just moving + // within it between child elements). + const related = e.relatedTarget as HTMLElement | null; + if (related && card.contains(related)) return; + clearHoverPreview(); + }); +} + +function scheduleHoverPreview(card: HTMLElement, vodId: string): void { + if (pendingHoverVodId === vodId) return; + pendingHoverVodId = vodId; + // Debounce so rapid mouse passes (scrolling, dragging across cards) + // don't trigger a download for every card brushed. + window.setTimeout(() => { + if (pendingHoverVodId !== vodId) return; + void activateHoverPreview(card, vodId); + }, HOVER_DEBOUNCE_MS); +} + +function clearHoverPreview(): void { + pendingHoverVodId = null; + if (!activeHover) return; + window.clearInterval(activeHover.intervalId); + activeHover.card.classList.remove('preview-active'); + // Brief opacity fade-out, then remove from DOM. + activeHover.overlay.style.opacity = '0'; + const overlayToRemove = activeHover.overlay; + window.setTimeout(() => { try { overlayToRemove.remove(); } catch { /* gone */ } }, 220); + activeHover = null; +} + +async function activateHoverPreview(card: HTMLElement, vodId: string): Promise { + // Stale-guard: user might have moved off the card in the debounce window. + if (pendingHoverVodId !== vodId) return; + + let storyboard: VodStoryboard | null | undefined = vodStoryboardClientCache.get(vodId); + if (storyboard === undefined) { + try { + storyboard = await window.api.getVodStoryboard(vodId); + } catch (_) { + storyboard = null; + } + rememberStoryboard(vodId, storyboard); + } + + // Cursor may have moved on while we awaited; re-check guard. + if (pendingHoverVodId !== vodId) return; + if (!storyboard) return; + + clearHoverPreview(); + + // Pick FRAMES_TO_CYCLE evenly-spaced cells from the first sprite — + // distributes the chosen preview frames across the early/mid portion + // of the VOD. For very short VODs the first sprite is the only one, + // so this still gives a representative spread. + const totalCells = Math.min(storyboard.framesInSprite, storyboard.cols * storyboard.rows); + const stride = Math.max(1, Math.floor(totalCells / FRAMES_TO_CYCLE)); + const cellsToShow: Array<{ col: number; row: number }> = []; + for (let i = 0; i < FRAMES_TO_CYCLE; i++) { + const idx = Math.min(totalCells - 1, i * stride); + const col = idx % storyboard.cols; + const row = Math.floor(idx / storyboard.cols); + cellsToShow.push({ col, row }); + } + + const overlay = document.createElement('div'); + overlay.className = 'vod-storyboard-preview'; + + // Anchor an .vod-thumb-wrap. Wrap-Element hat exakt Thumbnail-Bounds. + const anchor = card.querySelector('.vod-thumb-wrap') as HTMLElement | null; + const host = anchor ?? card; + const hostRect = host.getBoundingClientRect(); + const width = hostRect.width; + const height = hostRect.height; + + if (width <= 0 || height <= 0) return; + if (storyboard.cellWidth <= 0 || storyboard.cellHeight <= 0) return; + + // Position + Size voll inline gesetzt — kein CSS aspect-ratio mehr, das + // sich mit JS-Dimensionen streiten koennte (siehe styles.css, die Klasse + // gibt nur noch Visual + Stacking, keine Geometrie). + overlay.style.top = '0'; + overlay.style.left = '0'; + overlay.style.width = `${width}px`; + overlay.style.height = `${height}px`; + + // Skaliere X und Y unabhaengig, damit eine Cell die Overlay-Box exakt + // fuellt — Twitch-Cell-Aspect kann von 16:9 minimal abweichen. + const scaleX = width / storyboard.cellWidth; + const scaleY = height / storyboard.cellHeight; + overlay.style.backgroundImage = `url("${storyboard.spriteDataUrl.replace(/"/g, '%22')}")`; + overlay.style.backgroundSize = `${storyboard.cols * storyboard.cellWidth * scaleX}px ${storyboard.rows * storyboard.cellHeight * scaleY}px`; + overlay.style.backgroundRepeat = 'no-repeat'; + const first = cellsToShow[0]; + overlay.style.backgroundPosition = `-${first.col * storyboard.cellWidth * scaleX}px -${first.row * storyboard.cellHeight * scaleY}px`; + + host.appendChild(overlay); + // Trigger CSS transition to opacity:1 on the next frame. + requestAnimationFrame(() => { card.classList.add('preview-active'); }); + + let frameIdx = 1; + const intervalId = window.setInterval(() => { + const cell = cellsToShow[frameIdx % cellsToShow.length]; + overlay.style.backgroundPosition = `-${cell.col * storyboard.cellWidth * scaleX}px -${cell.row * storyboard.cellHeight * scaleY}px`; + frameIdx++; + }, FRAME_INTERVAL_MS); + + activeHover = { vodId, intervalId, overlay, card }; +} + +(window as unknown as { ensureVodHoverHandlersBound: typeof ensureVodHoverHandlersBound }).ensureVodHoverHandlersBound = ensureVodHoverHandlersBound; + +// Bind once the grid exists. Tab switches don't re-create the grid, so +// one-time binding via DOMContentLoaded is enough. +if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', () => { ensureVodHoverHandlersBound(); }); +} else { + ensureVodHoverHandlersBound(); +} diff --git a/src/renderer.ts b/src/renderer.ts new file mode 100644 index 0000000..f9d1a28 --- /dev/null +++ b/src/renderer.ts @@ -0,0 +1,1705 @@ +const QUEUE_SYNC_FAST_MS = 900; +const QUEUE_SYNC_DEFAULT_MS = 1800; +const QUEUE_SYNC_IDLE_MS = 4500; +const QUEUE_SYNC_HIDDEN_MS = 9000; +const QUEUE_SYNC_RECENT_ACTIVITY_WINDOW_MS = 15000; + +async function init(): Promise { + const [loadedConfig, initialQueue, isDown, version] = await Promise.all([ + window.api.getConfig(), + window.api.getQueue(), + window.api.isDownloading(), + window.api.getVersion() + ]); + config = loadedConfig; + const language = setLanguage((config.language as string) || 'en'); + config.language = language; + queue = Array.isArray(initialQueue) ? initialQueue : []; + downloading = isDown; + markQueueActivity(); + + byId('versionText').textContent = `v${version}`; + byId('versionInfo').textContent = `Version: v${version}`; + appVersion = version; + document.title = `${UI_TEXT.appName} v${version}`; + + byId('clientId').value = config.client_id ?? ''; + byId('clientSecret').value = config.client_secret ?? ''; + byId('downloadPath').value = config.download_path ?? ''; + byId('themeSelect').value = config.theme ?? 'twitch'; + byId('languageSelect').value = config.language ?? 'en'; + updateLanguagePicker(config.language ?? 'en'); + byId('downloadMode').value = config.download_mode ?? 'full'; + byId('partMinutes').value = String(config.part_minutes ?? 120); + byId('performanceMode').value = (config.performance_mode as string) || 'balanced'; + byId('smartSchedulerToggle').checked = (config.smart_queue_scheduler as boolean) !== false; + byId('duplicatePreventionToggle').checked = (config.prevent_duplicate_downloads as boolean) !== false; + byId('metadataCacheMinutes').value = String((config.metadata_cache_minutes as number) || 10); + byId('vodFilenameTemplate').value = (config.filename_template_vod as string) || DEFAULT_VOD_TEMPLATE; + byId('partsFilenameTemplate').value = (config.filename_template_parts as string) || DEFAULT_PARTS_TEMPLATE; + byId('defaultClipFilenameTemplate').value = (config.filename_template_clip as string) || DEFAULT_CLIP_TEMPLATE; + initSettingsAutoSave(); + + changeTheme(config.theme ?? 'twitch'); + renderStreamers(); + renderQueue(); + + // Keyboard activation for nav-items (Enter / Space). The items are + // div[role="button"][tabindex="0"], so browsers won't synthesise a + // click on Enter/Space natively — we wire it here once via event + // delegation so the listener doesn't need re-binding per tab switch. + const nav = document.querySelector('.nav'); + if (nav && !nav.hasAttribute('data-keynav-bound')) { + nav.setAttribute('data-keynav-bound', '1'); + nav.addEventListener('keydown', (event) => { + const ev = event as KeyboardEvent; + if (ev.key !== 'Enter' && ev.key !== ' ') return; + const target = ev.target as HTMLElement | null; + const item = target?.closest('.nav-item') as HTMLElement | null; + if (!item) return; + const tab = item.dataset.tab; + if (!tab) return; + ev.preventDefault(); + showTab(tab); + }); + } + + // Kick off live-status subscription so the sidebar dots populate. + const liveStatusInit = (window as unknown as { initLiveStatusSubscription?: () => Promise }).initLiveStatusSubscription; + if (typeof liveStatusInit === 'function') void liveStatusInit(); + initQueueDragDrop(); + updateDownloadButtonState(); + updateStatusBarQueueSummary(); + + // Restore persisted VOD filter into the input — the filter itself only + // takes effect once VODs load (renderVODs reads vodFilterQuery). + vodFilterQuery = loadPersistedVodFilter(); + const vodFilterInput = document.getElementById('vodFilterInput') as HTMLInputElement | null; + if (vodFilterInput) vodFilterInput.value = vodFilterQuery; + syncVodFilterClearButton(); + + // Restore persisted VOD sort key. Apply localized labels to