commit dc367633f56fcd609fdcba25e76eb776afd06eaf Author: Sucukdeluxe Date: Sat Aug 1 22:28:08 2026 +0200 Initial public release v1.7.233 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..35e868a --- /dev/null +++ b/.gitignore @@ -0,0 +1,18 @@ +node_modules/ +build/ +dist/ +release/ +.vite/ +coverage/ +*.log +*.log.old +*.bak +rd_downloader_config.json +rd_download_manifest.json +_update_staging/ +apply_update.cmd +.env +.env.* +!.env.example +.DS_Store +Thumbs.db diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..d95c97f --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Sucukdeluxe + +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..7785aad --- /dev/null +++ b/README.md @@ -0,0 +1,308 @@ +# Multi Debrid Downloader + +Desktop downloader for Windows with package-based queue management, multi-provider fallback, automatic extraction, auto-rename, provider statistics, and built-in updates. + +![Platform](https://img.shields.io/badge/platform-Windows%2010%2F11-0078D6) +![Electron](https://img.shields.io/badge/Electron-31.x-47848F) +![React](https://img.shields.io/badge/React-18.x-149ECA) +![TypeScript](https://img.shields.io/badge/TypeScript-5.x-3178C6) +![License](https://img.shields.io/badge/license-MIT-green) + +## Why this tool? + +- JDownloader-style workflow with packages, progress, extraction, history, and clean post-processing. +- Multiple debrid accounts in one app, including provider order, automatic fallback, and per-hoster routing. +- Built for large queues with session persistence, retries, reconnect handling, resume support, and integrity checks. +- Includes an in-app updater for releases published on GitHub. + +## Supported providers + +- AllDebrid API +- AllDebrid Web via browser login +- BestDebrid API +- BestDebrid Web via cookie import +- Debrid-Link with multi-key support +- DDownload login +- 1fichier API +- LinkSnappy login +- Mega-Debrid API +- Mega-Debrid Web +- Real-Debrid + +## Core features + +### Queue and package handling + +- Package-based queue with item status, retries, ETA, speed, provider, and account label. +- Start, pause, stop, cancel, reset, rename, and delete for packages and items. +- Ctrl+Click multi-select and bulk actions. +- Queue backup import/export as JSON. +- Context-menu export for selected packages or selected items as structured TXT re-import files. +- Duplicate handling when adding links: keep, skip, or overwrite. +- Optional start scheduling for a specific time. +- Session recovery after restart with optional auto-resume. +- Optional auto-sorting by progress. + +### Link collection + +- Paste links directly into the collector. +- Import `.txt` export files that preserve package names and optional per-file names. +- Clipboard watcher with automatic link detection. +- `.dlc` import via file picker and drag-and-drop. +- Drag-and-drop of plain links, `.txt` export files, and supported container files. + +### Provider routing and fallback + +- Configurable provider order with primary, secondary, and tertiary fallback. +- Optional automatic provider fallback on unrestrict/download failures. +- Per-hoster routing override, so specific hosters can always use a specific provider. +- Providers can be disabled without deleting stored account data. +- Daily traffic limits per provider. +- Debrid-Link per-key daily limits and per-key daily usage tracking. + +### Accounts and provider tools + +- Central Accounts view with account type, status, info, access data, and actions. +- BestDebrid cookie import directly from a Netscape cookies file. +- AllDebrid browser-login flow and in-app Rapidgator host status display. +- Debrid-Link multi-key management with optional detailed line-by-line key display. +- Debrid-Link API-key statistics popup with per-key Rapidgator traffic quota, link quota, reset, activate/deactivate, and click-to-copy masked keys. +- Reset button for stored account column widths in the Accounts table. + +### Download engine + +- Parallel downloads with resumable transfers when supported. +- Reconnect handling with configurable wait time. +- Circuit-breaker style cooldown and retry handling for provider issues. +- Global speed limit or per-download speed limit mode. +- Bandwidth schedules with time windows and speed caps. +- Live bandwidth chart and session statistics. +- Persistent all-time download counter. + +### Extraction and post-processing + +- Automatic extraction after download. +- Extraction can continue even when the session is stopped or after app restart. +- Hybrid download + extract workflow. +- Extraction backend using native tools by default, with JVM sidecar support available. +- Supports common archive formats including RAR, ZIP, and 7z. +- Nested extraction for archives found inside extracted output. +- Conflict handling: overwrite, skip, rename, or ask. +- Disk-space validation before extraction. +- Package-scoped password reuse for multi-archive sets. +- Optional cleanup of downloaded archives after extraction. +- Optional cleanup of link artifacts and sample files after extraction. +- Optional flat MKV collection folder after package completion. + +### Auto-rename and media cleanup + +- Auto-rename for extracted scene-style files based on folder/source naming. +- Multi-episode token parsing. +- Handles compact episode tokens like `s02e01` directly attached to the title. +- Optional skip of already extracted packages on start. + +### Integrity, history, and backup + +- Optional integrity verification with `CRC32`, `MD5`, and `SHA1`. +- Download history with package details, duration, size, provider, and target folder. +- Backup export/import for restoring app state. +- Persistent config, session, and history files in the Electron `userData` directory. + +### UI and desktop integration + +- Downloads, history, statistics, and settings tabs. +- Progress bars for packages and single items. +- Hoster/provider display showing both source and effective debrid account. +- Minimize-to-tray support. +- Dark/light theme setting. +- Long path support on Windows. +- Default startup window size of `1920x1080`. + +## Installation + +### Prebuilt releases + +1. Download the latest installer or portable build from the releases page. +2. Start the app. +3. Add your provider credentials in `Settings > Accounts`. + +Releases: [GitHub Releases](https://github.com/Sucukdeluxe/multi-debrid-downloader/releases) + +### Build from source + +Requirements: + +- Node.js `20+` +- npm +- Windows `10/11` +- Java Runtime `8+` for the optional JVM extraction backend +- Optional native extraction tools: 7-Zip / WinRAR / UnRAR + +```bash +npm install +npm run dev +``` + +## NPM scripts + +| Command | Description | +| --- | --- | +| `npm run dev` | Starts Vite, tsup watchers, and Electron in development mode | +| `npm run build` | Builds main and renderer bundles | +| `npm run start` | Starts the built app in production mode | +| `npm test` | Runs Vitest unit tests | +| `npm run self-check` | Runs integrated self-checks | +| `npm run verify:release -- --verify-archives --seven-zip ` | Verifies metadata, unpacked redistribution files, and the nested Setup/Portable payloads | +| `npm run release:win` | Builds Windows installer and portable EXE | + +## Typical workflow + +1. Add one or more provider accounts in `Settings > Accounts`. +2. Configure provider order, fallback, and optional hoster routing. +3. Paste links or import `.dlc` files. +4. Adjust package names, target folders, extraction, and cleanup settings if needed. +5. Start the queue and monitor downloads, extraction, and provider status. +6. Review history and statistics after completion. + +## Link export format + +Selected packages or items can be exported from the context menu as a structured text file. Re-importing that file restores the original package grouping, even if it only contains a subset of items from a larger package. + +Example: + +```txt +# rd-link-export: 1 +# package: Dave Staffel 1 +# file: Dave.S01E01.rar +https://example.com/e01 +# file: Dave.S01E02.rar +https://example.com/e02 +``` + +Supported import sources: + +- collector text input +- `Datei importieren` +- drag-and-drop of `.txt` and `.json` + +The optional `# file:` marker preserves the original item name so the imported subset can be rebuilt with the same package name and per-item filename hints. + +## Project structure + +- `src/main` - Electron main process, download engine, provider clients, updater, storage +- `src/preload` - secure IPC bridge +- `src/renderer` - React UI +- `src/shared` - shared types and IPC contracts +- `tests` - unit and integration-style tests +- `resources/extractor-jvm` - optional JVM extraction runtime +- `scripts` - release and build helpers + +## Data and logs + +Runtime files are stored in Electron's `userData` directory, including: + +- `rd_downloader_config.json` +- `rd_session_state.json` +- `rd_history.json` +- `rd_downloader.log` +- `audit.log` +- `rename.log` +- `debug_support_manifest.json` +- `trace.log` +- `trace_config.json` +- `session-logs/session_*.txt` +- `package-logs/package_*.txt` +- `item-logs/item_*.txt` + +`audit.log`, `rename.log`, and `trace.log` are rotated automatically. The current file is kept plus one `.old` backup, and outdated backups are purged automatically. + +### Remote debug server + +For headless or server-style troubleshooting, the app can expose a small authenticated HTTP debug API with live status and log tails. + +Enable it by creating these files in the same runtime folder that contains `rd_downloader.log`: + +- `debug_token.txt` + Example: a long random token such as `rd-debug-please-change-me` +- `debug_port.txt` + Example: `9868` +- `debug_host.txt` (optional) + Default is `127.0.0.1`. Set `0.0.0.0` only if you really want remote access and protect it with firewall, VPN, or reverse proxy. + +After startup, the app also writes `debug_support_manifest.json` into the same runtime folder. This file lists all available endpoints, the authentication method, related runtime files, and the requirements for remote access. + +If you want extra support detail during a flaky or hard-to-reproduce issue, the app also maintains a `trace.log` plus `trace_config.json`. You can enable or disable the support trace from the app menu or remotely via the debug API. By default, the support trace now auto-disables again after 2 hours so it does not stay enabled forever by accident. + +The app menu under `Hilfe` also includes a `Debug-Setup prüfen` action. It verifies the current host/port/token/support-manifest/trace setup locally and now also reports free disk space, current support-log sizes, and an estimated support-bundle size. + +Available endpoints after restart: + +- `GET /health` +- `GET /meta` +- `GET /debug/setup` +- `GET /self-check` +- `GET /host/diagnostics` +- `GET /status` +- `GET /settings` +- `GET /accounts` +- `GET /stats` +- `GET /history?limit=50&status=completed` +- `GET /packages?package=Release&includeItems=1` +- `GET /items?status=downloading&package=Release` +- `GET /session?package=Release` +- `GET /log?lines=100&grep=keyword` +- `GET /logs/main?lines=100&grep=keyword` +- `GET /logs/audit?lines=100&grep=keyword` +- `GET /logs/rename?lines=100&grep=keyword` +- `GET /logs/trace?lines=100&grep=keyword` +- `GET /logs/session?lines=100&grep=keyword` +- `GET /logs/package?package=Release&lines=100&grep=keyword` +- `GET /logs/item?item=episode.part2.rar&lines=100&grep=keyword` +- `GET /trace/config?enable=1¬e=support&durationMinutes=120` +- `GET /support/bundle` +- `GET /diagnostics?package=Release&lines=150` + +Authentication works with either: + +- header: `Authorization: Bearer ` +- query param: `?token=` + +Example from PowerShell: + +```powershell +Invoke-RestMethod "http://SERVER:9868/diagnostics?token=YOUR_TOKEN&package=Release" +Invoke-RestMethod "http://SERVER:9868/settings?token=YOUR_TOKEN" +Invoke-RestMethod "http://SERVER:9868/accounts?token=YOUR_TOKEN" +Invoke-RestMethod "http://SERVER:9868/stats?token=YOUR_TOKEN" +Invoke-RestMethod "http://SERVER:9868/history?token=YOUR_TOKEN&limit=20" +Invoke-RestMethod "http://SERVER:9868/debug/setup?token=YOUR_TOKEN" +Invoke-RestMethod "http://SERVER:9868/self-check?token=YOUR_TOKEN" +Invoke-RestMethod "http://SERVER:9868/logs/audit?token=YOUR_TOKEN&lines=200" +Invoke-RestMethod "http://SERVER:9868/logs/rename?token=YOUR_TOKEN&lines=200" +Invoke-RestMethod "http://SERVER:9868/logs/trace?token=YOUR_TOKEN&lines=200" +Invoke-RestMethod "http://SERVER:9868/trace/config?token=YOUR_TOKEN&enable=1¬e=support&durationMinutes=120" +Invoke-RestMethod "http://SERVER:9868/logs/package?token=YOUR_TOKEN&package=Release&lines=200" +Invoke-RestMethod "http://SERVER:9868/logs/item?token=YOUR_TOKEN&item=episode.part2.rar&lines=200" +Invoke-RestMethod "http://SERVER:9868/host/diagnostics?token=YOUR_TOKEN" +Invoke-WebRequest "http://SERVER:9868/support/bundle?token=YOUR_TOKEN" -OutFile ".\\rd-support-bundle.zip" +``` + +This makes it easy to share one URL plus token during support, so current package status, session state, history, redacted account/settings state, audit actions, rename/MKV move traces, trace data, package/session/item logs, host-side Windows crash hints, disk space, support-log volume, support-bundle size estimates, and even a full ZIP support bundle can be inspected remotely. + +## Troubleshooting + +- Provider does not work: verify credentials, enabled state, provider order, and daily limits. +- Debrid-Link quota looks wrong: open the API-key statistics popup and check the Rapidgator quota for the affected key. +- Extraction fails: verify passwords and installed extraction tools. The native backend is the default; JVM extraction is optional. +- Downloads stall: check active speed limits, bandwidth schedules, reconnect settings, and provider health. +- Accounts table looks misaligned on one machine: use `Spalten zuruecksetzen` in the Accounts view to clear the locally stored column widths. + +## Changelog + +Detailed release history is published on [GitHub Releases](https://github.com/Sucukdeluxe/multi-debrid-downloader/releases). + +## License + +The project is licensed under the MIT License. See `LICENSE`. + +Bundled JVM extractor licenses and redistribution notices are available in `resources/extractor-jvm`. diff --git a/assets/app_icon.ico b/assets/app_icon.ico new file mode 100644 index 0000000..48ca0bf Binary files /dev/null and b/assets/app_icon.ico differ diff --git a/assets/app_icon.png b/assets/app_icon.png new file mode 100644 index 0000000..0e05484 Binary files /dev/null and b/assets/app_icon.png differ diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..de85802 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,9782 @@ +{ + "name": "real-debrid-downloader", + "version": "1.7.233", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "real-debrid-downloader", + "version": "1.7.233", + "license": "MIT", + "dependencies": { + "adm-zip": "0.6.0", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "uuid": "11.1.1" + }, + "devDependencies": { + "@types/adm-zip": "^0.5.7", + "@types/node": "^24.0.13", + "@types/react": "^18.3.12", + "@types/react-dom": "^18.3.1", + "@types/uuid": "^10.0.0", + "@vitejs/plugin-react": "^4.3.4", + "concurrently": "^9.0.1", + "cross-env": "^7.0.3", + "electron": "^31.7.7", + "electron-builder": "^25.1.8", + "rcedit": "^5.0.2", + "tsup": "^8.3.6", + "tsx": "^4.19.2", + "typescript": "^5.7.3", + "vite": "^6.0.5", + "vitest": "^2.1.8", + "wait-on": "^8.0.1", + "yaml": "^2.9.0" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", + "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.28.5", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.0.tgz", + "integrity": "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz", + "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-compilation-targets": "^7.28.6", + "@babel/helper-module-transforms": "^7.28.6", + "@babel/helpers": "^7.28.6", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/traverse": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.1", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz", + "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz", + "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.28.6", + "@babel/helper-validator-option": "^7.27.1", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", + "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", + "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz", + "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.28.6", + "@babel/helper-validator-identifier": "^7.28.5", + "@babel/traverse": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.28.6.tgz", + "integrity": "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", + "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.6.tgz", + "integrity": "sha512-xOBvwq86HHdB7WUDTfKfT/Vuxh7gElQ+Sfti2Cy6yIWNW05P8iUslOVcZ4/sKbE+/jQaukQAdz/gf3724kYdqw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.0.tgz", + "integrity": "sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.0" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.27.1.tgz", + "integrity": "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.27.1.tgz", + "integrity": "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/template": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", + "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.28.6", + "@babel/parser": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz", + "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-globals": "^7.28.0", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "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/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/@electron/asar/node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "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.5.0", + "resolved": "https://registry.npmjs.org/@electron/notarize/-/notarize-2.5.0.tgz", + "integrity": "sha512-jNT8nwH1f9X5GEITXaQ8IF/KdskvIkOFfB2CvwumsveVidzpSc+mvhhTMdAGSYF3O+Nq49lJ7y+ssODRXu06+A==", + "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.3.1", + "resolved": "https://registry.npmjs.org/@electron/osx-sign/-/osx-sign-1.3.1.tgz", + "integrity": "sha512-BAfviURMHpmb1Yb50YbCxnOY0wfwaLXH5KJ4+80zS0gUkzDX3ec23naTlEqKsN+PwYn+a1cCzM7BJ4Wcd3sGzw==", + "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/rebuild": { + "version": "3.6.1", + "resolved": "https://registry.npmjs.org/@electron/rebuild/-/rebuild-3.6.1.tgz", + "integrity": "sha512-f6596ZHpEq/YskUd8emYvOUne89ij8mQgjYFA5ru25QwbrRO+t1SImofdDv7kKOuWCmVOuU5tvfkbgGxIl3E/w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@malept/cross-spawn-promise": "^2.0.0", + "chalk": "^4.0.0", + "debug": "^4.1.1", + "detect-libc": "^2.0.1", + "fs-extra": "^10.0.0", + "got": "^11.7.0", + "node-abi": "^3.45.0", + "node-api-version": "^0.2.0", + "node-gyp": "^9.0.0", + "ora": "^5.1.0", + "read-binary-file-arch": "^1.0.6", + "semver": "^7.3.5", + "tar": "^6.0.5", + "yargs": "^17.0.1" + }, + "bin": { + "electron-rebuild": "lib/cli.js" + }, + "engines": { + "node": ">=12.13.0" + } + }, + "node_modules/@electron/rebuild/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/rebuild/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/rebuild/node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@electron/rebuild/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": "2.0.1", + "resolved": "https://registry.npmjs.org/@electron/universal/-/universal-2.0.1.tgz", + "integrity": "sha512-fKpv9kg4SPmt+hY7SVBnIYULE9QJl8L3sCfcBsnqbJwwBwAeTLokJ9TRt9y7bK0JAzIW2y78TVVjvnQEms/yyA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@electron/asar": "^3.2.7", + "@malept/cross-spawn-promise": "^2.0.0", + "debug": "^4.3.1", + "dir-compare": "^4.2.0", + "fs-extra": "^11.1.1", + "minimatch": "^9.0.3", + "plist": "^3.1.0" + }, + "engines": { + "node": ">=16.4" + } + }, + "node_modules/@electron/universal/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/@electron/universal/node_modules/brace-expansion": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/@electron/universal/node_modules/fs-extra": { + "version": "11.3.3", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.3.3.tgz", + "integrity": "sha512-VWSRii4t0AFm6ixFFmLLx1t7wS1gh+ckoa84aOeapGum0h+EZd1EhEumSB+ZdDLnEPuucsVB9oB7cxJHap6Afg==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=14.14" + } + }, + "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": "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/@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/@esbuild/aix-ppc64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.3.tgz", + "integrity": "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.3.tgz", + "integrity": "sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.3.tgz", + "integrity": "sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.3.tgz", + "integrity": "sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.3.tgz", + "integrity": "sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.3.tgz", + "integrity": "sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.3.tgz", + "integrity": "sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.3.tgz", + "integrity": "sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.3.tgz", + "integrity": "sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.3.tgz", + "integrity": "sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.3.tgz", + "integrity": "sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.3.tgz", + "integrity": "sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.3.tgz", + "integrity": "sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.3.tgz", + "integrity": "sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.3.tgz", + "integrity": "sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.3.tgz", + "integrity": "sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.3.tgz", + "integrity": "sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.3.tgz", + "integrity": "sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.3.tgz", + "integrity": "sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.3.tgz", + "integrity": "sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.3.tgz", + "integrity": "sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.3.tgz", + "integrity": "sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.3.tgz", + "integrity": "sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.3.tgz", + "integrity": "sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.3.tgz", + "integrity": "sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.3.tgz", + "integrity": "sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@gar/promisify": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@gar/promisify/-/promisify-1.1.3.tgz", + "integrity": "sha512-k2Ty1JcVojjJFwrg/ThKi2ujJ7XNLYaFGNB/bWT9wGR+oSMJHMa5w+CUq6p/pVrKeNNgA7pCqEcjSnHVoqJQFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@hapi/address": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/@hapi/address/-/address-5.1.1.tgz", + "integrity": "sha512-A+po2d/dVoY7cYajycYI43ZbYMXukuopIsqCjh5QzsBCipDtdofHntljDlpccMjIfTy6UOkg+5KPriwYch2bXA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@hapi/hoek": "^11.0.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@hapi/formula": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@hapi/formula/-/formula-3.0.2.tgz", + "integrity": "sha512-hY5YPNXzw1He7s0iqkRQi+uMGh383CGdyyIGYtB+W5N3KHPXoqychklvHhKCC9M3Xtv0OCs/IHw+r4dcHtBYWw==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/@hapi/hoek": { + "version": "11.0.7", + "resolved": "https://registry.npmjs.org/@hapi/hoek/-/hoek-11.0.7.tgz", + "integrity": "sha512-HV5undWkKzcB4RZUusqOpcgxOaq6VOAH7zhhIr2g3G8NF/MlFO75SjOr2NfuSx0Mh40+1FqCkagKLJRykUWoFQ==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/@hapi/pinpoint": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@hapi/pinpoint/-/pinpoint-2.0.1.tgz", + "integrity": "sha512-EKQmr16tM8s16vTT3cA5L0kZZcTMU5DUOZTuvpnY738m+jyP3JIUj+Mm1xc1rsLkGBQ/gVnfKYPwOmPg1tUR4Q==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/@hapi/tlds": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/@hapi/tlds/-/tlds-1.1.6.tgz", + "integrity": "sha512-xdi7A/4NZokvV0ewovme3aUO5kQhW9pQ2YD1hRqZGhhSi5rBv4usHYidVocXSi9eihYsznZxLtAiEYYUL6VBGw==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@hapi/topo": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/@hapi/topo/-/topo-6.0.2.tgz", + "integrity": "sha512-KR3rD5inZbGMrHmgPxsJ9dbi6zEK+C3ZwUwTa+eMwWLz7oijWUTWD2pMSNNYJAU6Qq+65NkxXjqHr/7LM2Xkqg==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@hapi/hoek": "^11.0.2" + } + }, + "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.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.2.2" + }, + "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/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "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/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@malept/cross-spawn-promise": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@malept/cross-spawn-promise/-/cross-spawn-promise-2.0.0.tgz", + "integrity": "sha512-1DpKU0Z5ThltBwjNySMC14g0CkbyhCaz9FkhxqNsZI6uAPJXFS8cMXlBKo26FJ8ZuW6S9GCMcR9IO5k2X5/9Fg==", + "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": ">= 12.13.0" + } + }, + "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/@npmcli/fs": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/@npmcli/fs/-/fs-2.1.2.tgz", + "integrity": "sha512-yOJKRvohFOaLqipNtwYB9WugyZKhC/DZC4VYPmpaCzDBrA8YpK3qHZ8/HGscMnE4GqbkLNuVcCnxkeQEdGt6LQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "@gar/promisify": "^1.1.3", + "semver": "^7.3.5" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || >=16.0.0" + } + }, + "node_modules/@npmcli/fs/node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@npmcli/move-file": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@npmcli/move-file/-/move-file-2.0.1.tgz", + "integrity": "sha512-mJd2Z5TjYWq/ttPLLGqArdtnC74J6bOzg4rMDnN+p1xTacZ2yPRCk2y0oSWQtygLR9YVQXgOcONrwtnk3JupxQ==", + "deprecated": "This functionality has been moved to @npmcli/fs", + "dev": true, + "license": "MIT", + "dependencies": { + "mkdirp": "^1.0.4", + "rimraf": "^3.0.2" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || >=16.0.0" + } + }, + "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/pluginutils": { + "version": "1.0.0-beta.27", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", + "integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.59.0.tgz", + "integrity": "sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.59.0.tgz", + "integrity": "sha512-hZ+Zxj3SySm4A/DylsDKZAeVg0mvi++0PYVceVyX7hemkw7OreKdCvW2oQ3T1FMZvCaQXqOTHb8qmBShoqk69Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.59.0.tgz", + "integrity": "sha512-W2Psnbh1J8ZJw0xKAd8zdNgF9HRLkdWwwdWqubSVk0pUuQkoHnv7rx4GiF9rT4t5DIZGAsConRE3AxCdJ4m8rg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.59.0.tgz", + "integrity": "sha512-ZW2KkwlS4lwTv7ZVsYDiARfFCnSGhzYPdiOU4IM2fDbL+QGlyAbjgSFuqNRbSthybLbIJ915UtZBtmuLrQAT/w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.59.0.tgz", + "integrity": "sha512-EsKaJ5ytAu9jI3lonzn3BgG8iRBjV4LxZexygcQbpiU0wU0ATxhNVEpXKfUa0pS05gTcSDMKpn3Sx+QB9RlTTA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.59.0.tgz", + "integrity": "sha512-d3DuZi2KzTMjImrxoHIAODUZYoUUMsuUiY4SRRcJy6NJoZ6iIqWnJu9IScV9jXysyGMVuW+KNzZvBLOcpdl3Vg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.59.0.tgz", + "integrity": "sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.59.0.tgz", + "integrity": "sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.59.0.tgz", + "integrity": "sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.59.0.tgz", + "integrity": "sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.59.0.tgz", + "integrity": "sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.59.0.tgz", + "integrity": "sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.59.0.tgz", + "integrity": "sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.59.0.tgz", + "integrity": "sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.59.0.tgz", + "integrity": "sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.59.0.tgz", + "integrity": "sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.59.0.tgz", + "integrity": "sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.59.0.tgz", + "integrity": "sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.59.0.tgz", + "integrity": "sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.59.0.tgz", + "integrity": "sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.59.0.tgz", + "integrity": "sha512-tt9KBJqaqp5i5HUZzoafHZX8b5Q2Fe7UjYERADll83O4fGqJ49O1FsL6LpdzVFQcpwvnyd0i+K/VSwu/o/nWlA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.59.0.tgz", + "integrity": "sha512-V5B6mG7OrGTwnxaNUzZTDTjDS7F75PO1ae6MJYdiMu60sq0CqN5CVeVsbhPxalupvTX8gXVSU9gq+Rx1/hvu6A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.59.0.tgz", + "integrity": "sha512-UKFMHPuM9R0iBegwzKF4y0C4J9u8C6MEJgFuXTBerMk7EJ92GFVFYBfOZaSGLu6COf7FxpQNqhNS4c4icUPqxA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.59.0.tgz", + "integrity": "sha512-laBkYlSS1n2L8fSo1thDNGrCTQMmxjYY5G0WFWjFFYZkKPjsMBsgJfGf4TLxXrF6RyhI60L8TMOjBMvXiTcxeA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.59.0.tgz", + "integrity": "sha512-2HRCml6OztYXyJXAvdDXPKcawukWY2GpR5/nxKp4iBgiO3wcoEGkAaqctIbZcNB6KlUQBIqt8VYkNSj2397EfA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "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.0", + "resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-2.0.0.tgz", + "integrity": "sha512-XCuKFP5PS55gnMVu3dty8KPatLqUoy/ZYzDzAGCQ8JNFCkLXzmI7vNHCR+XpbZaMWQK/vQubr7PkYq8g470J/A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10" + } + }, + "node_modules/@types/adm-zip": { + "version": "0.5.7", + "resolved": "https://registry.npmjs.org/@types/adm-zip/-/adm-zip-0.5.7.tgz", + "integrity": "sha512-DNEs/QvmyRLurdQPChqq0Md4zGvPwHerAJYWk9l2jCbD1VPpnzRJorOdiq4zsw09NFbYnhfsoEhWtxIzXpn2yw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "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/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/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/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": "24.10.15", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.10.15.tgz", + "integrity": "sha512-BgjLoRuSr0MTI5wA6gMw9Xy0sFudAaUuvrnjgGx9wZ522fYYLA5SYJ+1Y30vTcJEG+DRCyDHx/gzQVfofYzSdg==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.16.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/prop-types": { + "version": "15.7.15", + "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", + "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "18.3.28", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.28.tgz", + "integrity": "sha512-z9VXpC7MWrhfWipitjNdgCauoMLRdIILQsAEV+ZesIzBq/oUlxk0m3ApZuMFCXdnS4U7KrI+l3WRUEGQ8K1QKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/prop-types": "*", + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "18.3.7", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz", + "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^18.0.0" + } + }, + "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/uuid": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-10.0.0.tgz", + "integrity": "sha512-7gqG38EyHgyP1S+7+xomFtL+ZNHcKv6DwNaCZmJmo1vgMugyF3TCnXVg4t1uk89mLNwnLtnY3TpOpCOyp1/xHQ==", + "dev": true, + "license": "MIT" + }, + "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/@vitejs/plugin-react": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", + "integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.28.0", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-beta.27", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.17.0" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" + } + }, + "node_modules/@vitest/expect": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-2.1.9.tgz", + "integrity": "sha512-UJCIkTBenHeKT1TTlKMJWy1laZewsRIzYighyYiJKZreqtdxSos/S1t+ktRMQWu2CKqaarrkeszJx1cgC5tGZw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "2.1.9", + "@vitest/utils": "2.1.9", + "chai": "^5.1.2", + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/pretty-format": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-2.1.9.tgz", + "integrity": "sha512-KhRIdGV2U9HOUzxfiHmY8IFHTdqtOhIzCpd8WRdJiE7D/HUcZVD0EgQCVjm+Q9gkUXWgBvMmTtZgIG48wq7sOQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-2.1.9.tgz", + "integrity": "sha512-ZXSSqTFIrzduD63btIfEyOmNcBmQvgOVsPNPe0jYtESiXkhd8u2erDLnMxmGrDCwHCCHE7hxwRDCT3pt0esT4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "2.1.9", + "pathe": "^1.1.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner/node_modules/pathe": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz", + "integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@vitest/snapshot": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-2.1.9.tgz", + "integrity": "sha512-oBO82rEjsxLNJincVhLhaxxZdEtV0EFHMK5Kmx5sJ6H9L183dHECjiefOAdnqpIgT5eZwT04PoggUnW88vOBNQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "2.1.9", + "magic-string": "^0.30.12", + "pathe": "^1.1.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot/node_modules/pathe": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz", + "integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@vitest/spy": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-2.1.9.tgz", + "integrity": "sha512-E1B35FwzXXTs9FHNK6bDszs7mtydNi5MIfUWpceJ8Xbfb1gBMscAnwLbEu+B44ed6W3XjL9/ehLPHR1fkf1KLQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyspy": "^3.0.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-2.1.9.tgz", + "integrity": "sha512-v0psaMSkNJ3A2NMrUEHFRzJtDPFn+/VWZ5WxImB21T9fjucJRmS7xCS3ppEnARb9y11OAzaD+P2Ps+b+BGX5iQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "2.1.9", + "loupe": "^3.1.2", + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@xmldom/xmldom": { + "version": "0.8.11", + "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.11.tgz", + "integrity": "sha512-cQzWCtO6C8TQiYl1ruKNn2U6Ao4o4WBBcbL61yJl84x+j5sOWWFU9X7DpND8XZG3daDppSsigMdfAIl2upQBRw==", + "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/abbrev": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-1.1.1.tgz", + "integrity": "sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==", + "dev": true, + "license": "ISC" + }, + "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/adm-zip": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/adm-zip/-/adm-zip-0.6.0.tgz", + "integrity": "sha512-XleryMhbuksdKtofnWZ9Sk+4CUTbms4Mb/EU32SZwToAyZ5RgVos/ki8n+yr0LWHOGKuakbXTuuYNHLQjhddgg==", + "license": "MIT", + "engines": { + "node": ">=14.0" + } + }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/agentkeepalive": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/agentkeepalive/-/agentkeepalive-4.6.0.tgz", + "integrity": "sha512-kja8j7PjmncONqaTsB8fQ+wE2mSU2DJ9D4XKoJ5PFWIdRMa6SLSN1ff4mOr4jCbfRSsxR4keIiySJU0N9T5hIQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "humanize-ms": "^1.2.1" + }, + "engines": { + "node": ">= 8.0.0" + } + }, + "node_modules/aggregate-error": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/aggregate-error/-/aggregate-error-3.1.0.tgz", + "integrity": "sha512-4I7Td01quW/RpocfNayFdFVk1qSuoh0E7JrbRJ16nH01HhKFQ88INq9Sd+nd72zqRySlr9BmDA8xlEJ6vJMrYA==", + "dev": true, + "license": "MIT", + "dependencies": { + "clean-stack": "^2.0.0", + "indent-string": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "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/any-promise": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", + "integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==", + "dev": true, + "license": "MIT" + }, + "node_modules/app-builder-bin": { + "version": "5.0.0-alpha.10", + "resolved": "https://registry.npmjs.org/app-builder-bin/-/app-builder-bin-5.0.0-alpha.10.tgz", + "integrity": "sha512-Ev4jj3D7Bo+O0GPD2NMvJl+PGiBAfS7pUGawntBNpCbxtpncfUixqFj9z9Jme7V7s3LBGqsWZZP54fxBX3JKJw==", + "dev": true, + "license": "MIT" + }, + "node_modules/app-builder-lib": { + "version": "25.1.8", + "resolved": "https://registry.npmjs.org/app-builder-lib/-/app-builder-lib-25.1.8.tgz", + "integrity": "sha512-pCqe7dfsQFBABC1jeKZXQWhGcCPF3rPCXDdfqVKjIeWBcXzyC1iOWZdfFhGl+S9MyE/k//DFmC6FzuGAUudNDg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@develar/schema-utils": "~2.6.5", + "@electron/notarize": "2.5.0", + "@electron/osx-sign": "1.3.1", + "@electron/rebuild": "3.6.1", + "@electron/universal": "2.0.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": "25.1.7", + "builder-util-runtime": "9.2.10", + "chromium-pickle-js": "^0.2.0", + "config-file-ts": "0.2.8-rc1", + "debug": "^4.3.4", + "dotenv": "^16.4.5", + "dotenv-expand": "^11.0.6", + "ejs": "^3.1.8", + "electron-publish": "25.1.7", + "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", + "json5": "^2.2.3", + "lazy-val": "^1.0.5", + "minimatch": "^10.0.0", + "resedit": "^1.7.0", + "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": "25.1.8", + "electron-builder-squirrel-windows": "25.1.8" + } + }, + "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.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "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/aproba": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/aproba/-/aproba-2.1.0.tgz", + "integrity": "sha512-tLIEcj5GuR2RSTnxNKdkK0dJ/GrC7P38sUkiDmDuHfsHmbagTFAxDVIBltoklXEVIQ/f14IL8IMJ5pn9Hez1Ew==", + "dev": true, + "license": "ISC" + }, + "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/are-we-there-yet": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/are-we-there-yet/-/are-we-there-yet-3.0.1.tgz", + "integrity": "sha512-QZW4EDmGwlYur0Yyf/b2uGucHQMa8aFUP7eu9ddR73vvhFyt4V0Vl3QHPcTNJ8l6qYOBdxgXdnBXQrHilfRQBg==", + "deprecated": "This package is no longer supported.", + "dev": true, + "license": "ISC", + "dependencies": { + "delegates": "^1.0.0", + "readable-stream": "^3.6.0" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || >=16.0.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==", + "dev": true, + "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==", + "dev": true, + "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.13.5", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.5.tgz", + "integrity": "sha512-cz4ur7Vb0xS4/KUN0tPWe44eqxrIu31me+fbang3ijiNscE129POzipJJA6zniq2C/Z6sJCjMimjS8Lc/GAs8Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.15.11", + "form-data": "^4.0.5", + "proxy-from-env": "^1.1.0" + } + }, + "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/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "dev": true, + "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/baseline-browser-mapping": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.0.tgz", + "integrity": "sha512-lIyg0szRfYbiy67j9KN8IyeD7q7hcmqnJ1ddWmNt19ItGpNN64mnllmxUNFIOdOm6by97jlL6wfpTTJrmnjWAA==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.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==", + "dev": true, + "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": "5.0.3", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.3.tgz", + "integrity": "sha512-fy6KJm2RawA5RcHkLa1z/ScpBeA762UF9KmZQxwIbDtRJrgLzM10depAiEQ+CXYcoiqW1/m96OAAoke2nE9EeA==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/browserslist": { + "version": "4.28.1", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz", + "integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.9.0", + "caniuse-lite": "^1.0.30001759", + "electron-to-chromium": "^1.5.263", + "node-releases": "^2.0.27", + "update-browserslist-db": "^1.2.0" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/buffer": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", + "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", + "dev": true, + "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-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": "25.1.7", + "resolved": "https://registry.npmjs.org/builder-util/-/builder-util-25.1.7.tgz", + "integrity": "sha512-7jPjzBwEGRbwNcep0gGNpLXG9P94VA3CPAZQCzxkFXiV2GMQKlziMbY//rXPI7WKfhsvGgFXjTcXdBEwgXw9ww==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/debug": "^4.1.6", + "7zip-bin": "~5.2.0", + "app-builder-bin": "5.0.0-alpha.10", + "bluebird-lst": "^1.0.9", + "builder-util-runtime": "9.2.10", + "chalk": "^4.1.2", + "cross-spawn": "^7.0.3", + "debug": "^4.3.4", + "fs-extra": "^10.1.0", + "http-proxy-agent": "^7.0.0", + "https-proxy-agent": "^7.0.0", + "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.10", + "resolved": "https://registry.npmjs.org/builder-util-runtime/-/builder-util-runtime-9.2.10.tgz", + "integrity": "sha512-6p/gfG1RJSQeIbz8TK5aPNkoztgY1q5TgmGFMAXcY8itsGW6Y2ld1ALsZ5UJn8rog7hKF3zHx5iQbNQ8uLcRlw==", + "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/bundle-require": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/bundle-require/-/bundle-require-5.1.0.tgz", + "integrity": "sha512-3WrrOuZiyaaZPWiEt4G3+IffISVC9HYlWueJEBWED4ZH4aIAC2PnkdnuRrR94M+w6yGWn4AglWtJtBI8YqvgoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "load-tsconfig": "^0.2.3" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "peerDependencies": { + "esbuild": ">=0.18" + } + }, + "node_modules/cac": { + "version": "6.7.14", + "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", + "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/cacache": { + "version": "16.1.3", + "resolved": "https://registry.npmjs.org/cacache/-/cacache-16.1.3.tgz", + "integrity": "sha512-/+Emcj9DAXxX4cwlLmRI9c166RuL3w30zp4R7Joiv2cQTtTtA+jeuCAjH3ZlGnYS3tKENSrKhAzVVP9GVyzeYQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "@npmcli/fs": "^2.1.0", + "@npmcli/move-file": "^2.0.0", + "chownr": "^2.0.0", + "fs-minipass": "^2.1.0", + "glob": "^8.0.1", + "infer-owner": "^1.0.4", + "lru-cache": "^7.7.1", + "minipass": "^3.1.6", + "minipass-collect": "^1.0.2", + "minipass-flush": "^1.0.5", + "minipass-pipeline": "^1.2.4", + "mkdirp": "^1.0.4", + "p-map": "^4.0.0", + "promise-inflight": "^1.0.1", + "rimraf": "^3.0.2", + "ssri": "^9.0.0", + "tar": "^6.1.11", + "unique-filename": "^2.0.0" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || >=16.0.0" + } + }, + "node_modules/cacache/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/cacache/node_modules/brace-expansion": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/cacache/node_modules/glob": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-8.1.0.tgz", + "integrity": "sha512-r8hpEjiQEYlF2QU0df3dS+nxxSIreXQS1qRhMJM0Q5NDdR386C7jb7Hwwod8Fgiuex+k0GFjgft18yvxm5XoCQ==", + "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": "^5.0.1", + "once": "^1.3.0" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/cacache/node_modules/lru-cache": { + "version": "7.18.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-7.18.3.tgz", + "integrity": "sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/cacache/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/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==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001774", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001774.tgz", + "integrity": "sha512-DDdwPGz99nmIEv216hKSgLD+D4ikHQHjBC/seF98N9CPqRX4M5mSxT9eTV6oyisnJcuzxtZy4n17yKKQYmYQOA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/chai": { + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", + "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "assertion-error": "^2.0.1", + "check-error": "^2.1.1", + "deep-eql": "^5.0.1", + "loupe": "^3.1.0", + "pathval": "^2.0.0" + }, + "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/chalk/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/check-error": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz", + "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 16" + } + }, + "node_modules/chokidar": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", + "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "readdirp": "^4.0.1" + }, + "engines": { + "node": ">= 14.16.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "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/clean-stack": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/clean-stack/-/clean-stack-2.2.0.tgz", + "integrity": "sha512-4diC9HaTE+KRAMWhDhrGOECgWZxoevMc5TlkObMqNSsVU62PYzXZ/SMTjzyGAFF1YusgxGcSWTEXBhp0CPwQ1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/cli-cursor": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-3.1.0.tgz", + "integrity": "sha512-I/zHAwsKf9FqGoXM4WWRACob9+SNukZTd94DWF57E4toouRulbCxcUh6RKUEOQlYTHJnzkPMySvPNaaSLNfLZw==", + "dev": true, + "license": "MIT", + "dependencies": { + "restore-cursor": "^3.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/cli-spinners": { + "version": "2.9.2", + "resolved": "https://registry.npmjs.org/cli-spinners/-/cli-spinners-2.9.2.tgz", + "integrity": "sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "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": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/clone/-/clone-1.0.4.tgz", + "integrity": "sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8" + } + }, + "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/color-support": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/color-support/-/color-support-1.1.3.tgz", + "integrity": "sha512-qiBjkpbMLO/HL68y+lh4q0/O1MZFj2RX6X/KmMa3+gJD3z+WwI1ZzDHysvqHGS3mP6mznPckpXmw1nI9cJjyRg==", + "dev": true, + "license": "ISC", + "bin": { + "color-support": "bin.js" + } + }, + "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==", + "dev": true, + "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/concurrently": { + "version": "9.2.1", + "resolved": "https://registry.npmjs.org/concurrently/-/concurrently-9.2.1.tgz", + "integrity": "sha512-fsfrO0MxV64Znoy8/l1vVIjjHa29SZyyqPgQBwhiDcaW8wJc2W3XWVOGx4M3oJBnv/zdUZIIp1gDeS98GzP8Ng==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "4.1.2", + "rxjs": "7.8.2", + "shell-quote": "1.8.3", + "supports-color": "8.1.1", + "tree-kill": "1.2.2", + "yargs": "17.7.2" + }, + "bin": { + "conc": "dist/bin/concurrently.js", + "concurrently": "dist/bin/concurrently.js" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/open-cli-tools/concurrently?sponsor=1" + } + }, + "node_modules/confbox": { + "version": "0.1.8", + "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.1.8.tgz", + "integrity": "sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/config-file-ts": { + "version": "0.2.8-rc1", + "resolved": "https://registry.npmjs.org/config-file-ts/-/config-file-ts-0.2.8-rc1.tgz", + "integrity": "sha512-GtNECbVI82bT4RiDIzBSVuTKoSHufnU7Ce7/42bkWZJZFLjmDF2WBpVsvRkhKCfKBnTBb3qZrBwPpFBU/Myvhg==", + "dev": true, + "license": "MIT", + "dependencies": { + "glob": "^10.3.12", + "typescript": "^5.4.3" + } + }, + "node_modules/config-file-ts/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/config-file-ts/node_modules/brace-expansion": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "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.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/consola": { + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/consola/-/consola-3.4.2.tgz", + "integrity": "sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^14.18.0 || >=16.10.0" + } + }, + "node_modules/console-control-strings": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/console-control-strings/-/console-control-strings-1.1.0.tgz", + "integrity": "sha512-ty/fTekppD2fIwRvnZAVdeOiGd1c7YXEixbgJTNzqcxJWKQnjJ/V1bNEEE6hygpM3WjwHFUVK6HTjWSzV4a8sQ==", + "dev": true, + "license": "ISC" + }, + "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-env": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/cross-env/-/cross-env-7.0.3.tgz", + "integrity": "sha512-+/HKd6EgcQCJGh2PSjZuUitQBQynKor4wrFbRg4DtAgS1aWO+gU52xpH7M9ScGgXSYmAVS9bIJ8EzuaGw0oNAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "cross-spawn": "^7.0.1" + }, + "bin": { + "cross-env": "src/bin/cross-env.js", + "cross-env-shell": "src/bin/cross-env-shell.js" + }, + "engines": { + "node": ">=10.14", + "npm": ">=6", + "yarn": ">=1" + } + }, + "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/cross-spawn-windows-exe": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/cross-spawn-windows-exe/-/cross-spawn-windows-exe-1.2.0.tgz", + "integrity": "sha512-mkLtJJcYbDCxEG7Js6eUnUNndWjyUZwJ3H7bErmmtOYU/Zb99DyUkpamuIZE0b3bhmJyZ7D90uS6f+CGxRRjOw==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/malept" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/subscription/pkg/npm-cross-spawn-windows-exe?utm_medium=referral&utm_source=npm_fund" + } + ], + "license": "Apache-2.0", + "dependencies": { + "@malept/cross-spawn-promise": "^1.1.0", + "is-wsl": "^2.2.0", + "which": "^2.0.2" + }, + "engines": { + "node": ">= 10" + } + }, + "node_modules/cross-spawn-windows-exe/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/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "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==", + "dev": true, + "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==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/deep-eql": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", + "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/defaults": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/defaults/-/defaults-1.0.4.tgz", + "integrity": "sha512-eFuaLoy/Rxalv2kr+lqMlUnrDWV+3j4pljOIJgLIhI058IQfWJ7vXhyEIHu+HtC738klGALYxOKDO0bQP3tg8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "clone": "^1.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "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==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/delegates": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delegates/-/delegates-1.0.0.tgz", + "integrity": "sha512-bd2L678uiWATM6m5Z1VzNCErI3jiGzt6HGY8OVICs40JQq/HALfbyNJmp0UDakEY4pMMaN0Ly5om/B1VI/+xfQ==", + "dev": true, + "license": "MIT" + }, + "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==", + "dev": true, + "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": "4.2.0", + "resolved": "https://registry.npmjs.org/dir-compare/-/dir-compare-4.2.0.tgz", + "integrity": "sha512-2xMCmOoMrdQIPHdsTawECdNPwlVFB9zGcz3kuhmBO6U3oU+UQjsue0i8ayLKpgBcm+hcXPMVSGUN9d+pvJ6+VQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "minimatch": "^3.0.5", + "p-limit": "^3.1.0 " + } + }, + "node_modules/dir-compare/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/dir-compare/node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "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": "25.1.8", + "resolved": "https://registry.npmjs.org/dmg-builder/-/dmg-builder-25.1.8.tgz", + "integrity": "sha512-NoXo6Liy2heSklTI5OIZbCgXC1RzrDQsZkeEwXhdOro3FT1VBOvbubvscdPnjVuQ4AMwwv61oaH96AbiYg9EnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "app-builder-lib": "25.1.8", + "builder-util": "25.1.7", + "builder-util-runtime": "9.2.10", + "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": "16.6.1", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", + "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/dotenv-expand": { + "version": "11.0.7", + "resolved": "https://registry.npmjs.org/dotenv-expand/-/dotenv-expand-11.0.7.tgz", + "integrity": "sha512-zIHwmZPRshsCdpMDyVsqGmgyP0yT8GAgXUnkdAoJisxvf33k7yO6OuoKmcTGuXPWSsm8Oh88nZicRLA9Y0rUeA==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "dotenv": "^16.4.5" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "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==", + "dev": true, + "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": "31.7.7", + "resolved": "https://registry.npmjs.org/electron/-/electron-31.7.7.tgz", + "integrity": "sha512-HZtZg8EHsDGnswFt0QeV8If8B+et63uD6RJ7I4/xhcXqmTIbI08GoubX/wm+HdY0DwcuPe1/xsgqpmYvjdjRoA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "@electron/get": "^2.0.0", + "@types/node": "^20.9.0", + "extract-zip": "^2.0.1" + }, + "bin": { + "electron": "cli.js" + }, + "engines": { + "node": ">= 12.20.55" + } + }, + "node_modules/electron-builder": { + "version": "25.1.8", + "resolved": "https://registry.npmjs.org/electron-builder/-/electron-builder-25.1.8.tgz", + "integrity": "sha512-poRgAtUHHOnlzZnc9PK4nzG53xh74wj2Jy7jkTrqZ0MWPoHGh1M2+C//hGeYdA+4K8w4yiVCNYoLXF7ySj2Wig==", + "dev": true, + "license": "MIT", + "dependencies": { + "app-builder-lib": "25.1.8", + "builder-util": "25.1.7", + "builder-util-runtime": "9.2.10", + "chalk": "^4.1.2", + "dmg-builder": "25.1.8", + "fs-extra": "^10.1.0", + "is-ci": "^3.0.0", + "lazy-val": "^1.0.5", + "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": "25.1.8", + "resolved": "https://registry.npmjs.org/electron-builder-squirrel-windows/-/electron-builder-squirrel-windows-25.1.8.tgz", + "integrity": "sha512-2ntkJ+9+0GFP6nAISiMabKt6eqBB0kX1QqHNWFWAXgi0VULKGisM46luRFpIBiU3u/TDmhZMM8tzvo2Abn3ayg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "app-builder-lib": "25.1.8", + "archiver": "^5.3.1", + "builder-util": "25.1.7", + "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": "25.1.7", + "resolved": "https://registry.npmjs.org/electron-publish/-/electron-publish-25.1.7.tgz", + "integrity": "sha512-+jbTkR9m39eDBMP4gfbqglDd6UvBC7RLh5Y0MhFSsc6UkGHj9Vj9TWobxevHYMMqmoujL11ZLjfPpMX+Pt6YEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/fs-extra": "^9.0.11", + "builder-util": "25.1.7", + "builder-util-runtime": "9.2.10", + "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-to-chromium": { + "version": "1.5.302", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.302.tgz", + "integrity": "sha512-sM6HAN2LyK82IyPBpznDRqlTQAtuSaO+ShzFiWTvoMJLHyZ+Y39r8VMfHzwbU8MVBzQ4Wdn85+wlZl2TLGIlwg==", + "dev": true, + "license": "ISC" + }, + "node_modules/electron/node_modules/@types/node": { + "version": "20.19.35", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.35.tgz", + "integrity": "sha512-Uarfe6J91b9HAUXxjvSOdiO2UPOKLm07Q1oh0JHxoZ1y8HoqxDAu3gVrsrOHeiio0kSsoVBt4wFrKOm0dKxVPQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/electron/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/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/encoding": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/encoding/-/encoding-0.1.13.tgz", + "integrity": "sha512-ETBauow1T35Y/WZMkio9jiM0Z5xjHHmJ4XmjZOq1l/dXz3lr2sRn87nJy20RupqSh1F2m3HHPSp8ShIPQJrJ3A==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "iconv-lite": "^0.6.2" + } + }, + "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==", + "dev": true, + "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==", + "dev": true, + "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==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-module-lexer": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "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==", + "dev": true, + "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==", + "dev": true, + "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/esbuild": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.3.tgz", + "integrity": "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.3", + "@esbuild/android-arm": "0.27.3", + "@esbuild/android-arm64": "0.27.3", + "@esbuild/android-x64": "0.27.3", + "@esbuild/darwin-arm64": "0.27.3", + "@esbuild/darwin-x64": "0.27.3", + "@esbuild/freebsd-arm64": "0.27.3", + "@esbuild/freebsd-x64": "0.27.3", + "@esbuild/linux-arm": "0.27.3", + "@esbuild/linux-arm64": "0.27.3", + "@esbuild/linux-ia32": "0.27.3", + "@esbuild/linux-loong64": "0.27.3", + "@esbuild/linux-mips64el": "0.27.3", + "@esbuild/linux-ppc64": "0.27.3", + "@esbuild/linux-riscv64": "0.27.3", + "@esbuild/linux-s390x": "0.27.3", + "@esbuild/linux-x64": "0.27.3", + "@esbuild/netbsd-arm64": "0.27.3", + "@esbuild/netbsd-x64": "0.27.3", + "@esbuild/openbsd-arm64": "0.27.3", + "@esbuild/openbsd-x64": "0.27.3", + "@esbuild/openharmony-arm64": "0.27.3", + "@esbuild/sunos-x64": "0.27.3", + "@esbuild/win32-arm64": "0.27.3", + "@esbuild/win32-ia32": "0.27.3", + "@esbuild/win32-x64": "0.27.3" + } + }, + "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", + "optional": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/expect-type": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/exponential-backoff": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/exponential-backoff/-/exponential-backoff-3.1.3.tgz", + "integrity": "sha512-ZgEeZXj30q+I0EN+CbSSpIyPaJ5HVQD18Z1m+u1FXbAeT94mr1zw50q4q6jiiC447Nl/YTcIYSAftiGqetwXCA==", + "dev": true, + "license": "Apache-2.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/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/filelist": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/filelist/-/filelist-1.0.6.tgz", + "integrity": "sha512-5giy2PkLYY1cP39p17Ech+2xlpTRL9HLspOfEgm0L6CwBXBTgsK5ou0JtzYuepxkaQ/tvhCFIJ5uXo0OrM2DxA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "minimatch": "^5.0.1" + } + }, + "node_modules/filelist/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/filelist/node_modules/brace-expansion": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/filelist/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/fix-dts-default-cjs-exports": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/fix-dts-default-cjs-exports/-/fix-dts-default-cjs-exports-1.0.1.tgz", + "integrity": "sha512-pVIECanWFC61Hzl2+oOCtoJ3F17kglZC/6N94eRWycFgBH35hHx0Li604ZIzhseh97mf2p0cv7vVrOZGoqhlEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "magic-string": "^0.30.17", + "mlly": "^1.7.4", + "rollup": "^4.34.8" + } + }, + "node_modules/follow-redirects": { + "version": "1.15.11", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz", + "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==", + "dev": true, + "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/foreground-child/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/form-data": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", + "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "dev": true, + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, + "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==", + "dev": true, + "license": "MIT", + "peer": true + }, + "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.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.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/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gauge": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/gauge/-/gauge-4.0.4.tgz", + "integrity": "sha512-f9m+BEN5jkg6a0fZjleidjN51VE1X+mPFQ2DJ0uv1V39oCLCbsGe6yjbBnp7eK7z/+GAon99a3nHuqbuuthyPg==", + "deprecated": "This package is no longer supported.", + "dev": true, + "license": "ISC", + "dependencies": { + "aproba": "^1.0.3 || ^2.0.0", + "color-support": "^1.1.3", + "console-control-strings": "^1.1.0", + "has-unicode": "^2.0.1", + "signal-exit": "^3.0.7", + "string-width": "^4.2.3", + "strip-ansi": "^6.0.1", + "wide-align": "^1.1.5" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || >=16.0.0" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "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==", + "dev": true, + "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==", + "dev": true, + "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/get-tsconfig": { + "version": "4.13.6", + "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.6.tgz", + "integrity": "sha512-shZT/QMiSHc/YBLxxOkMtgSid5HFoauqCE3/exfsEcwg1WkeqjG+V40yBbBrsD+jW2HDXcs28xOfcbm2jI8Ddw==", + "dev": true, + "license": "MIT", + "dependencies": { + "resolve-pkg-maps": "^1.0.0" + }, + "funding": { + "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" + } + }, + "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/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/glob/node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "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.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "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==", + "dev": true, + "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==", + "dev": true, + "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==", + "dev": true, + "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==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-unicode": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/has-unicode/-/has-unicode-2.0.1.tgz", + "integrity": "sha512-8Rf9Y83NBReMnx0gFzA8JImQACstCYWUplepDa9xprwwtmgEZUF0h/i5xSA625zB/I37EtrswSST6OXxwaaIJQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "dev": true, + "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/hosted-git-info/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/hosted-git-info/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/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": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "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": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/humanize-ms": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/humanize-ms/-/humanize-ms-1.2.1.tgz", + "integrity": "sha512-Fl70vYtsAFb/C06PTS9dZBo7ihau+Tu/DNCk/OyHhea07S+aeMWpFFkUaXRa8fI+ScZbEI8dfSxwY7gxZ9SAVQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.0.0" + } + }, + "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==", + "dev": true, + "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/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/indent-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", + "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/infer-owner": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/infer-owner/-/infer-owner-1.0.4.tgz", + "integrity": "sha512-IClj+Xz94+d7irH5qRyfJonOdfTzuDaifE6ZPWfx0N0+/ATZCbuTPq2prFl526urkQd90WyUKIh1DfBQ2hMz9A==", + "dev": true, + "license": "ISC" + }, + "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==", + "dev": true, + "license": "ISC" + }, + "node_modules/ip-address": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.1.0.tgz", + "integrity": "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, + "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-docker": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-2.2.1.tgz", + "integrity": "sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ==", + "dev": true, + "license": "MIT", + "bin": { + "is-docker": "cli.js" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "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-interactive": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-interactive/-/is-interactive-1.0.0.tgz", + "integrity": "sha512-2HvIEKRoqS62guEC+qBjpvRubdX910WCMuJTZ+I9yvqKU2/12eSL549HMwtabb4oupdj2sMP50k+XJfB/8JE6w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-lambda": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-lambda/-/is-lambda-1.0.1.tgz", + "integrity": "sha512-z7CMFGNrENq5iFB9Bqo64Xk6Y9sg+epq1myIcdHaGnbMTYOxvzsEtdYqQUylB7LxfkvgrrjP32T6Ywciio9UIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/is-unicode-supported": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-0.1.0.tgz", + "integrity": "sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-wsl": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-2.2.0.tgz", + "integrity": "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-docker": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "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/joi": { + "version": "18.0.2", + "resolved": "https://registry.npmjs.org/joi/-/joi-18.0.2.tgz", + "integrity": "sha512-RuCOQMIt78LWnktPoeBL0GErkNaJPTBGcYuyaBvUOQSpcpcLfWrHPPihYdOGbV5pam9VTWbeoF7TsGiHugcjGA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@hapi/address": "^5.1.1", + "@hapi/formula": "^3.0.2", + "@hapi/hoek": "^11.0.7", + "@hapi/pinpoint": "^2.0.1", + "@hapi/tlds": "^1.1.1", + "@hapi/topo": "^6.0.2", + "@standard-schema/spec": "^1.0.0" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/joycon": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/joycon/-/joycon-3.1.1.tgz", + "integrity": "sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "license": "MIT" + }, + "node_modules/js-yaml": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", + "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "dev": true, + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "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-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==", + "dev": true, + "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/lilconfig": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", + "integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antonk52" + } + }, + "node_modules/lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", + "dev": true, + "license": "MIT" + }, + "node_modules/load-tsconfig": { + "version": "0.2.5", + "resolved": "https://registry.npmjs.org/load-tsconfig/-/load-tsconfig-0.2.5.tgz", + "integrity": "sha512-IXO6OCs9yg8tMKzfPZ1YmheJbZCiEsnBdcB03l0OcfK9prKnJb96siuHCr5Fl37/yo9DnKU+TLpxzTUspw9shg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + } + }, + "node_modules/lodash": { + "version": "4.17.23", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz", + "integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==", + "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.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.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/log-symbols": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-4.1.0.tgz", + "integrity": "sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "^4.1.0", + "is-unicode-supported": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/loupe": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", + "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", + "dev": true, + "license": "MIT" + }, + "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": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "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/make-fetch-happen": { + "version": "10.2.1", + "resolved": "https://registry.npmjs.org/make-fetch-happen/-/make-fetch-happen-10.2.1.tgz", + "integrity": "sha512-NgOPbRiaQM10DYXvN3/hhGVI2M5MtITFryzBGxHM5p4wnFxsVCbxkrBrDsk+EZ5OB4jEOT7AjDxtdF+KVEFT7w==", + "dev": true, + "license": "ISC", + "dependencies": { + "agentkeepalive": "^4.2.1", + "cacache": "^16.1.0", + "http-cache-semantics": "^4.1.0", + "http-proxy-agent": "^5.0.0", + "https-proxy-agent": "^5.0.0", + "is-lambda": "^1.0.1", + "lru-cache": "^7.7.1", + "minipass": "^3.1.6", + "minipass-collect": "^1.0.2", + "minipass-fetch": "^2.0.3", + "minipass-flush": "^1.0.5", + "minipass-pipeline": "^1.2.4", + "negotiator": "^0.6.3", + "promise-retry": "^2.0.1", + "socks-proxy-agent": "^7.0.0", + "ssri": "^9.0.0" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || >=16.0.0" + } + }, + "node_modules/make-fetch-happen/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==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "4" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/make-fetch-happen/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/make-fetch-happen/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==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/make-fetch-happen/node_modules/lru-cache": { + "version": "7.18.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-7.18.3.tgz", + "integrity": "sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "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==", + "dev": true, + "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==", + "dev": true, + "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==", + "dev": true, + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mimic-fn": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", + "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=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": "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/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "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/minipass-collect": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/minipass-collect/-/minipass-collect-1.0.2.tgz", + "integrity": "sha512-6T6lH0H8OG9kITm/Jm6tdooIbogG9e0tLgpY6mphXSm/A9u8Nq1ryBG+Qspiub9LjWlBPsPS3tWQ/Botq4FdxA==", + "dev": true, + "license": "ISC", + "dependencies": { + "minipass": "^3.0.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/minipass-fetch": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/minipass-fetch/-/minipass-fetch-2.1.2.tgz", + "integrity": "sha512-LT49Zi2/WMROHYoqGgdlQIZh8mLPZmOrN2NdJjMXxYe4nkN6FUyuPuOAOedNJDrx0IRGg9+4guZewtp8hE6TxA==", + "dev": true, + "license": "MIT", + "dependencies": { + "minipass": "^3.1.6", + "minipass-sized": "^1.0.3", + "minizlib": "^2.1.2" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || >=16.0.0" + }, + "optionalDependencies": { + "encoding": "^0.1.13" + } + }, + "node_modules/minipass-flush": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/minipass-flush/-/minipass-flush-1.0.5.tgz", + "integrity": "sha512-JmQSYYpPUqX5Jyn1mXaRwOda1uQ8HP5KAT/oDSLCzt1BYRhQU0/hDtsB1ufZfEEzMZ9aAVmsBw8+FWsIXlClWw==", + "dev": true, + "license": "ISC", + "dependencies": { + "minipass": "^3.0.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/minipass-pipeline": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/minipass-pipeline/-/minipass-pipeline-1.2.4.tgz", + "integrity": "sha512-xuIq7cIOt09RPRJ19gdi4b+RiNvDFYe5JH+ggNvBqGqpQXcru3PcRmOZuHBKWK1Txf9+cQ+HMVN4d6z46LZP7A==", + "dev": true, + "license": "ISC", + "dependencies": { + "minipass": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/minipass-sized": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/minipass-sized/-/minipass-sized-1.0.3.tgz", + "integrity": "sha512-MbkQQ2CTiBMlA2Dm/5cY+9SWFEN8pzzOXi6rlM5Xxq0Yqbda5ZQy9sU75a673FE9ZK0Zsbr6Y5iP6u9nktfg2g==", + "dev": true, + "license": "ISC", + "dependencies": { + "minipass": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/minipass/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/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/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/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/mlly": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/mlly/-/mlly-1.8.0.tgz", + "integrity": "sha512-l8D9ODSRWLe2KHJSifWGwBqpTZXIXTeo8mlKjY+E2HAakaTeNpqAyBZ8GSqLzHgw4XmHmC8whvpjJNMbFZN7/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "acorn": "^8.15.0", + "pathe": "^2.0.3", + "pkg-types": "^1.3.1", + "ufo": "^1.6.1" + } + }, + "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==", + "dev": true, + "license": "MIT" + }, + "node_modules/mz": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz", + "integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0", + "object-assign": "^4.0.1", + "thenify-all": "^1.0.0" + } + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "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/negotiator": { + "version": "0.6.4", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.4.tgz", + "integrity": "sha512-myRT3DiWPHqho5PrJaIRyaMv2kgYf0mUVgBNOYMuCH5Ki1yEiQaf/ZJuQ62nvpc44wL5WDbTX7yGJi1Neevw8w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/node-abi": { + "version": "3.87.0", + "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.87.0.tgz", + "integrity": "sha512-+CGM1L1CgmtheLcBuleyYOn7NWPVu0s0EJH2C4puxgEZb9h8QpR9G2dBfZJOAUhi7VQxuBPMd0hiISWcTyiYyQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.3.5" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/node-abi/node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "dev": true, + "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/node-api-version": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/node-api-version/-/node-api-version-0.2.1.tgz", + "integrity": "sha512-2xP/IGGMmmSQpI1+O/k72jF/ykvZ89JeuKX3TLJAYPDVLUalrshrLHkeVcCCZqG/eEa635cr8IBYzgnDvM2O8Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.3.5" + } + }, + "node_modules/node-api-version/node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/node-gyp": { + "version": "9.4.1", + "resolved": "https://registry.npmjs.org/node-gyp/-/node-gyp-9.4.1.tgz", + "integrity": "sha512-OQkWKbjQKbGkMf/xqI1jjy3oCTgMKJac58G2+bjZb3fza6gW2YrCSdMQYaoTb70crvE//Gngr4f0AgVHmqHvBQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "env-paths": "^2.2.0", + "exponential-backoff": "^3.1.1", + "glob": "^7.1.4", + "graceful-fs": "^4.2.6", + "make-fetch-happen": "^10.0.3", + "nopt": "^6.0.0", + "npmlog": "^6.0.0", + "rimraf": "^3.0.2", + "semver": "^7.3.5", + "tar": "^6.1.2", + "which": "^2.0.2" + }, + "bin": { + "node-gyp": "bin/node-gyp.js" + }, + "engines": { + "node": "^12.13 || ^14.13 || >=16" + } + }, + "node_modules/node-gyp/node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/node-releases": { + "version": "2.0.27", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz", + "integrity": "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nopt": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/nopt/-/nopt-6.0.0.tgz", + "integrity": "sha512-ZwLpbTgdhuZUnZzjd7nb1ZV+4DoiC6/sfiVKok72ym/4Tlf+DFdlHYmT2JPmcNNWV6Pi3SDf1kT+A4r9RTuT9g==", + "dev": true, + "license": "ISC", + "dependencies": { + "abbrev": "^1.0.0" + }, + "bin": { + "nopt": "bin/nopt.js" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || >=16.0.0" + } + }, + "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/npmlog": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/npmlog/-/npmlog-6.0.2.tgz", + "integrity": "sha512-/vBvz5Jfr9dT/aFWd0FIRf+T/Q2WBsLENygUaFUqstqsycmZAP/t5BvFJTK0viFmSUxiUKTUplWy5vt+rvKIxg==", + "deprecated": "This package is no longer supported.", + "dev": true, + "license": "ISC", + "dependencies": { + "are-we-there-yet": "^3.0.0", + "console-control-strings": "^1.1.0", + "gauge": "^4.0.3", + "set-blocking": "^2.0.0" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || >=16.0.0" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "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/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "dev": true, + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/onetime": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz", + "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "mimic-fn": "^2.1.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ora": { + "version": "5.4.1", + "resolved": "https://registry.npmjs.org/ora/-/ora-5.4.1.tgz", + "integrity": "sha512-5b6Y85tPxZZ7QytO+BQzysW31HJku27cRIlkbAXaNx+BdcVi+LlRFmVXzeF6a7JCwJpyw5c4b+YSVImQIrBpuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "bl": "^4.1.0", + "chalk": "^4.1.0", + "cli-cursor": "^3.1.0", + "cli-spinners": "^2.5.0", + "is-interactive": "^1.0.0", + "is-unicode-supported": "^0.1.0", + "log-symbols": "^4.1.0", + "strip-ansi": "^6.0.0", + "wcwidth": "^1.0.1" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "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-map": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/p-map/-/p-map-4.0.0.tgz", + "integrity": "sha512-/bjOqmgETBYB5BoEeGVea8dmvHb2m9GLy1E9W43yeyfP6QQCZGFNa+XRceJEuDB6zqr+gKpIAmlLebMpykw/MQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "aggregate-error": "^3.0.0" + }, + "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-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/path-scurry/node_modules/minipass": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "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/pathval": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz", + "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.16" + } + }, + "node_modules/pe-library": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/pe-library/-/pe-library-0.4.1.tgz", + "integrity": "sha512-eRWB5LBz7PpDu4PUlwT0PhnQfTQJlDDdPa35urV4Osrm0t0AqQFGn+UIkU3klZvwJ8KPO3VbBFsXquA6p6kqZw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12", + "npm": ">=6" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/jet2jet" + } + }, + "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.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pirates": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz", + "integrity": "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/pkg-types": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-1.3.1.tgz", + "integrity": "sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "confbox": "^0.1.8", + "mlly": "^1.7.4", + "pathe": "^2.0.1" + } + }, + "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.6", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", + "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", + "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.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/postcss-load-config": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-6.0.1.tgz", + "integrity": "sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "lilconfig": "^3.1.1" + }, + "engines": { + "node": ">= 18" + }, + "peerDependencies": { + "jiti": ">=1.21.0", + "postcss": ">=8.0.9", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + }, + "postcss": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "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-inflight": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/promise-inflight/-/promise-inflight-1.0.1.tgz", + "integrity": "sha512-6zWPyEOFaQBJYcGMHBKTKJ3u6TBsnMFOIZSa6ce1e/ZrrsOlnHRHbabMjLiBYKp+n44X9eUI6VUPaukCXHuG4g==", + "dev": true, + "license": "ISC" + }, + "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": "1.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", + "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", + "dev": true, + "license": "MIT" + }, + "node_modules/pump": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.3.tgz", + "integrity": "sha512-todwxLMY7/heScKmntwQG8CXVkWUOdYxIvY2s0VWAAMh/nd8SoYiRaKjlr7+iCs984f2P8zvrfWcDDYVb73NfA==", + "dev": true, + "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/rcedit": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/rcedit/-/rcedit-5.0.2.tgz", + "integrity": "sha512-dgysxaeXZ4snLpPjn8aVtHvZDCx+aRcvZbaWBgl1poU6OPustMvOkj9a9ZqASQ6i5Y5szJ13LSvglEOwrmgUxA==", + "dev": true, + "license": "MIT", + "dependencies": { + "cross-spawn-windows-exe": "^1.1.0" + }, + "engines": { + "node": ">= 22.12.0" + } + }, + "node_modules/react": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", + "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", + "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0", + "scheduler": "^0.23.2" + }, + "peerDependencies": { + "react": "^18.3.1" + } + }, + "node_modules/react-refresh": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", + "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/read-binary-file-arch": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/read-binary-file-arch/-/read-binary-file-arch-1.0.6.tgz", + "integrity": "sha512-BNg9EN3DD3GsDXX7Aa8O4p92sryjkmzYYgmgTAc6CA4uGLEDzFfxOxugu21akOxpcXHiEgsYkC6nPsQvLLLmEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.3.4" + }, + "bin": { + "read-binary-file-arch": "cli.js" + } + }, + "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==", + "dev": true, + "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/readdir-glob/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", + "peer": true + }, + "node_modules/readdir-glob/node_modules/brace-expansion": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/readdir-glob/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", + "peer": true, + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/readdirp": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", + "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.18.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, + "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/resedit": { + "version": "1.7.2", + "resolved": "https://registry.npmjs.org/resedit/-/resedit-1.7.2.tgz", + "integrity": "sha512-vHjcY2MlAITJhC0eRD/Vv8Vlgmu9Sd3LX9zZvtGzU5ZImdTN3+d6e/4mnTyV8vEbyf1sgNIrWxhWlrys52OkEA==", + "dev": true, + "license": "MIT", + "dependencies": { + "pe-library": "^0.4.1" + }, + "engines": { + "node": ">=12", + "npm": ">=6" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/jet2jet" + } + }, + "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/resolve-from": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", + "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/resolve-pkg-maps": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", + "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" + } + }, + "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/restore-cursor": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-3.1.0.tgz", + "integrity": "sha512-l+sSefzHpj5qimhFSE5a8nufZYAM3sBSVMAPtYkmC+4EH2anSGaEMXSD0izRQbu9nfyQ9y5JrVmp7E8oZrUjvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "onetime": "^5.1.0", + "signal-exit": "^3.0.2" + }, + "engines": { + "node": ">=8" + } + }, + "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/rimraf": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", + "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", + "deprecated": "Rimraf versions prior to v4 are no longer supported", + "dev": true, + "license": "ISC", + "dependencies": { + "glob": "^7.1.3" + }, + "bin": { + "rimraf": "bin.js" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "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/rollup": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz", + "integrity": "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.59.0", + "@rollup/rollup-android-arm64": "4.59.0", + "@rollup/rollup-darwin-arm64": "4.59.0", + "@rollup/rollup-darwin-x64": "4.59.0", + "@rollup/rollup-freebsd-arm64": "4.59.0", + "@rollup/rollup-freebsd-x64": "4.59.0", + "@rollup/rollup-linux-arm-gnueabihf": "4.59.0", + "@rollup/rollup-linux-arm-musleabihf": "4.59.0", + "@rollup/rollup-linux-arm64-gnu": "4.59.0", + "@rollup/rollup-linux-arm64-musl": "4.59.0", + "@rollup/rollup-linux-loong64-gnu": "4.59.0", + "@rollup/rollup-linux-loong64-musl": "4.59.0", + "@rollup/rollup-linux-ppc64-gnu": "4.59.0", + "@rollup/rollup-linux-ppc64-musl": "4.59.0", + "@rollup/rollup-linux-riscv64-gnu": "4.59.0", + "@rollup/rollup-linux-riscv64-musl": "4.59.0", + "@rollup/rollup-linux-s390x-gnu": "4.59.0", + "@rollup/rollup-linux-x64-gnu": "4.59.0", + "@rollup/rollup-linux-x64-musl": "4.59.0", + "@rollup/rollup-openbsd-x64": "4.59.0", + "@rollup/rollup-openharmony-arm64": "4.59.0", + "@rollup/rollup-win32-arm64-msvc": "4.59.0", + "@rollup/rollup-win32-ia32-msvc": "4.59.0", + "@rollup/rollup-win32-x64-gnu": "4.59.0", + "@rollup/rollup-win32-x64-msvc": "4.59.0", + "fsevents": "~2.3.2" + } + }, + "node_modules/rxjs": { + "version": "7.8.2", + "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz", + "integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.1.0" + } + }, + "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==", + "dev": true, + "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/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==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=11.0.0" + } + }, + "node_modules/scheduler": { + "version": "0.23.2", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", + "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.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/set-blocking": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz", + "integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==", + "dev": true, + "license": "ISC" + }, + "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/shell-quote": { + "version": "1.8.3", + "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.3.tgz", + "integrity": "sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "node_modules/signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "dev": true, + "license": "ISC" + }, + "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.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "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", + "engines": { + "node": ">= 6.0.0", + "npm": ">= 3.0.0" + } + }, + "node_modules/socks": { + "version": "2.8.7", + "resolved": "https://registry.npmjs.org/socks/-/socks-2.8.7.tgz", + "integrity": "sha512-HLpt+uLy/pxB+bum/9DzAgiKS8CX1EvbWxI4zlmgGCExImLdiad2iCwXT5Z4c9c3Eq8rP2318mPW2c+QbtjK8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ip-address": "^10.0.1", + "smart-buffer": "^4.2.0" + }, + "engines": { + "node": ">= 10.0.0", + "npm": ">= 3.0.0" + } + }, + "node_modules/socks-proxy-agent": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/socks-proxy-agent/-/socks-proxy-agent-7.0.0.tgz", + "integrity": "sha512-Fgl0YPZ902wEsAyiQ+idGd1A7rSFx/ayC1CQVMw5P+EQx2V0SgpGtf6OKFhVjPflPUl9YMmEOnmfjCdMUsygww==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^6.0.2", + "debug": "^4.3.3", + "socks": "^2.6.2" + }, + "engines": { + "node": ">= 10" + } + }, + "node_modules/socks-proxy-agent/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==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "4" + }, + "engines": { + "node": ">= 6.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/ssri": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/ssri/-/ssri-9.0.1.tgz", + "integrity": "sha512-o57Wcn66jMQvfHG1FlYbWeZWW/dHZhJXjpIcTfXldXEk5nz5lStPo3mK0OJQfGR3RbZUlbISexbljkJzuEj/8Q==", + "dev": true, + "license": "ISC", + "dependencies": { + "minipass": "^3.1.1" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || >=16.0.0" + } + }, + "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": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "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==", + "dev": true, + "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/sucrase": { + "version": "3.35.1", + "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz", + "integrity": "sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.2", + "commander": "^4.0.0", + "lines-and-columns": "^1.1.6", + "mz": "^2.7.0", + "pirates": "^4.0.1", + "tinyglobby": "^0.2.11", + "ts-interface-checker": "^0.1.9" + }, + "bin": { + "sucrase": "bin/sucrase", + "sucrase-node": "bin/sucrase-node" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/sucrase/node_modules/commander": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", + "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "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": "8.1.1", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", + "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, + "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-stream": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz", + "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==", + "dev": true, + "license": "MIT", + "peer": true, + "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/tar/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/tar/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/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/thenify": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", + "integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0" + } + }, + "node_modules/thenify-all": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/thenify-all/-/thenify-all-1.6.0.tgz", + "integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "thenify": ">= 3.1.0 < 4" + }, + "engines": { + "node": ">=0.8" + } + }, + "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": "0.3.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", + "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyglobby": { + "version": "0.2.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinypool": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz", + "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + } + }, + "node_modules/tinyrainbow": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-1.2.0.tgz", + "integrity": "sha512-weEDEq7Z5eTHPDh4xjX789+fHfF+P8boiFB+0vbWzpbnbsEr/GRaohi/uMKxg8RZMXnl1ItAi/IUHWMsjDV7kQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tinyspy": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-3.0.2.tgz", + "integrity": "sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tmp": { + "version": "0.2.5", + "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.5.tgz", + "integrity": "sha512-voyz6MApa1rQGUxT3E+BK7/ROe8itEx7vD8/HEvt4xwXucvQ5G5oeEiHkmHZJuBO21RpOf+YYm9MOivj709jow==", + "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/tree-kill": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz", + "integrity": "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==", + "dev": true, + "license": "MIT", + "bin": { + "tree-kill": "cli.js" + } + }, + "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-interface-checker": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", + "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD" + }, + "node_modules/tsup": { + "version": "8.5.1", + "resolved": "https://registry.npmjs.org/tsup/-/tsup-8.5.1.tgz", + "integrity": "sha512-xtgkqwdhpKWr3tKPmCkvYmS9xnQK3m3XgxZHwSUjvfTjp7YfXe5tT3GgWi0F2N+ZSMsOeWeZFh7ZZFg5iPhing==", + "dev": true, + "license": "MIT", + "dependencies": { + "bundle-require": "^5.1.0", + "cac": "^6.7.14", + "chokidar": "^4.0.3", + "consola": "^3.4.0", + "debug": "^4.4.0", + "esbuild": "^0.27.0", + "fix-dts-default-cjs-exports": "^1.0.0", + "joycon": "^3.1.1", + "picocolors": "^1.1.1", + "postcss-load-config": "^6.0.1", + "resolve-from": "^5.0.0", + "rollup": "^4.34.8", + "source-map": "^0.7.6", + "sucrase": "^3.35.0", + "tinyexec": "^0.3.2", + "tinyglobby": "^0.2.11", + "tree-kill": "^1.2.2" + }, + "bin": { + "tsup": "dist/cli-default.js", + "tsup-node": "dist/cli-node.js" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@microsoft/api-extractor": "^7.36.0", + "@swc/core": "^1", + "postcss": "^8.4.12", + "typescript": ">=4.5.0" + }, + "peerDependenciesMeta": { + "@microsoft/api-extractor": { + "optional": true + }, + "@swc/core": { + "optional": true + }, + "postcss": { + "optional": true + }, + "typescript": { + "optional": true + } + } + }, + "node_modules/tsup/node_modules/source-map": { + "version": "0.7.6", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.6.tgz", + "integrity": "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">= 12" + } + }, + "node_modules/tsx": { + "version": "4.21.0", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz", + "integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.27.0", + "get-tsconfig": "^4.7.5" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, + "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/ufo": { + "version": "1.6.3", + "resolved": "https://registry.npmjs.org/ufo/-/ufo-1.6.3.tgz", + "integrity": "sha512-yDJTmhydvl5lJzBmy/hyOAA0d+aqCBuwl818haVdYCRrWV84o7YyeVm4QlVHStqNrrJSTb6jKuFAVqAFsr+K3Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/undici-types": { + "version": "7.16.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", + "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", + "dev": true, + "license": "MIT" + }, + "node_modules/unique-filename": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/unique-filename/-/unique-filename-2.0.1.tgz", + "integrity": "sha512-ODWHtkkdx3IAR+veKxFV+VBkUMcN+FaqzUUd7IZzt+0zhDZFPFxhlqwPF3YQvMHx1TD0tdgYl+kuPnJ8E6ql7A==", + "dev": true, + "license": "ISC", + "dependencies": { + "unique-slug": "^3.0.0" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || >=16.0.0" + } + }, + "node_modules/unique-slug": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/unique-slug/-/unique-slug-3.0.0.tgz", + "integrity": "sha512-8EyMynh679x/0gqE9fT9oilG+qEt+ibFyqjuVTsZn1+CMxH+XLlpvr2UZx4nVcCwTpx81nICr2JQFkM+HPLq4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "imurmurhash": "^0.1.4" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || >=16.0.0" + } + }, + "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/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.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==", + "dev": true, + "license": "MIT" + }, + "node_modules/uuid": { + "version": "11.1.1", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.1.tgz", + "integrity": "sha512-vIYxrBCC/N/K+Js3qSN88go7kIfNPssr/hHCesKCQNAjmgvYS2oqr69kIufEG+O4+PfezOH4EbIeHCfFov8ZgQ==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist/esm/bin/uuid" + } + }, + "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": "6.4.1", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.1.tgz", + "integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.25.0", + "fdir": "^6.4.4", + "picomatch": "^4.0.2", + "postcss": "^8.5.3", + "rollup": "^4.34.9", + "tinyglobby": "^0.2.13" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "jiti": ">=1.21.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "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": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-2.1.9.tgz", + "integrity": "sha512-AM9aQ/IPrW/6ENLQg3AGY4K1N2TGZdR5e4gu/MmmR2xR3Ll1+dib+nook92g4TV3PXVyeyxdWwtaCAiUL0hMxA==", + "dev": true, + "license": "MIT", + "dependencies": { + "cac": "^6.7.14", + "debug": "^4.3.7", + "es-module-lexer": "^1.5.4", + "pathe": "^1.1.2", + "vite": "^5.0.0" + }, + "bin": { + "vite-node": "vite-node.mjs" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/vite-node/node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "node_modules/vite-node/node_modules/pathe": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz", + "integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/vite-node/node_modules/vite": { + "version": "5.4.21", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "node_modules/vite/node_modules/@esbuild/aix-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/android-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/android-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/android-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/darwin-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/darwin-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/freebsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-loong64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-mips64el": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-riscv64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-s390x": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/netbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/openbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/sunos-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/win32-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/win32-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/win32-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/esbuild": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" + } + }, + "node_modules/vitest": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-2.1.9.tgz", + "integrity": "sha512-MSmPM9REYqDGBI8439mA4mWhV5sKmDlBKWIYbA3lRb2PTHACE0mgKwA8yQ2xq9vxDTuk4iPrECBAEW2aoFXY0Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "2.1.9", + "@vitest/mocker": "2.1.9", + "@vitest/pretty-format": "^2.1.9", + "@vitest/runner": "2.1.9", + "@vitest/snapshot": "2.1.9", + "@vitest/spy": "2.1.9", + "@vitest/utils": "2.1.9", + "chai": "^5.1.2", + "debug": "^4.3.7", + "expect-type": "^1.1.0", + "magic-string": "^0.30.12", + "pathe": "^1.1.2", + "std-env": "^3.8.0", + "tinybench": "^2.9.0", + "tinyexec": "^0.3.1", + "tinypool": "^1.0.1", + "tinyrainbow": "^1.2.0", + "vite": "^5.0.0", + "vite-node": "2.1.9", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@types/node": "^18.0.0 || >=20.0.0", + "@vitest/browser": "2.1.9", + "@vitest/ui": "2.1.9", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, + "node_modules/vitest/node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@vitest/mocker": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-2.1.9.tgz", + "integrity": "sha512-tVL6uJgoUdi6icpxmdrn5YNo3g3Dxv+IHJBr0GXHaEdTcw3F+cPKnsXFhli6nO+f/6SDKPHEK1UN+k+TQv0Ehg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "2.1.9", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.12" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^5.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/vitest/node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "node_modules/vitest/node_modules/pathe": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz", + "integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/vitest/node_modules/vite": { + "version": "5.4.21", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "node_modules/wait-on": { + "version": "8.0.5", + "resolved": "https://registry.npmjs.org/wait-on/-/wait-on-8.0.5.tgz", + "integrity": "sha512-J3WlS0txVHkhLRb2FsmRg3dkMTCV1+M6Xra3Ho7HzZDHpE7DCOnoSoCJsZotrmW3uRMhvIJGSKUKrh/MeF4iag==", + "dev": true, + "license": "MIT", + "dependencies": { + "axios": "^1.12.1", + "joi": "^18.0.1", + "lodash": "^4.17.21", + "minimist": "^1.2.8", + "rxjs": "^7.8.2" + }, + "bin": { + "wait-on": "bin/wait-on" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/wcwidth": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/wcwidth/-/wcwidth-1.0.1.tgz", + "integrity": "sha512-XHPEwS0q6TaxcvG85+8EYkbiCux2XtWG2mkc47Ng2A77BQu9+DqIOJldST4HgPkuea7dvKSj5VgX3P1d4rW8Tg==", + "dev": true, + "license": "MIT", + "dependencies": { + "defaults": "^1.0.3" + } + }, + "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/wide-align": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/wide-align/-/wide-align-1.1.5.tgz", + "integrity": "sha512-eDMORYaPNZ4sQIuuYPDHdQvf4gyCF9rEEV/yPxGfwPkRodwEgiMUUXTx/dex+Me0wxx53S+NgUHaP7y3MGlDmg==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^1.0.2 || 2 || 3 || 4" + } + }, + "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==", + "dev": true, + "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": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + }, + "node_modules/yaml": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz", + "integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==", + "dev": true, + "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" + } + }, + "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..9a35f26 --- /dev/null +++ b/package.json @@ -0,0 +1,96 @@ +{ + "name": "real-debrid-downloader", + "version": "1.7.233", + "description": "Desktop downloader", + "main": "build/main/main/main.js", + "author": "Sucukdeluxe", + "license": "MIT", + "scripts": { + "dev": "concurrently -k \"npm:dev:main:watch\" \"npm:dev:renderer\" \"npm:dev:electron\"", + "dev:renderer": "vite", + "dev:main:watch": "tsup src/main/main.ts src/preload/preload.ts --out-dir build/main --format cjs --target node20 --external electron --sourcemap --watch", + "dev:electron": "wait-on tcp:5173 file:build/main/main/main.js && cross-env NODE_ENV=development electron .", + "build": "npm run build:main && npm run build:renderer", + "build:main": "tsup src/main/main.ts src/preload/preload.ts --out-dir build/main --format cjs --target node20 --external electron --sourcemap", + "build:renderer": "vite build", + "start": "cross-env NODE_ENV=production electron .", + "test": "vitest run", + "self-check": "tsx tests/self-check.ts", + "release:win": "npm run build && electron-builder --publish never --win nsis portable", + "verify:release": "node scripts/verify_public_release.mjs" + }, + "dependencies": { + "adm-zip": "0.6.0", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "uuid": "11.1.1" + }, + "devDependencies": { + "@types/adm-zip": "^0.5.7", + "@types/node": "^24.0.13", + "@types/react": "^18.3.12", + "@types/react-dom": "^18.3.1", + "@types/uuid": "^10.0.0", + "@vitejs/plugin-react": "^4.3.4", + "concurrently": "^9.0.1", + "cross-env": "^7.0.3", + "electron": "^31.7.7", + "electron-builder": "^25.1.8", + "rcedit": "^5.0.2", + "tsup": "^8.3.6", + "tsx": "^4.19.2", + "typescript": "^5.7.3", + "vite": "^6.0.5", + "vitest": "^2.1.8", + "wait-on": "^8.0.1", + "yaml": "^2.9.0" + }, + "build": { + "appId": "com.sucukdeluxe.realdebrid", + "productName": "Real-Debrid-Downloader", + "directories": { + "buildResources": "assets", + "output": "release" + }, + "publish": { + "provider": "github", + "owner": "Sucukdeluxe", + "repo": "multi-debrid-downloader" + }, + "files": [ + "build/main/**/*", + "build/renderer/**/*", + "resources/extractor-jvm/**/*", + "LICENSE", + "package.json" + ], + "extraResources": [ + { + "from": "LICENSE", + "to": "LICENSE" + } + ], + "asarUnpack": [ + "resources/extractor-jvm/**/*" + ], + "win": { + "target": [ + "nsis", + "portable" + ], + "icon": "assets/app_icon.ico", + "signAndEditExecutable": false + }, + "nsis": { + "artifactName": "${productName}-Setup-${version}.${ext}", + "oneClick": false, + "perMachine": false, + "allowToChangeInstallationDirectory": true, + "createDesktopShortcut": true + }, + "portable": { + "artifactName": "${productName}-${version}-portable.${ext}" + }, + "afterPack": "scripts/afterPack.cjs" + } +} diff --git a/resources/extractor-jvm/README.md b/resources/extractor-jvm/README.md new file mode 100644 index 0000000..de86cd1 --- /dev/null +++ b/resources/extractor-jvm/README.md @@ -0,0 +1,22 @@ +# JVM extractor runtime + +This directory contains the Java sidecar runtime used by `src/main/extractor.ts`. + +## Included backends + +- `sevenzipjbinding` for the primary extraction path (RAR/7z/ZIP and others) +- `zip4j` for ZIP multipart handling (JD-style split ZIP behavior) + +## Layout + +- `classes/` compiled `JBindExtractorMain` classes +- `lib/` runtime jars required by the sidecar +- `src/` Java source for the sidecar + +## Rebuild notes + +The checked-in classes are Java 8 compatible and built from: + +`resources/extractor-jvm/src/com/sucukdeluxe/extractor/JBindExtractorMain.java` + +If you need to rebuild, compile against the jars in `lib/` with a Java 8-compatible compiler. diff --git a/resources/extractor-jvm/THIRD_PARTY_NOTICES.txt b/resources/extractor-jvm/THIRD_PARTY_NOTICES.txt new file mode 100644 index 0000000..ba5ce1b --- /dev/null +++ b/resources/extractor-jvm/THIRD_PARTY_NOTICES.txt @@ -0,0 +1,7 @@ +Bundled JVM extractor dependencies + +| Maven artifact | Version | License and restrictions | Included text | Upstream | +| --- | --- | --- | --- | --- | +| net.sf.sevenzipjbinding:sevenzipjbinding:16.02-2.01 | 16.02-2.01 | GNU Lesser General Public License 2.1 or later; bundled 7-Zip code includes the unRAR restriction | licenses/LGPL-2.1.txt; licenses/7-Zip-license.txt | https://sevenzipjbind.sourceforge.net/ | +| net.sf.sevenzipjbinding:sevenzipjbinding-all-platforms:16.02-2.01 | 16.02-2.01 | GNU Lesser General Public License 2.1 or later; bundled 7-Zip code includes the unRAR restriction | licenses/LGPL-2.1.txt; licenses/7-Zip-license.txt | https://sevenzipjbind.sourceforge.net/ | +| net.lingala.zip4j:zip4j:2.11.5 | 2.11.5 | Apache License 2.0 | licenses/Apache-2.0.txt | https://github.com/srikanth-lingala/zip4j | diff --git a/resources/extractor-jvm/classes/com/sucukdeluxe/extractor/JBindExtractorMain$1.class b/resources/extractor-jvm/classes/com/sucukdeluxe/extractor/JBindExtractorMain$1.class new file mode 100644 index 0000000..e3c3b5e Binary files /dev/null and b/resources/extractor-jvm/classes/com/sucukdeluxe/extractor/JBindExtractorMain$1.class differ diff --git a/resources/extractor-jvm/classes/com/sucukdeluxe/extractor/JBindExtractorMain$Backend.class b/resources/extractor-jvm/classes/com/sucukdeluxe/extractor/JBindExtractorMain$Backend.class new file mode 100644 index 0000000..fe9414c Binary files /dev/null and b/resources/extractor-jvm/classes/com/sucukdeluxe/extractor/JBindExtractorMain$Backend.class differ diff --git a/resources/extractor-jvm/classes/com/sucukdeluxe/extractor/JBindExtractorMain$BulkExtractCallback$1.class b/resources/extractor-jvm/classes/com/sucukdeluxe/extractor/JBindExtractorMain$BulkExtractCallback$1.class new file mode 100644 index 0000000..f4414fb Binary files /dev/null and b/resources/extractor-jvm/classes/com/sucukdeluxe/extractor/JBindExtractorMain$BulkExtractCallback$1.class differ diff --git a/resources/extractor-jvm/classes/com/sucukdeluxe/extractor/JBindExtractorMain$BulkExtractCallback.class b/resources/extractor-jvm/classes/com/sucukdeluxe/extractor/JBindExtractorMain$BulkExtractCallback.class new file mode 100644 index 0000000..9b29d83 Binary files /dev/null and b/resources/extractor-jvm/classes/com/sucukdeluxe/extractor/JBindExtractorMain$BulkExtractCallback.class differ diff --git a/resources/extractor-jvm/classes/com/sucukdeluxe/extractor/JBindExtractorMain$ConflictMode.class b/resources/extractor-jvm/classes/com/sucukdeluxe/extractor/JBindExtractorMain$ConflictMode.class new file mode 100644 index 0000000..80669bf Binary files /dev/null and b/resources/extractor-jvm/classes/com/sucukdeluxe/extractor/JBindExtractorMain$ConflictMode.class differ diff --git a/resources/extractor-jvm/classes/com/sucukdeluxe/extractor/JBindExtractorMain$ExtractionRequest.class b/resources/extractor-jvm/classes/com/sucukdeluxe/extractor/JBindExtractorMain$ExtractionRequest.class new file mode 100644 index 0000000..d06ab5e Binary files /dev/null and b/resources/extractor-jvm/classes/com/sucukdeluxe/extractor/JBindExtractorMain$ExtractionRequest.class differ diff --git a/resources/extractor-jvm/classes/com/sucukdeluxe/extractor/JBindExtractorMain$ProgressTracker.class b/resources/extractor-jvm/classes/com/sucukdeluxe/extractor/JBindExtractorMain$ProgressTracker.class new file mode 100644 index 0000000..471ebab Binary files /dev/null and b/resources/extractor-jvm/classes/com/sucukdeluxe/extractor/JBindExtractorMain$ProgressTracker.class differ diff --git a/resources/extractor-jvm/classes/com/sucukdeluxe/extractor/JBindExtractorMain$SevenZipArchiveContext.class b/resources/extractor-jvm/classes/com/sucukdeluxe/extractor/JBindExtractorMain$SevenZipArchiveContext.class new file mode 100644 index 0000000..507bea2 Binary files /dev/null and b/resources/extractor-jvm/classes/com/sucukdeluxe/extractor/JBindExtractorMain$SevenZipArchiveContext.class differ diff --git a/resources/extractor-jvm/classes/com/sucukdeluxe/extractor/JBindExtractorMain$SevenZipVolumeCallback.class b/resources/extractor-jvm/classes/com/sucukdeluxe/extractor/JBindExtractorMain$SevenZipVolumeCallback.class new file mode 100644 index 0000000..b282d77 Binary files /dev/null and b/resources/extractor-jvm/classes/com/sucukdeluxe/extractor/JBindExtractorMain$SevenZipVolumeCallback.class differ diff --git a/resources/extractor-jvm/classes/com/sucukdeluxe/extractor/JBindExtractorMain$WrongPasswordException.class b/resources/extractor-jvm/classes/com/sucukdeluxe/extractor/JBindExtractorMain$WrongPasswordException.class new file mode 100644 index 0000000..12a9fe1 Binary files /dev/null and b/resources/extractor-jvm/classes/com/sucukdeluxe/extractor/JBindExtractorMain$WrongPasswordException.class differ diff --git a/resources/extractor-jvm/classes/com/sucukdeluxe/extractor/JBindExtractorMain.class b/resources/extractor-jvm/classes/com/sucukdeluxe/extractor/JBindExtractorMain.class new file mode 100644 index 0000000..63dbc49 Binary files /dev/null and b/resources/extractor-jvm/classes/com/sucukdeluxe/extractor/JBindExtractorMain.class differ diff --git a/resources/extractor-jvm/lib/sevenzipjbinding-all-platforms.jar b/resources/extractor-jvm/lib/sevenzipjbinding-all-platforms.jar new file mode 100644 index 0000000..bfb022c Binary files /dev/null and b/resources/extractor-jvm/lib/sevenzipjbinding-all-platforms.jar differ diff --git a/resources/extractor-jvm/lib/sevenzipjbinding.jar b/resources/extractor-jvm/lib/sevenzipjbinding.jar new file mode 100644 index 0000000..d153dbc Binary files /dev/null and b/resources/extractor-jvm/lib/sevenzipjbinding.jar differ diff --git a/resources/extractor-jvm/lib/zip4j.jar b/resources/extractor-jvm/lib/zip4j.jar new file mode 100644 index 0000000..c08f0f3 Binary files /dev/null and b/resources/extractor-jvm/lib/zip4j.jar differ diff --git a/resources/extractor-jvm/licenses/7-Zip-license.txt b/resources/extractor-jvm/licenses/7-Zip-license.txt new file mode 100644 index 0000000..a5a8079 --- /dev/null +++ b/resources/extractor-jvm/licenses/7-Zip-license.txt @@ -0,0 +1,146 @@ + 7-Zip + ~~~~~ + License for use and distribution + ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + + 7-Zip Copyright (C) 1999-2025 Igor Pavlov. + + The licenses for files are: + + - 7z.dll: + - The "GNU LGPL" as main license for most of the code + - The "GNU LGPL" with "unRAR license restriction" for some code + - The "BSD 3-clause License" for some code + - The "BSD 2-clause License" for some code + - All other files: the "GNU LGPL". + + Redistributions in binary form must reproduce related license information from this file. + + Note: + You can use 7-Zip on any computer, including a computer in a commercial + organization. You don't need to register or pay for 7-Zip. + + +GNU LGPL information +-------------------- + + This library is free software; you can redistribute it and/or + modify it under the terms of the GNU Lesser General Public + License as published by the Free Software Foundation; either + version 2.1 of the License, or (at your option) any later version. + + This library is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + Lesser General Public License for more details. + + You can receive a copy of the GNU Lesser General Public License from + http://www.gnu.org/ + + + + +BSD 3-clause License in 7-Zip code +---------------------------------- + + The "BSD 3-clause License" is used for the following code in 7z.dll + 1) LZFSE data decompression. + That code was derived from the code in the "LZFSE compression library" developed by Apple Inc, + that also uses the "BSD 3-clause License". + 2) ZSTD data decompression. + that code was developed using original zstd decoder code as reference code. + The original zstd decoder code was developed by Facebook Inc, + that also uses the "BSD 3-clause License". + + Copyright (c) 2015-2016, Apple Inc. All rights reserved. + Copyright (c) Facebook, Inc. All rights reserved. + Copyright (c) 2023-2025 Igor Pavlov. + +Text of the "BSD 3-clause License" +---------------------------------- + +Redistribution and use in source and binary forms, with or without modification, +are permitted provided that the following conditions are met: + +1. Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + +2. Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + +3. Neither the name of the copyright holder nor the names of its contributors may + be used to endorse or promote products derived from this software without + specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR +ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON +ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +--- + + + + +BSD 2-clause License in 7-Zip code +---------------------------------- + + The "BSD 2-clause License" is used for the XXH64 code in 7-Zip. + + XXH64 code in 7-Zip was derived from the original XXH64 code developed by Yann Collet. + + Copyright (c) 2012-2021 Yann Collet. + Copyright (c) 2023-2025 Igor Pavlov. + +Text of the "BSD 2-clause License" +---------------------------------- + +Redistribution and use in source and binary forms, with or without modification, +are permitted provided that the following conditions are met: + +1. Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + +2. Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR +ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON +ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +--- + + + + +unRAR license restriction +------------------------- + +The decompression engine for RAR archives was developed using source +code of unRAR program. +All copyrights to original unRAR code are owned by Alexander Roshal. + +The license for original unRAR code has the following restriction: + + The unRAR sources cannot be used to re-create the RAR compression algorithm, + which is proprietary. Distribution of modified unRAR sources in separate form + or as a part of other software is permitted, provided that it is clearly + stated in the documentation and source comments that the code may + not be used to develop a RAR (WinRAR) compatible archiver. + +-- diff --git a/resources/extractor-jvm/licenses/Apache-2.0.txt b/resources/extractor-jvm/licenses/Apache-2.0.txt new file mode 100644 index 0000000..d645695 --- /dev/null +++ b/resources/extractor-jvm/licenses/Apache-2.0.txt @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/resources/extractor-jvm/licenses/LGPL-2.1.txt b/resources/extractor-jvm/licenses/LGPL-2.1.txt new file mode 100644 index 0000000..f6683e7 --- /dev/null +++ b/resources/extractor-jvm/licenses/LGPL-2.1.txt @@ -0,0 +1,501 @@ + GNU LESSER GENERAL PUBLIC LICENSE + Version 2.1, February 1999 + + Copyright (C) 1991, 1999 Free Software Foundation, Inc. + + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + +[This is the first released version of the Lesser GPL. It also counts + as the successor of the GNU Library Public License, version 2, hence + the version number 2.1.] + + Preamble + + The licenses for most software are designed to take away your +freedom to share and change it. By contrast, the GNU General Public +Licenses are intended to guarantee your freedom to share and change +free software--to make sure the software is free for all its users. + + This license, the Lesser General Public License, applies to some +specially designated software packages--typically libraries--of the +Free Software Foundation and other authors who decide to use it. You +can use it too, but we suggest you first think carefully about whether +this license or the ordinary General Public License is the better +strategy to use in any particular case, based on the explanations below. + + When we speak of free software, we are referring to freedom of use, +not price. Our General Public Licenses are designed to make sure that +you have the freedom to distribute copies of free software (and charge +for this service if you wish); that you receive source code or can get +it if you want it; that you can change the software and use pieces of +it in new free programs; and that you are informed that you can do +these things. + + To protect your rights, we need to make restrictions that forbid +distributors to deny you these rights or to ask you to surrender these +rights. These restrictions translate to certain responsibilities for +you if you distribute copies of the library or if you modify it. + + For example, if you distribute copies of the library, whether gratis +or for a fee, you must give the recipients all the rights that we gave +you. You must make sure that they, too, receive or can get the source +code. If you link other code with the library, you must provide +complete object files to the recipients, so that they can relink them +with the library after making changes to the library and recompiling +it. And you must show them these terms so they know their rights. + + We protect your rights with a two-step method: (1) we copyright the +library, and (2) we offer you this license, which gives you legal +permission to copy, distribute and/or modify the library. + + To protect each distributor, we want to make it very clear that +there is no warranty for the free library. Also, if the library is +modified by someone else and passed on, the recipients should know +that what they have is not the original version, so that the original +author's reputation will not be affected by problems that might be +introduced by others. + + Finally, software patents pose a constant threat to the existence of +any free program. We wish to make sure that a company cannot +effectively restrict the users of a free program by obtaining a +restrictive license from a patent holder. Therefore, we insist that +any patent license obtained for a version of the library must be +consistent with the full freedom of use specified in this license. + + Most GNU software, including some libraries, is covered by the +ordinary GNU General Public License. This license, the GNU Lesser +General Public License, applies to certain designated libraries, and +is quite different from the ordinary General Public License. We use +this license for certain libraries in order to permit linking those +libraries into non-free programs. + + When a program is linked with a library, whether statically or using +a shared library, the combination of the two is legally speaking a +combined work, a derivative of the original library. The ordinary +General Public License therefore permits such linking only if the +entire combination fits its criteria of freedom. The Lesser General +Public License permits more lax criteria for linking other code with +the library. + + We call this license the "Lesser" General Public License because it +does Less to protect the user's freedom than the ordinary General +Public License. It also provides other free software developers Less +of an advantage over competing non-free programs. These disadvantages +are the reason we use the ordinary General Public License for many +libraries. However, the Lesser license provides advantages in certain +special circumstances. + + For example, on rare occasions, there may be a special need to +encourage the widest possible use of a certain library, so that it becomes +a de-facto standard. To achieve this, non-free programs must be +allowed to use the library. A more frequent case is that a free +library does the same job as widely used non-free libraries. In this +case, there is little to gain by limiting the free library to free +software only, so we use the Lesser General Public License. + + In other cases, permission to use a particular library in non-free +programs enables a greater number of people to use a large body of +free software. For example, permission to use the GNU C Library in +non-free programs enables many more people to use the whole GNU +operating system, as well as its variant, the GNU/Linux operating +system. + + Although the Lesser General Public License is Less protective of the +users' freedom, it does ensure that the user of a program that is +linked with the Library has the freedom and the wherewithal to run +that program using a modified version of the Library. + + The precise terms and conditions for copying, distribution and +modification follow. Pay close attention to the difference between a +"work based on the library" and a "work that uses the library". The +former contains code derived from the library, whereas the latter must +be combined with the library in order to run. + + GNU LESSER GENERAL PUBLIC LICENSE + TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION + + 0. This License Agreement applies to any software library or other +program which contains a notice placed by the copyright holder or +other authorized party saying it may be distributed under the terms of +this Lesser General Public License (also called "this License"). +Each licensee is addressed as "you". + + A "library" means a collection of software functions and/or data +prepared so as to be conveniently linked with application programs +(which use some of those functions and data) to form executables. + + The "Library", below, refers to any such software library or work +which has been distributed under these terms. A "work based on the +Library" means either the Library or any derivative work under +copyright law: that is to say, a work containing the Library or a +portion of it, either verbatim or with modifications and/or translated +straightforwardly into another language. (Hereinafter, translation is +included without limitation in the term "modification".) + + "Source code" for a work means the preferred form of the work for +making modifications to it. For a library, complete source code means +all the source code for all modules it contains, plus any associated +interface definition files, plus the scripts used to control compilation +and installation of the library. + + Activities other than copying, distribution and modification are not +covered by this License; they are outside its scope. The act of +running a program using the Library is not restricted, and output from +such a program is covered only if its contents constitute a work based +on the Library (independent of the use of the Library in a tool for +writing it). Whether that is true depends on what the Library does +and what the program that uses the Library does. + + 1. You may copy and distribute verbatim copies of the Library's +complete source code as you receive it, in any medium, provided that +you conspicuously and appropriately publish on each copy an +appropriate copyright notice and disclaimer of warranty; keep intact +all the notices that refer to this License and to the absence of any +warranty; and distribute a copy of this License along with the +Library. + + You may charge a fee for the physical act of transferring a copy, +and you may at your option offer warranty protection in exchange for a +fee. + + 2. You may modify your copy or copies of the Library or any portion +of it, thus forming a work based on the Library, and copy and +distribute such modifications or work under the terms of Section 1 +above, provided that you also meet all of these conditions: + + a) The modified work must itself be a software library. + + b) You must cause the files modified to carry prominent notices + stating that you changed the files and the date of any change. + + c) You must cause the whole of the work to be licensed at no + charge to all third parties under the terms of this License. + + d) If a facility in the modified Library refers to a function or a + table of data to be supplied by an application program that uses + the facility, other than as an argument passed when the facility + is invoked, then you must make a good faith effort to ensure that, + in the event an application does not supply such function or + table, the facility still operates, and performs whatever part of + its purpose remains meaningful. + + (For example, a function in a library to compute square roots has + a purpose that is entirely well-defined independent of the + application. Therefore, Subsection 2d requires that any + application-supplied function or table used by this function must + be optional: if the application does not supply it, the square + root function must still compute square roots.) + +These requirements apply to the modified work as a whole. If +identifiable sections of that work are not derived from the Library, +and can be reasonably considered independent and separate works in +themselves, then this License, and its terms, do not apply to those +sections when you distribute them as separate works. But when you +distribute the same sections as part of a whole which is a work based +on the Library, the distribution of the whole must be on the terms of +this License, whose permissions for other licensees extend to the +entire whole, and thus to each and every part regardless of who wrote +it. + +Thus, it is not the intent of this section to claim rights or contest +your rights to work written entirely by you; rather, the intent is to +exercise the right to control the distribution of derivative or +collective works based on the Library. + +In addition, mere aggregation of another work not based on the Library +with the Library (or with a work based on the Library) on a volume of +a storage or distribution medium does not bring the other work under +the scope of this License. + + 3. You may opt to apply the terms of the ordinary GNU General Public +License instead of this License to a given copy of the Library. To do +this, you must alter all the notices that refer to this License, so +that they refer to the ordinary GNU General Public License, version 2, +instead of to this License. (If a newer version than version 2 of the +ordinary GNU General Public License has appeared, then you can specify +that version instead if you wish.) Do not make any other change in +these notices. + + Once this change is made in a given copy, it is irreversible for +that copy, so the ordinary GNU General Public License applies to all +subsequent copies and derivative works made from that copy. + + This option is useful when you wish to copy part of the code of +the Library into a program that is not a library. + + 4. You may copy and distribute the Library (or a portion or +derivative of it, under Section 2) in object code or executable form +under the terms of Sections 1 and 2 above provided that you accompany +it with the complete corresponding machine-readable source code, which +must be distributed under the terms of Sections 1 and 2 above on a +medium customarily used for software interchange. + + If distribution of object code is made by offering access to copy +from a designated place, then offering equivalent access to copy the +source code from the same place satisfies the requirement to +distribute the source code, even though third parties are not +compelled to copy the source along with the object code. + + 5. A program that contains no derivative of any portion of the +Library, but is designed to work with the Library by being compiled or +linked with it, is called a "work that uses the Library". Such a +work, in isolation, is not a derivative work of the Library, and +therefore falls outside the scope of this License. + + However, linking a "work that uses the Library" with the Library +creates an executable that is a derivative of the Library (because it +contains portions of the Library), rather than a "work that uses the +library". The executable is therefore covered by this License. +Section 6 states terms for distribution of such executables. + + When a "work that uses the Library" uses material from a header file +that is part of the Library, the object code for the work may be a +derivative work of the Library even though the source code is not. +Whether this is true is especially significant if the work can be +linked without the Library, or if the work is itself a library. The +threshold for this to be true is not precisely defined by law. + + If such an object file uses only numerical parameters, data +structure layouts and accessors, and small macros and small inline +functions (ten lines or less in length), then the use of the object +file is unrestricted, regardless of whether it is legally a derivative +work. (Executables containing this object code plus portions of the +Library will still fall under Section 6.) + + Otherwise, if the work is a derivative of the Library, you may +distribute the object code for the work under the terms of Section 6. +Any executables containing that work also fall under Section 6, +whether or not they are linked directly with the Library itself. + + 6. As an exception to the Sections above, you may also combine or +link a "work that uses the Library" with the Library to produce a +work containing portions of the Library, and distribute that work +under terms of your choice, provided that the terms permit +modification of the work for the customer's own use and reverse +engineering for debugging such modifications. + + You must give prominent notice with each copy of the work that the +Library is used in it and that the Library and its use are covered by +this License. You must supply a copy of this License. If the work +during execution displays copyright notices, you must include the +copyright notice for the Library among them, as well as a reference +directing the user to the copy of this License. Also, you must do one +of these things: + + a) Accompany the work with the complete corresponding + machine-readable source code for the Library including whatever + changes were used in the work (which must be distributed under + Sections 1 and 2 above); and, if the work is an executable linked + with the Library, with the complete machine-readable "work that + uses the Library", as object code and/or source code, so that the + user can modify the Library and then relink to produce a modified + executable containing the modified Library. (It is understood + that the user who changes the contents of definitions files in the + Library will not necessarily be able to recompile the application + to use the modified definitions.) + + b) Use a suitable shared library mechanism for linking with the + Library. A suitable mechanism is one that (1) uses at run time a + copy of the library already present on the user's computer system, + rather than copying library functions into the executable, and (2) + will operate properly with a modified version of the library, if + the user installs one, as long as the modified version is + interface-compatible with the version that the work was made with. + + c) Accompany the work with a written offer, valid for at + least three years, to give the same user the materials + specified in Subsection 6a, above, for a charge no more + than the cost of performing this distribution. + + d) If distribution of the work is made by offering access to copy + from a designated place, offer equivalent access to copy the above + specified materials from the same place. + + e) Verify that the user has already received a copy of these + materials or that you have already sent this user a copy. + + For an executable, the required form of the "work that uses the +Library" must include any data and utility programs needed for +reproducing the executable from it. However, as a special exception, +the materials to be distributed need not include anything that is +normally distributed (in either source or binary form) with the major +components (compiler, kernel, and so on) of the operating system on +which the executable runs, unless that component itself accompanies +the executable. + + It may happen that this requirement contradicts the license +restrictions of other proprietary libraries that do not normally +accompany the operating system. Such a contradiction means you cannot +use both them and the Library together in an executable that you +distribute. + + 7. You may place library facilities that are a work based on the +Library side-by-side in a single library together with other library +facilities not covered by this License, and distribute such a combined +library, provided that the separate distribution of the work based on +the Library and of the other library facilities is otherwise +permitted, and provided that you do these two things: + + a) Accompany the combined library with a copy of the same work + based on the Library, uncombined with any other library + facilities. This must be distributed under the terms of the + Sections above. + + b) Give prominent notice with the combined library of the fact + that part of it is a work based on the Library, and explaining + where to find the accompanying uncombined form of the same work. + + 8. You may not copy, modify, sublicense, link with, or distribute +the Library except as expressly provided under this License. Any +attempt otherwise to copy, modify, sublicense, link with, or +distribute the Library is void, and will automatically terminate your +rights under this License. However, parties who have received copies, +or rights, from you under this License will not have their licenses +terminated so long as such parties remain in full compliance. + + 9. You are not required to accept this License, since you have not +signed it. However, nothing else grants you permission to modify or +distribute the Library or its derivative works. These actions are +prohibited by law if you do not accept this License. Therefore, by +modifying or distributing the Library (or any work based on the +Library), you indicate your acceptance of this License to do so, and +all its terms and conditions for copying, distributing or modifying +the Library or works based on it. + + 10. Each time you redistribute the Library (or any work based on the +Library), the recipient automatically receives a license from the +original licensor to copy, distribute, link with or modify the Library +subject to these terms and conditions. You may not impose any further +restrictions on the recipients' exercise of the rights granted herein. +You are not responsible for enforcing compliance by third parties with +this License. + + 11. If, as a consequence of a court judgment or allegation of patent +infringement or for any other reason (not limited to patent issues), +conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot +distribute so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you +may not distribute the Library at all. For example, if a patent +license would not permit royalty-free redistribution of the Library by +all those who receive copies directly or indirectly through you, then +the only way you could satisfy both it and this License would be to +refrain entirely from distribution of the Library. + +If any portion of this section is held invalid or unenforceable under any +particular circumstance, the balance of the section is intended to apply, +and the section as a whole is intended to apply in other circumstances. + +It is not the purpose of this section to induce you to infringe any +patents or other property right claims or to contest validity of any +such claims; this section has the sole purpose of protecting the +integrity of the free software distribution system which is +implemented by public license practices. Many people have made +generous contributions to the wide range of software distributed +through that system in reliance on consistent application of that +system; it is up to the author/donor to decide if he or she is willing +to distribute software through any other system and a licensee cannot +impose that choice. + +This section is intended to make thoroughly clear what is believed to +be a consequence of the rest of this License. + + 12. If the distribution and/or use of the Library is restricted in +certain countries either by patents or by copyrighted interfaces, the +original copyright holder who places the Library under this License may add +an explicit geographical distribution limitation excluding those countries, +so that distribution is permitted only in or among countries not thus +excluded. In such case, this License incorporates the limitation as if +written in the body of this License. + + 13. The Free Software Foundation may publish revised and/or new +versions of the Lesser General Public License from time to time. +Such new versions will be similar in spirit to the present version, +but may differ in detail to address new problems or concerns. + +Each version is given a distinguishing version number. If the Library +specifies a version number of this License which applies to it and +"any later version", you have the option of following the terms and +conditions either of that version or of any later version published by +the Free Software Foundation. If the Library does not specify a +license version number, you may choose any version ever published by +the Free Software Foundation. + + 14. If you wish to incorporate parts of the Library into other free +programs whose distribution conditions are incompatible with these, +write to the author to ask for permission. For software which is +copyrighted by the Free Software Foundation, write to the Free +Software Foundation; we sometimes make exceptions for this. Our +decision will be guided by the two goals of preserving the free status +of all derivatives of our free software and of promoting the sharing +and reuse of software generally. + + NO WARRANTY + + 15. BECAUSE THE LIBRARY IS LICENSED FREE OF CHARGE, THERE IS NO +WARRANTY FOR THE LIBRARY, TO THE EXTENT PERMITTED BY APPLICABLE LAW. +EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR +OTHER PARTIES PROVIDE THE LIBRARY "AS IS" WITHOUT WARRANTY OF ANY +KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE +LIBRARY IS WITH YOU. SHOULD THE LIBRARY PROVE DEFECTIVE, YOU ASSUME +THE COST OF ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN +WRITING WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY +AND/OR REDISTRIBUTE THE LIBRARY AS PERMITTED ABOVE, BE LIABLE TO YOU +FOR DAMAGES, INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR +CONSEQUENTIAL DAMAGES ARISING OUT OF THE USE OR INABILITY TO USE THE +LIBRARY (INCLUDING BUT NOT LIMITED TO LOSS OF DATA OR DATA BEING +RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR A +FAILURE OF THE LIBRARY TO OPERATE WITH ANY OTHER SOFTWARE), EVEN IF +SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH +DAMAGES. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Libraries + + If you develop a new library, and you want it to be of the greatest +possible use to the public, we recommend making it free software that +everyone can redistribute and change. You can do so by permitting +redistribution under these terms (or, alternatively, under the terms of the +ordinary General Public License). + + To apply these terms, attach the following notices to the library. It is +safest to attach them to the start of each source file to most effectively +convey the exclusion of warranty; and each file should have at least the +"copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This library is free software; you can redistribute it and/or + modify it under the terms of the GNU Lesser General Public + License as published by the Free Software Foundation; either + version 2.1 of the License, or (at your option) any later version. + + This library is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + Lesser General Public License for more details. + + You should have received a copy of the GNU Lesser General Public + License along with this library; if not, see . + +Also add information on how to contact you by electronic and paper mail. + +You should also get your employer (if you work as a programmer) or your +school, if any, to sign a "copyright disclaimer" for the library, if +necessary. Here is a sample; alter the names: + + Yoyodyne, Inc., hereby disclaims all copyright interest in the + library `Frob' (a library for tweaking knobs) written by James Random Hacker. + + , 1 April 1990 + Moe Ghoul, President of Vice + +That's all there is to it! diff --git a/resources/extractor-jvm/src/com/sucukdeluxe/extractor/JBindExtractorMain.java b/resources/extractor-jvm/src/com/sucukdeluxe/extractor/JBindExtractorMain.java new file mode 100644 index 0000000..5d64b33 --- /dev/null +++ b/resources/extractor-jvm/src/com/sucukdeluxe/extractor/JBindExtractorMain.java @@ -0,0 +1,1221 @@ +package com.sucukdeluxe.extractor; + +import net.lingala.zip4j.ZipFile; +import net.lingala.zip4j.exception.ZipException; +import net.lingala.zip4j.model.FileHeader; +import net.sf.sevenzipjbinding.ExtractAskMode; +import net.sf.sevenzipjbinding.ExtractOperationResult; +import net.sf.sevenzipjbinding.IArchiveExtractCallback; +import net.sf.sevenzipjbinding.IArchiveOpenCallback; +import net.sf.sevenzipjbinding.IArchiveOpenVolumeCallback; +import net.sf.sevenzipjbinding.IInArchive; +import net.sf.sevenzipjbinding.IInStream; +import net.sf.sevenzipjbinding.ISequentialOutStream; +import net.sf.sevenzipjbinding.ICryptoGetTextPassword; +import net.sf.sevenzipjbinding.PropID; +import net.sf.sevenzipjbinding.SevenZip; +import net.sf.sevenzipjbinding.SevenZipException; +import net.sf.sevenzipjbinding.impl.RandomAccessFileInStream; +import net.sf.sevenzipjbinding.impl.VolumedArchiveInStream; +import net.sf.sevenzipjbinding.simple.ISimpleInArchive; +import net.sf.sevenzipjbinding.simple.ISimpleInArchiveItem; + +import java.io.Closeable; +import java.io.File; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.io.RandomAccessFile; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.util.ArrayList; +import java.util.Base64; +import java.util.HashMap; +import java.util.HashSet; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Set; +import java.util.regex.Pattern; + +public final class JBindExtractorMain { + private static final int BUFFER_SIZE = 64 * 1024; + private static final Pattern NUMBERED_ZIP_SPLIT_RE = Pattern.compile("(?i).*\\.zip\\.\\d{3}$"); + private static final Pattern OLD_ZIP_SPLIT_RE = Pattern.compile("(?i).*\\.z\\d{2,3}$"); + private static final Pattern SEVEN_ZIP_SPLIT_RE = Pattern.compile("(?i).*\\.7z\\.001$"); + private static final Pattern DIGIT_SUFFIX_RE = Pattern.compile("\\d{2,3}"); + private static final Pattern WINDOWS_SPECIAL_CHARS_RE = Pattern.compile("[:<>*?\"\\|]"); + private static volatile boolean sevenZipInitialized = false; + + private JBindExtractorMain() { + } + + public static void main(String[] args) { + if (args.length == 1 && "--daemon".equals(args[0])) { + runDaemon(); + return; + } + int exit = 1; + try { + ExtractionRequest request = parseArgs(args); + exit = runExtraction(request); + } catch (IllegalArgumentException error) { + emitError("Argumentfehler: " + safeMessage(error)); + exit = 2; + } catch (Throwable error) { + emitError(safeMessage(error)); + exit = 1; + } + System.exit(exit); + } + + private static void runDaemon() { + System.out.println("RD_DAEMON_READY"); + System.out.flush(); + java.io.BufferedReader reader = new java.io.BufferedReader( + new java.io.InputStreamReader(System.in, StandardCharsets.UTF_8)); + try { + String line; + while ((line = reader.readLine()) != null) { + line = line.trim(); + if (line.isEmpty()) { + continue; + } + int exitCode = 1; + try { + ExtractionRequest request = parseDaemonRequest(line); + exitCode = runExtraction(request); + } catch (IllegalArgumentException error) { + emitError("Argumentfehler: " + safeMessage(error)); + exitCode = 2; + } catch (Throwable error) { + emitError(safeMessage(error)); + exitCode = 1; + } + System.out.println("RD_REQUEST_DONE " + exitCode); + System.out.flush(); + } + } catch (IOException ignored) { + + } + } + + private static ExtractionRequest parseDaemonRequest(String jsonLine) { + + ExtractionRequest request = new ExtractionRequest(); + request.archiveFile = new File(extractJsonString(jsonLine, "archive")); + request.targetDir = new File(extractJsonString(jsonLine, "target")); + String conflict = extractJsonString(jsonLine, "conflict"); + if (conflict.length() > 0) { + request.conflictMode = ConflictMode.fromValue(conflict); + } + String backend = extractJsonString(jsonLine, "backend"); + if (backend.length() > 0) { + request.backend = Backend.fromValue(backend); + } + + int pwStart = jsonLine.indexOf("\"passwords\""); + if (pwStart >= 0) { + int arrStart = jsonLine.indexOf('[', pwStart); + int arrEnd = jsonLine.indexOf(']', arrStart); + if (arrStart >= 0 && arrEnd > arrStart) { + String arrContent = jsonLine.substring(arrStart + 1, arrEnd); + int idx = 0; + while (idx < arrContent.length()) { + int qStart = arrContent.indexOf('"', idx); + if (qStart < 0) break; + int qEnd = findClosingQuote(arrContent, qStart + 1); + if (qEnd < 0) break; + request.passwords.add(unescapeJsonString(arrContent.substring(qStart + 1, qEnd))); + idx = qEnd + 1; + } + } + } + if (request.archiveFile == null || !request.archiveFile.exists() || !request.archiveFile.isFile()) { + throw new IllegalArgumentException("Archiv nicht gefunden: " + + (request.archiveFile == null ? "null" : request.archiveFile.getAbsolutePath())); + } + if (request.targetDir == null) { + throw new IllegalArgumentException("--target fehlt"); + } + return request; + } + + private static String extractJsonString(String json, String key) { + String search = "\"" + key + "\""; + int keyIdx = json.indexOf(search); + if (keyIdx < 0) return ""; + int colonIdx = json.indexOf(':', keyIdx + search.length()); + if (colonIdx < 0) return ""; + int qStart = json.indexOf('"', colonIdx + 1); + if (qStart < 0) return ""; + int qEnd = findClosingQuote(json, qStart + 1); + if (qEnd < 0) return ""; + return unescapeJsonString(json.substring(qStart + 1, qEnd)); + } + + private static int findClosingQuote(String s, int from) { + for (int i = from; i < s.length(); i++) { + char c = s.charAt(i); + if (c == '\\') { + i++; + continue; + } + if (c == '"') return i; + } + return -1; + } + + private static String unescapeJsonString(String s) { + if (s.indexOf('\\') < 0) return s; + StringBuilder sb = new StringBuilder(s.length()); + for (int i = 0; i < s.length(); i++) { + char c = s.charAt(i); + if (c == '\\' && i + 1 < s.length()) { + char next = s.charAt(i + 1); + switch (next) { + case '"': sb.append('"'); i++; break; + case '\\': sb.append('\\'); i++; break; + case '/': sb.append('/'); i++; break; + case 'n': sb.append('\n'); i++; break; + case 'r': sb.append('\r'); i++; break; + case 't': sb.append('\t'); i++; break; + default: sb.append(c); break; + } + } else { + sb.append(c); + } + } + return sb.toString(); + } + + private static int runExtraction(ExtractionRequest request) throws Exception { + List passwords = normalizePasswords(request.passwords); + Exception lastError = null; + boolean hadWrongPassword = false; + for (String password : passwords) { + try { + extractSingle(request, password); + emitPassword(password); + emitDone(); + return 0; + } catch (WrongPasswordException wrongPassword) { + hadWrongPassword = true; + lastError = wrongPassword; + } catch (Exception error) { + lastError = error; + break; + } + } + + if (hadWrongPassword && (lastError instanceof WrongPasswordException)) { + emitError("Falsches Archiv-Passwort"); + return 1; + } + if (lastError != null) { + throw lastError; + } + emitError("Entpacken fehlgeschlagen"); + return 1; + } + + private static void extractSingle(ExtractionRequest request, String password) throws Exception { + Backend backend = request.backend; + if (backend == Backend.AUTO) { + backend = shouldUseZip4j(request.archiveFile) ? Backend.ZIP4J : Backend.SEVENZIPJBIND; + } + emitBackend(backend); + + if (backend == Backend.ZIP4J) { + extractWithZip4j(request, password); + return; + } + extractWithSevenZip(request, password); + } + + private static void extractWithZip4j(ExtractionRequest request, String password) throws Exception { + ZipFile zipFile = new ZipFile(request.archiveFile); + try { + if (password != null && password.length() > 0) { + zipFile.setPassword(password.toCharArray()); + } + + List fileHeaders = zipFile.getFileHeaders(); + if (fileHeaders == null) { + fileHeaders = new ArrayList(); + } + + long totalUnits = 0; + boolean encrypted = false; + for (FileHeader header : fileHeaders) { + if (header == null || header.isDirectory()) { + continue; + } + encrypted = encrypted || header.isEncrypted(); + totalUnits += safeSize(header.getUncompressedSize()); + } + ProgressTracker progress = new ProgressTracker(totalUnits); + progress.emitStart(); + + Set reserved = new HashSet(); + for (FileHeader header : fileHeaders) { + if (header == null) { + continue; + } + + String entryName = normalizeEntryName(header.getFileName(), "file"); + if (header.isDirectory()) { + File dir = resolveDirectory(request.targetDir, entryName); + ensureDirectory(dir); + reserved.add(pathKey(dir)); + continue; + } + + long itemUnits = safeSize(header.getUncompressedSize()); + File output = resolveOutputFile(request.targetDir, entryName, request.conflictMode, reserved); + if (output == null) { + progress.advance(itemUnits); + continue; + } + + ensureDirectory(output.getParentFile()); + rejectSymlink(output); + long[] remaining = new long[] { itemUnits }; + boolean extractionSuccess = false; + try { + InputStream in = zipFile.getInputStream(header); + try { + OutputStream out = new FileOutputStream(output); + try { + byte[] buffer = new byte[BUFFER_SIZE]; + while (true) { + int read = in.read(buffer); + if (read < 0) { + break; + } + if (read == 0) { + continue; + } + out.write(buffer, 0, read); + long accounted = Math.min(remaining[0], (long) read); + remaining[0] -= accounted; + progress.advance(accounted); + } + } finally { + try { + out.close(); + } catch (Throwable ignored) { + } + } + } finally { + try { + in.close(); + } catch (Throwable ignored) { + } + } + if (remaining[0] > 0) { + progress.advance(remaining[0]); + } + long modified = header.getLastModifiedTimeEpoch(); + if (modified > 0) { + output.setLastModified(modified); + } + extractionSuccess = true; + } catch (ZipException error) { + if (isWrongPassword(error, encrypted)) { + throw new WrongPasswordException(error); + } + throw error; + } finally { + if (!extractionSuccess && output.exists()) { + try { + output.delete(); + } catch (Throwable ignored) { + } + } + } + } + + progress.emitDone(); + } finally { + try { + zipFile.close(); + } catch (Throwable ignored) { + } + } + } + + private static synchronized void ensureSevenZipInitialized() throws Exception { + if (sevenZipInitialized) { + return; + } + SevenZip.initSevenZipFromPlatformJAR(); + sevenZipInitialized = true; + } + + private static void extractWithSevenZip(ExtractionRequest request, String password) throws Exception { + ensureSevenZipInitialized(); + SevenZipArchiveContext context = null; + try { + context = openSevenZipArchive(request.archiveFile, password); + IInArchive archive = context.archive; + int itemCount = archive.getNumberOfItems(); + if (itemCount <= 0) { + throw new IOException("Archiv enthalt keine Eintrage oder konnte nicht gelesen werden: " + request.archiveFile.getAbsolutePath()); + } + + long totalUnits = 0; + boolean encrypted = false; + List fileIndices = new ArrayList(); + List outputFiles = new ArrayList(); + List fileSizes = new ArrayList(); + Set reserved = new HashSet(); + + for (int i = 0; i < itemCount; i++) { + Boolean isFolder = (Boolean) archive.getProperty(i, PropID.IS_FOLDER); + String entryPath = (String) archive.getProperty(i, PropID.PATH); + String entryName = normalizeEntryName(entryPath, "item-" + i); + + if (Boolean.TRUE.equals(isFolder)) { + File dir = resolveDirectory(request.targetDir, entryName); + ensureDirectory(dir); + reserved.add(pathKey(dir)); + continue; + } + + try { + Boolean isEncrypted = (Boolean) archive.getProperty(i, PropID.ENCRYPTED); + encrypted = encrypted || Boolean.TRUE.equals(isEncrypted); + } catch (Throwable ignored) { + + } + + Long rawSize = (Long) archive.getProperty(i, PropID.SIZE); + long itemSize = safeSize(rawSize); + totalUnits += itemSize; + + File output = resolveOutputFile(request.targetDir, entryName, request.conflictMode, reserved); + fileIndices.add(i); + outputFiles.add(output); + fileSizes.add(itemSize); + } + + if (fileIndices.isEmpty()) { + + ProgressTracker progress = new ProgressTracker(1); + progress.emitStart(); + progress.emitDone(); + return; + } + + ProgressTracker progress = new ProgressTracker(totalUnits); + progress.emitStart(); + + int[] indices = new int[fileIndices.size()]; + for (int i = 0; i < fileIndices.size(); i++) { + indices[i] = fileIndices.get(i); + } + + Map indexToPos = new HashMap(); + for (int i = 0; i < fileIndices.size(); i++) { + indexToPos.put(fileIndices.get(i), i); + } + + final boolean encryptedFinal = encrypted; + final String effectivePassword = password == null ? "" : password; + final File[] currentOutput = new File[1]; + final FileOutputStream[] currentStream = new FileOutputStream[1]; + final boolean[] currentSuccess = new boolean[1]; + final long[] currentRemaining = new long[1]; + final Throwable[] firstError = new Throwable[1]; + final int[] currentPos = new int[] { -1 }; + + try { + archive.extract(indices, false, new BulkExtractCallback( + archive, indexToPos, fileIndices, outputFiles, fileSizes, + progress, encryptedFinal, effectivePassword, currentOutput, + currentStream, currentSuccess, currentRemaining, currentPos, firstError + )); + } catch (SevenZipException error) { + if (looksLikeWrongPassword(error, encryptedFinal)) { + throw new WrongPasswordException(error); + } + throw error; + } + + if (firstError[0] != null) { + if (firstError[0] instanceof WrongPasswordException) { + throw (WrongPasswordException) firstError[0]; + } + throw (Exception) firstError[0]; + } + + progress.emitDone(); + } finally { + if (context != null) { + context.close(); + } + } + } + + private static SevenZipArchiveContext openSevenZipArchive(File archiveFile, String password) throws Exception { + String nameLower = archiveFile.getName().toLowerCase(Locale.ROOT); + String effectivePassword = password == null ? "" : password; + SevenZipVolumeCallback callback = new SevenZipVolumeCallback(archiveFile, effectivePassword); + + if (SEVEN_ZIP_SPLIT_RE.matcher(nameLower).matches()) { + VolumedArchiveInStream volumed = new VolumedArchiveInStream(archiveFile.getName(), callback); + try { + IInArchive archive = SevenZip.openInArchive(null, volumed, callback); + return new SevenZipArchiveContext(archive, null, volumed, callback); + } catch (Exception error) { + callback.close(); + throw error; + } + } + + RandomAccessFile raf = new RandomAccessFile(archiveFile, "r"); + RandomAccessFileInStream stream = new RandomAccessFileInStream(raf); + try { + IInArchive archive = SevenZip.openInArchive(null, stream, callback); + return new SevenZipArchiveContext(archive, stream, null, callback); + } catch (Exception error) { + try { + stream.close(); + } catch (Throwable ignored) { + } + try { + raf.close(); + } catch (Throwable ignored) { + } + throw error; + } + } + + private static boolean isWrongPassword(ZipException error, boolean encrypted) { + if (error == null) { + return false; + } + if (error.getType() == ZipException.Type.WRONG_PASSWORD) { + return true; + } + String text = safeMessage(error).toLowerCase(Locale.ROOT); + if (text.contains("wrong password") || text.contains("falsches passwort")) { + return true; + } + return encrypted && (text.contains("checksum") || text.contains("crc") || text.contains("password")); + } + + private static boolean isPasswordFailure(ExtractOperationResult result, boolean encrypted) { + if (!encrypted || result == null) { + return false; + } + return result == ExtractOperationResult.CRCERROR || result == ExtractOperationResult.DATAERROR; + } + + private static boolean looksLikeWrongPassword(Throwable error, boolean encrypted) { + if (error == null) { + return false; + } + String text = safeMessage(error).toLowerCase(Locale.ROOT); + if (text.contains("wrong password") || text.contains("falsches passwort")) { + return true; + } + return encrypted && (text.contains("crc") || text.contains("data error") || text.contains("checksum")); + } + + private static boolean shouldUseZip4j(File archiveFile) { + String name = archiveFile.getName().toLowerCase(Locale.ROOT); + if (NUMBERED_ZIP_SPLIT_RE.matcher(name).matches()) { + return true; + } + if (OLD_ZIP_SPLIT_RE.matcher(name).matches()) { + return true; + } + if (name.endsWith(".zip")) { + File parent = archiveFile.getParentFile(); + if (parent == null || !parent.exists()) { + return false; + } + String stem = archiveFile.getName().substring(0, archiveFile.getName().length() - 4); + File[] siblings = parent.listFiles(); + if (siblings == null) { + return false; + } + String prefix = (stem + ".z").toLowerCase(Locale.ROOT); + for (File sibling : siblings) { + String siblingName = sibling.getName().toLowerCase(Locale.ROOT); + if (!sibling.isFile()) { + continue; + } + if (siblingName.startsWith(prefix) && siblingName.length() >= prefix.length() + 2) { + String suffix = siblingName.substring(prefix.length()); + if (DIGIT_SUFFIX_RE.matcher(suffix).matches()) { + return true; + } + } + } + } + return false; + } + + private static File resolveDirectory(File targetDir, String entryName) throws IOException { + File directory = secureResolve(targetDir, entryName); + return directory; + } + + private static File resolveOutputFile(File targetDir, String entryName, ConflictMode conflictMode, Set reserved) throws IOException { + File base = secureResolve(targetDir, entryName); + String key = pathKey(base); + boolean exists = base.exists() || reserved.contains(key); + + if (!exists) { + reserved.add(key); + return base; + } + + if (conflictMode == ConflictMode.SKIP) { + return null; + } + + if (conflictMode == ConflictMode.OVERWRITE) { + if (base.exists()) { + deleteRecursively(base); + } + reserved.add(key); + return base; + } + + File parent = base.getParentFile(); + String fileName = base.getName(); + int dot = fileName.lastIndexOf('.'); + String stem = dot > 0 ? fileName.substring(0, dot) : fileName; + String ext = dot > 0 ? fileName.substring(dot) : ""; + + int counter = 1; + while (counter <= 10000) { + String candidateName = stem + " (" + counter + ")" + ext; + File candidate = new File(parent, candidateName); + String candidateKey = pathKey(candidate); + if (!candidate.exists() && !reserved.contains(candidateKey)) { + reserved.add(candidateKey); + return candidate; + } + counter += 1; + } + + throw new IOException("Rename-Limit erreicht fur " + entryName); + } + + private static void deleteRecursively(File file) throws IOException { + if (file == null || !file.exists()) { + return; + } + if (file.isDirectory()) { + File[] children = file.listFiles(); + if (children != null) { + for (File child : children) { + deleteRecursively(child); + } + } + } + if (!file.delete()) { + throw new IOException("Konnte Datei nicht uberschreiben: " + file.getAbsolutePath()); + } + } + + private static File secureResolve(File targetDir, String entryName) throws IOException { + String normalized = normalizeEntryName(entryName, "file"); + while (normalized.startsWith("/")) { + normalized = normalized.substring(1); + } + while (normalized.startsWith("\\")) { + normalized = normalized.substring(1); + } + if (normalized.matches("^[a-zA-Z]:.*")) { + normalized = normalized.substring(2); + while (normalized.startsWith("/")) { + normalized = normalized.substring(1); + } + while (normalized.startsWith("\\")) { + normalized = normalized.substring(1); + } + } + File targetCanonical = targetDir.getCanonicalFile(); + File output = new File(targetCanonical, normalized); + File outputCanonical = output.getCanonicalFile(); + String targetPath = targetCanonical.getPath(); + String outputPath = outputCanonical.getPath(); + String targetPathNorm = isWindows() ? targetPath.toLowerCase(Locale.ROOT) : targetPath; + String outputPathNorm = isWindows() ? outputPath.toLowerCase(Locale.ROOT) : outputPath; + String targetPrefix = targetPathNorm.endsWith(File.separator) ? targetPathNorm : targetPathNorm + File.separator; + if (!outputPathNorm.equals(targetPathNorm) && !outputPathNorm.startsWith(targetPrefix)) { + throw new IOException("Path Traversal blockiert: " + entryName); + } + return outputCanonical; + } + + private static String normalizeEntryName(String value, String fallback) { + String entry = value == null ? "" : value.trim(); + if (entry.length() == 0) { + return fallback; + } + entry = entry.replace('\\', '/'); + while (entry.startsWith("./")) { + entry = entry.substring(2); + } + if (entry.length() == 0) { + return fallback; + } + + String[] segments = entry.split("/", -1); + StringBuilder sanitized = new StringBuilder(); + for (int i = 0; i < segments.length; i++) { + if (i > 0) { + sanitized.append('/'); + } + sanitized.append(WINDOWS_SPECIAL_CHARS_RE.matcher(segments[i]).replaceAll("_")); + } + entry = sanitized.toString(); + if (entry.length() == 0) { + return fallback; + } + return entry; + } + + private static long safeSize(Long value) { + if (value == null) { + return 0; + } + long size = value.longValue(); + if (size <= 0) { + return 0; + } + return size; + } + + private static void rejectSymlink(File file) throws IOException { + if (file == null) { + return; + } + if (Files.isSymbolicLink(file.toPath())) { + throw new IOException("Zieldatei ist ein Symlink, Schreiben verweigert: " + file.getAbsolutePath()); + } + + File parent = file.getParentFile(); + while (parent != null) { + if (Files.isSymbolicLink(parent.toPath())) { + throw new IOException("Elternverzeichnis ist ein Symlink, Schreiben verweigert: " + parent.getAbsolutePath()); + } + parent = parent.getParentFile(); + } + } + + private static void ensureDirectory(File dir) throws IOException { + if (dir == null) { + return; + } + if (dir.exists()) { + if (!dir.isDirectory()) { + throw new IOException("Pfad ist keine Directory: " + dir.getAbsolutePath()); + } + return; + } + if (!dir.mkdirs() && !dir.isDirectory()) { + throw new IOException("Verzeichnis konnte nicht erstellt werden: " + dir.getAbsolutePath()); + } + } + + private static String pathKey(File file) { + String value = file.getAbsolutePath(); + if (isWindows()) { + value = value.toLowerCase(Locale.ROOT); + } + return value; + } + + private static boolean isWindows() { + String osName = System.getProperty("os.name", "").toLowerCase(Locale.ROOT); + return osName.contains("win"); + } + + private static List normalizePasswords(List input) { + LinkedHashSet deduped = new LinkedHashSet(); + deduped.add(""); + if (input != null) { + for (String value : input) { + deduped.add(value == null ? "" : value); + } + } + return new ArrayList(deduped); + } + + private static ExtractionRequest parseArgs(String[] args) { + ExtractionRequest request = new ExtractionRequest(); + int index = 0; + while (index < args.length) { + String key = args[index]; + if ("--archive".equals(key)) { + request.archiveFile = new File(readNext(args, ++index, key)); + } else if ("--target".equals(key)) { + request.targetDir = new File(readNext(args, ++index, key)); + } else if ("--conflict".equals(key)) { + request.conflictMode = ConflictMode.fromValue(readNext(args, ++index, key)); + } else if ("--backend".equals(key)) { + request.backend = Backend.fromValue(readNext(args, ++index, key)); + } else if ("--password".equals(key)) { + request.passwords.add(readNext(args, ++index, key)); + } else { + throw new IllegalArgumentException("Unbekanntes Argument: " + key); + } + index += 1; + } + + if (request.archiveFile == null) { + throw new IllegalArgumentException("--archive fehlt"); + } + if (request.targetDir == null) { + throw new IllegalArgumentException("--target fehlt"); + } + if (!request.archiveFile.exists() || !request.archiveFile.isFile()) { + throw new IllegalArgumentException("Archiv nicht gefunden: " + request.archiveFile.getAbsolutePath()); + } + return request; + } + + private static String readNext(String[] args, int index, String key) { + if (index >= args.length) { + throw new IllegalArgumentException("Wert fehlt fur " + key); + } + return args[index]; + } + + private static String safeMessage(Throwable error) { + if (error == null) { + return "Unbekannter Fehler"; + } + String message = error.getMessage(); + if (message == null || message.trim().length() == 0) { + message = error.toString(); + } + return message.replace('\n', ' ').replace('\r', ' ').trim(); + } + + private static void emitBackend(Backend backend) { + System.out.println("RD_BACKEND " + backend.value); + } + + private static void emitPassword(String password) { + String encoded = Base64.getEncoder().encodeToString((password == null ? "" : password).getBytes(StandardCharsets.UTF_8)); + System.out.println("RD_PASSWORD " + encoded); + } + + private static void emitDone() { + System.out.println("RD_DONE"); + } + + private static void emitError(String message) { + System.err.println("RD_ERROR " + message); + } + + private enum Backend { + AUTO("auto"), + SEVENZIPJBIND("7zjbinding"), + ZIP4J("zip4j"); + + private final String value; + + Backend(String value) { + this.value = value; + } + + static Backend fromValue(String raw) { + String value = raw == null ? "" : raw.trim().toLowerCase(Locale.ROOT); + if ("auto".equals(value)) { + return AUTO; + } + if ("7zjb".equals(value) || "7zjbinding".equals(value) || "sevenzipjbinding".equals(value)) { + return SEVENZIPJBIND; + } + if ("zip4j".equals(value)) { + return ZIP4J; + } + throw new IllegalArgumentException("Ungueltiger Backend-Wert: " + raw); + } + } + + private enum ConflictMode { + OVERWRITE, + SKIP, + RENAME; + + static ConflictMode fromValue(String raw) { + String value = raw == null ? "" : raw.trim().toLowerCase(Locale.ROOT); + if ("overwrite".equals(value)) { + return OVERWRITE; + } + if ("skip".equals(value) || "ask".equals(value)) { + return SKIP; + } + if ("rename".equals(value)) { + return RENAME; + } + throw new IllegalArgumentException("Ungueltiger Conflict-Wert: " + raw); + } + } + + private static final class ExtractionRequest { + private File archiveFile; + private File targetDir; + private ConflictMode conflictMode = ConflictMode.SKIP; + private Backend backend = Backend.AUTO; + private final List passwords = new ArrayList(); + } + + private static final class BulkExtractCallback implements IArchiveExtractCallback, ICryptoGetTextPassword { + private final IInArchive archive; + private final Map indexToPos; + private final List fileIndices; + private final List outputFiles; + private final List fileSizes; + private final ProgressTracker progress; + private final boolean encrypted; + private final String password; + private final File[] currentOutput; + private final FileOutputStream[] currentStream; + private final boolean[] currentSuccess; + private final long[] currentRemaining; + private final int[] currentPos; + private final Throwable[] firstError; + + BulkExtractCallback(IInArchive archive, Map indexToPos, + List fileIndices, List outputFiles, List fileSizes, + ProgressTracker progress, boolean encrypted, String password, + File[] currentOutput, FileOutputStream[] currentStream, + boolean[] currentSuccess, long[] currentRemaining, int[] currentPos, + Throwable[] firstError) { + this.archive = archive; + this.indexToPos = indexToPos; + this.fileIndices = fileIndices; + this.outputFiles = outputFiles; + this.fileSizes = fileSizes; + this.progress = progress; + this.encrypted = encrypted; + this.password = password; + this.currentOutput = currentOutput; + this.currentStream = currentStream; + this.currentSuccess = currentSuccess; + this.currentRemaining = currentRemaining; + this.currentPos = currentPos; + this.firstError = firstError; + } + + @Override + public String cryptoGetTextPassword() { + return password; + } + + @Override + public void setTotal(long total) { + + } + + @Override + public void setCompleted(long complete) { + + } + + @Override + public ISequentialOutStream getStream(int index, ExtractAskMode extractAskMode) throws SevenZipException { + closeCurrentStream(); + + Integer pos = indexToPos.get(index); + if (pos == null) { + return null; + } + currentPos[0] = pos; + currentOutput[0] = outputFiles.get(pos); + currentSuccess[0] = false; + currentRemaining[0] = fileSizes.get(pos); + + if (extractAskMode != ExtractAskMode.EXTRACT) { + currentOutput[0] = null; + return null; + } + + if (currentOutput[0] == null) { + progress.advance(currentRemaining[0]); + return null; + } + + try { + ensureDirectory(currentOutput[0].getParentFile()); + rejectSymlink(currentOutput[0]); + currentStream[0] = new FileOutputStream(currentOutput[0]); + } catch (IOException error) { + throw new SevenZipException("Fehler beim Erstellen: " + error.getMessage(), error); + } + + return new ISequentialOutStream() { + @Override + public int write(byte[] data) throws SevenZipException { + if (data == null || data.length == 0) { + return 0; + } + try { + currentStream[0].write(data); + } catch (IOException error) { + throw new SevenZipException("Fehler beim Schreiben: " + error.getMessage(), error); + } + long accounted = Math.min(currentRemaining[0], (long) data.length); + currentRemaining[0] -= accounted; + progress.advance(accounted); + return data.length; + } + }; + } + + @Override + public void prepareOperation(ExtractAskMode extractAskMode) { + + } + + @Override + public void setOperationResult(ExtractOperationResult result) throws SevenZipException { + if (currentRemaining[0] > 0) { + progress.advance(currentRemaining[0]); + currentRemaining[0] = 0; + } + + if (result == ExtractOperationResult.OK) { + currentSuccess[0] = true; + closeCurrentStream(); + if (currentPos[0] >= 0 && currentOutput[0] != null) { + try { + int archiveIndex = fileIndices.get(currentPos[0]); + java.util.Date modified = (java.util.Date) archive.getProperty(archiveIndex, PropID.LAST_MODIFICATION_TIME); + if (modified != null) { + currentOutput[0].setLastModified(modified.getTime()); + } + } catch (Throwable ignored) { + + } + } + } else { + closeCurrentStream(); + if (currentOutput[0] != null && currentOutput[0].exists()) { + try { + currentOutput[0].delete(); + } catch (Throwable ignored) { + } + } + if (firstError[0] == null) { + if (isPasswordFailure(result, encrypted)) { + firstError[0] = new WrongPasswordException(new IOException("Falsches Passwort")); + } else { + firstError[0] = new IOException("7z-Fehler: " + result.name()); + } + } + } + } + + private void closeCurrentStream() { + if (currentStream[0] != null) { + try { + currentStream[0].close(); + } catch (Throwable ignored) { + } + currentStream[0] = null; + } + if (!currentSuccess[0] && currentOutput[0] != null && currentOutput[0].exists()) { + try { + currentOutput[0].delete(); + } catch (Throwable ignored) { + } + } + } + } + + private static final class WrongPasswordException extends Exception { + private static final long serialVersionUID = 1L; + + WrongPasswordException(Throwable cause) { + super(cause); + } + } + + private static final class ProgressTracker { + private final long total; + private long completed; + private int lastPercent = -1; + + ProgressTracker(long totalUnits) { + this.total = Math.max(1L, totalUnits); + this.completed = 0L; + } + + synchronized void emitStart() { + emitPercent(0); + } + + synchronized void advance(long units) { + if (units <= 0) { + return; + } + completed += units; + if (completed > total) { + completed = total; + } + int percent = (int) Math.min(100L, Math.max(0L, (completed * 100L) / total)); + emitPercent(percent); + } + + synchronized void emitDone() { + completed = total; + emitPercent(100); + } + + private void emitPercent(int percent) { + int bounded = Math.max(0, Math.min(100, percent)); + if (bounded == lastPercent) { + return; + } + lastPercent = bounded; + System.out.println("RD_PROGRESS " + bounded + "%"); + } + } + + private static final class SevenZipArchiveContext implements Closeable { + private final IInArchive archive; + private final IInStream rootStream; + private final VolumedArchiveInStream volumedArchiveInStream; + private final SevenZipVolumeCallback callback; + + SevenZipArchiveContext(IInArchive archive, IInStream rootStream, VolumedArchiveInStream volumedArchiveInStream, SevenZipVolumeCallback callback) { + this.archive = archive; + this.rootStream = rootStream; + this.volumedArchiveInStream = volumedArchiveInStream; + this.callback = callback; + } + + @Override + public void close() { + if (archive != null) { + try { + archive.close(); + } catch (Throwable ignored) { + } + } + if (rootStream != null) { + try { + rootStream.close(); + } catch (Throwable ignored) { + } + } + if (volumedArchiveInStream != null) { + try { + volumedArchiveInStream.close(); + } catch (Throwable ignored) { + } + } + if (callback != null) { + callback.close(); + } + } + } + + private static final class SevenZipVolumeCallback implements IArchiveOpenCallback, IArchiveOpenVolumeCallback, ICryptoGetTextPassword, Closeable { + private final File archiveDir; + private final String firstFileName; + private final String password; + private final Map openRafs = new HashMap(); + + SevenZipVolumeCallback(File archiveFile, String password) { + this.archiveDir = archiveFile.getAbsoluteFile().getParentFile(); + this.firstFileName = archiveFile.getName(); + this.password = password == null ? "" : password; + } + + @Override + public Object getProperty(PropID propID) { + if (propID == PropID.NAME) { + return firstFileName; + } + return null; + } + + @Override + public IInStream getStream(String filename) throws SevenZipException { + File file = resolveVolumeFile(filename); + if (file == null || !file.exists() || !file.isFile()) { + return null; + } + try { + String key = pathKey(file); + RandomAccessFile raf = openRafs.get(key); + if (raf == null) { + raf = new RandomAccessFile(file, "r"); + openRafs.put(key, raf); + } + raf.seek(0L); + return new RandomAccessFileInStream(raf); + } catch (IOException error) { + throw new SevenZipException("Volume konnte nicht geoffnet werden: " + filename, error); + } + } + + @Override + public void setTotal(Long files, Long bytes) { + + } + + @Override + public void setCompleted(Long files, Long bytes) { + + } + + @Override + public String cryptoGetTextPassword() { + return password; + } + + private File resolveVolumeFile(String filename) { + if (filename == null || filename.trim().length() == 0) { + return null; + } + + String baseName = new File(filename).getName(); + if (archiveDir != null) { + File relative = new File(archiveDir, baseName); + if (relative.exists()) { + return relative; + } + File[] siblings = archiveDir.listFiles(); + if (siblings != null) { + for (File sibling : siblings) { + if (!sibling.isFile()) { + continue; + } + if (sibling.getName().equalsIgnoreCase(baseName)) { + return sibling; + } + } + } + } + return null; + } + + @Override + public void close() { + for (RandomAccessFile raf : openRafs.values()) { + try { + raf.close(); + } catch (Throwable ignored) { + } + } + openRafs.clear(); + } + } +} diff --git a/scripts/afterPack.cjs b/scripts/afterPack.cjs new file mode 100644 index 0000000..4828acb --- /dev/null +++ b/scripts/afterPack.cjs @@ -0,0 +1,18 @@ +const path = require("path"); +const { rcedit } = require("rcedit"); + +module.exports = async function afterPack(context) { + const productFilename = context.packager?.appInfo?.productFilename; + if (!productFilename) { + console.warn(" • rcedit: skipped — productFilename not available"); + return; + } + const exePath = path.join(context.appOutDir, `${productFilename}.exe`); + const iconPath = path.resolve(__dirname, "..", "assets", "app_icon.ico"); + console.log(` • rcedit: patching icon → ${exePath}`); + try { + await rcedit(exePath, { icon: iconPath }); + } catch (error) { + console.warn(` • rcedit: failed — ${String(error)}`); + } +}; diff --git a/scripts/verify_public_release.mjs b/scripts/verify_public_release.mjs new file mode 100644 index 0000000..a7af7c6 --- /dev/null +++ b/scripts/verify_public_release.mjs @@ -0,0 +1,392 @@ +import fs from "node:fs"; +import path from "node:path"; +import crypto from "node:crypto"; +import os from "node:os"; +import { spawnSync } from "node:child_process"; +import { fileURLToPath } from "node:url"; +import { parse } from "yaml"; + +const EXPECTED_PUBLISH = Object.freeze({ + provider: "github", + owner: "Sucukdeluxe", + repo: "multi-debrid-downloader" +}); +const EXPECTED_PRODUCT_NAME = "Real-Debrid-Downloader"; +const EXPECTED_NSIS_ARTIFACT_NAME = "${productName}-Setup-${version}.${ext}"; +const EXPECTED_PORTABLE_ARTIFACT_NAME = "${productName}-${version}-portable.${ext}"; +const REQUIRED_BUILD_FILES = Object.freeze([ + "resources/extractor-jvm/**/*", + "LICENSE" +]); +const EXPECTED_EXTRA_RESOURCE = Object.freeze({ + from: "LICENSE", + to: "LICENSE" +}); +const REDISTRIBUTION_FILES = Object.freeze([ + Object.freeze({ + sourcePath: "LICENSE", + packagedPath: "resources/LICENSE", + sha256: "f2c1bc02d9ba5235cc67dfea734e7dc90559b00d8cb2d142bad7a984ff96d3f6" + }), + Object.freeze({ + sourcePath: "resources/extractor-jvm/licenses/LGPL-2.1.txt", + packagedPath: "resources/app.asar.unpacked/resources/extractor-jvm/licenses/LGPL-2.1.txt", + sha256: "20e50fe7aae3e56378ebf0417d9de904f55a0e61e4df315333e632a4d3555d95" + }), + Object.freeze({ + sourcePath: "resources/extractor-jvm/licenses/7-Zip-license.txt", + packagedPath: "resources/app.asar.unpacked/resources/extractor-jvm/licenses/7-Zip-license.txt", + sha256: "477e15d4033026edb25d36c9f078bb0beafc9318f6505473648972a536ece263" + }), + Object.freeze({ + sourcePath: "resources/extractor-jvm/licenses/Apache-2.0.txt", + packagedPath: "resources/app.asar.unpacked/resources/extractor-jvm/licenses/Apache-2.0.txt", + sha256: "cfc7749b96f63bd31c3c42b5c471bf756814053e847c10f3eb003417bc523d30" + }), + Object.freeze({ + sourcePath: "resources/extractor-jvm/THIRD_PARTY_NOTICES.txt", + packagedPath: "resources/app.asar.unpacked/resources/extractor-jvm/THIRD_PARTY_NOTICES.txt", + sha256: "d4ab6ee9ba293f9d25d22e0506a465d79794259274a0ec944d3bb3d001b46ca9" + }) +]); + +function readRequiredFile(filePath) { + requireNonEmptyFile(filePath, "required file"); + return fs.readFileSync(filePath, "utf8"); +} + +function parseYamlDocument(content, sourceName) { + let value; + try { + value = parse(content, { uniqueKeys: true }); + } catch (error) { + throw new Error(`${sourceName} contains invalid YAML: ${String(error?.message || error)}`); + } + if (!value || typeof value !== "object" || Array.isArray(value)) { + throw new Error(`${sourceName} must contain a YAML mapping`); + } + return value; +} + +function assertEqual(actual, expected, label) { + if (actual !== expected) { + throw new Error(`${label} must be ${expected}, received ${String(actual)}`); + } +} + +function resolveReleaseDir(rootDir) { + const directLatest = path.join(rootDir, "latest.yml"); + if (fs.existsSync(directLatest)) { + return rootDir; + } + return path.join(rootDir, "release"); +} + +function requireNonEmptyFile(filePath, label) { + let stat; + try { + stat = fs.lstatSync(filePath); + } catch (error) { + if (error?.code === "ENOENT") { + throw new Error(`Missing ${label}: ${filePath}`); + } + throw error; + } + if (stat.isSymbolicLink()) { + throw new Error(`${label} must be a regular file, not a symbolic link: ${filePath}`); + } + if (!stat.isFile() || stat.size === 0) { + throw new Error(`${label} must be a non-empty regular file: ${filePath}`); + } + return stat; +} + +function sha256NormalizedText(filePath) { + const normalized = fs.readFileSync(filePath, "utf8").replace(/\r\n/g, "\n").replace(/\r/g, "\n"); + return crypto.createHash("sha256").update(normalized, "utf8").digest("hex"); +} + +function verifyRedistributionFiles(baseDir, pathKey, label) { + for (const file of REDISTRIBUTION_FILES) { + const relativePath = file[pathKey]; + const filePath = path.join(baseDir, ...relativePath.split("/")); + requireNonEmptyFile(filePath, `${label} redistribution file`); + const actualDigest = sha256NormalizedText(filePath); + if (actualDigest !== file.sha256) { + throw new Error(`${label} redistribution content mismatch for ${relativePath}`); + } + } +} + +function hasExpectedExtraResource(extraResources) { + const entries = Array.isArray(extraResources) ? extraResources : [extraResources]; + return entries.some((entry) => ( + entry + && typeof entry === "object" + && !Array.isArray(entry) + && entry.from === EXPECTED_EXTRA_RESOURCE.from + && entry.to === EXPECTED_EXTRA_RESOURCE.to + )); +} + +function runCommand(command, args) { + const result = spawnSync(command, args, { + encoding: "utf8", + windowsHide: true + }); + return { + status: result.status, + stdout: String(result.stdout || ""), + stderr: String(result.stderr || ""), + error: result.error + }; +} + +function listFiles(rootDir) { + const files = []; + const pending = [rootDir]; + while (pending.length > 0) { + const current = pending.pop(); + for (const entry of fs.readdirSync(current, { withFileTypes: true })) { + const entryPath = path.join(current, entry.name); + const stat = fs.lstatSync(entryPath); + if (stat.isSymbolicLink()) { + files.push(entryPath); + } else if (stat.isDirectory()) { + pending.push(entryPath); + } else if (stat.isFile()) { + files.push(entryPath); + } + } + } + return files; +} + +function extractArchive(archivePath, outputDir, sevenZipPath, commandRunner) { + fs.mkdirSync(outputDir, { recursive: true }); + const args = ["x", archivePath, `-o${outputDir}`, "-y", "-bb0", "-bd"]; + const result = commandRunner(sevenZipPath, args); + if (result?.error || result?.status !== 0) { + const detail = String(result?.error?.message || result?.stderr || result?.stdout || "unknown error").trim(); + throw new Error(`7-Zip command failed for ${archivePath}: ${detail}`); + } +} + +function extractArchiveTree(archivePath, outputDir, sevenZipPath, commandRunner, depth = 0) { + if (depth > 5) { + throw new Error(`Archive nesting limit exceeded for ${archivePath}`); + } + extractArchive(archivePath, outputDir, sevenZipPath, commandRunner); + const nestedArchives = listFiles(outputDir).filter((filePath) => /\.7z$/i.test(filePath)); + for (let index = 0; index < nestedArchives.length; index += 1) { + const nestedArchive = nestedArchives[index]; + const nestedOutput = path.join(outputDir, `.nested-${depth}-${index}`); + extractArchiveTree(nestedArchive, nestedOutput, sevenZipPath, commandRunner, depth + 1); + } +} + +function normalizeRelativePath(rootDir, filePath) { + return path.relative(rootDir, filePath).split(path.sep).join("/"); +} + +function verifyArchiveRedistributionFiles(extractionRoot, archiveName) { + const extractedFiles = listFiles(extractionRoot); + for (const file of REDISTRIBUTION_FILES) { + const suffix = file.packagedPath.toLowerCase(); + const matches = extractedFiles.filter((filePath) => { + const relativePath = normalizeRelativePath(extractionRoot, filePath).toLowerCase(); + return relativePath === suffix || relativePath.endsWith(`/${suffix}`); + }); + if (matches.length === 0) { + throw new Error(`Missing redistribution file ${file.packagedPath} in archive ${archiveName}`); + } + for (const filePath of matches) { + requireNonEmptyFile(filePath, `${archiveName} redistribution file`); + const actualDigest = sha256NormalizedText(filePath); + if (actualDigest !== file.sha256) { + throw new Error(`Archive redistribution content mismatch for ${file.packagedPath} in ${archiveName}`); + } + } + } +} + +function readPackageVersion(rootDir) { + const packageJson = JSON.parse(readRequiredFile(path.join(rootDir, "package.json"))); + const version = String(packageJson.version || "").trim(); + if (!/^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/.test(version)) { + throw new Error(`package.json contains invalid version ${version || ""}`); + } + return { packageJson, version }; +} + +function sha512Base64(filePath) { + const hash = crypto.createHash("sha512"); + const handle = fs.openSync(filePath, "r"); + const buffer = Buffer.allocUnsafe(1024 * 1024); + try { + let bytesRead; + do { + bytesRead = fs.readSync(handle, buffer, 0, buffer.length, null); + if (bytesRead > 0) { + hash.update(buffer.subarray(0, bytesRead)); + } + } while (bytesRead > 0); + } finally { + fs.closeSync(handle); + } + return hash.digest("base64"); +} + +export function verifyPublicRelease(rootDir = process.cwd()) { + const absoluteRoot = path.resolve(rootDir); + const { packageJson, version } = readPackageVersion(absoluteRoot); + + const build = packageJson.build || {}; + const publish = build.publish || {}; + assertEqual(build.productName, EXPECTED_PRODUCT_NAME, "package.json build.productName"); + assertEqual(publish.provider, EXPECTED_PUBLISH.provider, "package.json publish provider"); + assertEqual(publish.owner, EXPECTED_PUBLISH.owner, "package.json publish owner"); + assertEqual(publish.repo, EXPECTED_PUBLISH.repo, "package.json publish repo"); + assertEqual(build.nsis?.artifactName, EXPECTED_NSIS_ARTIFACT_NAME, "package.json NSIS artifactName"); + assertEqual(build.portable?.artifactName, EXPECTED_PORTABLE_ARTIFACT_NAME, "package.json portable artifactName"); + const buildFiles = Array.isArray(build.files) ? build.files : []; + const missingBuildFiles = REQUIRED_BUILD_FILES.filter((entry) => !buildFiles.includes(entry)); + if (missingBuildFiles.length > 0) { + throw new Error(`package.json build.files omits redistribution content: ${missingBuildFiles.join(", ")}`); + } + if (!hasExpectedExtraResource(build.extraResources)) { + throw new Error("package.json build.extraResources must copy LICENSE to LICENSE"); + } + + const releaseDir = resolveReleaseDir(absoluteRoot); + const latestFields = parseYamlDocument( + readRequiredFile(path.join(releaseDir, "latest.yml")), + "latest.yml" + ); + const appUpdateFields = parseYamlDocument( + readRequiredFile(path.join(releaseDir, "win-unpacked", "resources", "app-update.yml")), + "app-update.yml" + ); + + assertEqual(latestFields.version, version, "latest.yml version"); + assertEqual(appUpdateFields.provider, EXPECTED_PUBLISH.provider, "app-update.yml provider"); + assertEqual(appUpdateFields.owner, EXPECTED_PUBLISH.owner, "app-update.yml owner"); + assertEqual(appUpdateFields.repo, EXPECTED_PUBLISH.repo, "app-update.yml repo"); + + const expectedSetup = `${EXPECTED_PRODUCT_NAME}-Setup-${version}.exe`; + const expectedPortable = `${EXPECTED_PRODUCT_NAME}-${version}-portable.exe`; + const expectedBlockmap = `${expectedSetup}.blockmap`; + assertEqual(latestFields.path, expectedSetup, "latest.yml path"); + + const requiredArtifacts = [expectedSetup, expectedPortable, expectedBlockmap]; + const missingArtifacts = requiredArtifacts.filter((fileName) => !fs.existsSync(path.join(releaseDir, fileName))); + if (missingArtifacts.length > 0) { + throw new Error(`Missing release artifacts: ${missingArtifacts.join(", ")}`); + } + const artifactStats = new Map(); + for (const fileName of requiredArtifacts) { + artifactStats.set(fileName, requireNonEmptyFile(path.join(releaseDir, fileName), "release artifact")); + } + + if (!Array.isArray(latestFields.files) || latestFields.files.length !== 1) { + throw new Error("latest.yml files must contain exactly one canonical installer entry"); + } + const installerEntry = latestFields.files[0]; + if (!installerEntry || typeof installerEntry !== "object" || Array.isArray(installerEntry)) { + throw new Error("latest.yml files entry must be a mapping"); + } + assertEqual(installerEntry.url, expectedSetup, "latest.yml files url"); + const expectedSetupSize = artifactStats.get(expectedSetup).size; + assertEqual(installerEntry.size, expectedSetupSize, "latest.yml files size"); + const expectedSetupDigest = sha512Base64(path.join(releaseDir, expectedSetup)); + assertEqual(installerEntry.sha512, expectedSetupDigest, "latest.yml files SHA512 digest"); + assertEqual(latestFields.sha512, expectedSetupDigest, "latest.yml SHA512 digest"); + + verifyRedistributionFiles(absoluteRoot, "sourcePath", "source"); + verifyRedistributionFiles(path.join(releaseDir, "win-unpacked"), "packagedPath", "win-unpacked"); + + return { + publish: { + provider: publish.provider, + owner: publish.owner, + repo: publish.repo + }, + latestArtifact: latestFields.path, + missingArtifacts + }; +} + +export function verifyReleaseArchives(rootDir = process.cwd(), options = {}) { + const absoluteRoot = path.resolve(rootDir); + const { version } = readPackageVersion(absoluteRoot); + const releaseDir = resolveReleaseDir(absoluteRoot); + const sevenZipPath = String(options.sevenZipPath || process.env.SEVEN_ZIP_PATH || "7z").trim(); + const commandRunner = options.runCommand || runCommand; + const archiveNames = [ + `${EXPECTED_PRODUCT_NAME}-Setup-${version}.exe`, + `${EXPECTED_PRODUCT_NAME}-${version}-portable.exe` + ]; + const verifiedArchives = []; + + for (const archiveName of archiveNames) { + const archivePath = path.join(releaseDir, archiveName); + requireNonEmptyFile(archivePath, "release archive"); + const extractionRoot = fs.mkdtempSync(path.join(os.tmpdir(), "public-release-archive-")); + try { + extractArchiveTree(archivePath, extractionRoot, sevenZipPath, commandRunner); + verifyArchiveRedistributionFiles(extractionRoot, archiveName); + verifiedArchives.push(archiveName); + } finally { + fs.rmSync(extractionRoot, { recursive: true, force: true }); + } + } + + return { verifiedArchives }; +} + +function parseCliArgs(argv) { + let rootDir = ""; + let verifyArchives = false; + let sevenZipPath = ""; + for (let index = 0; index < argv.length; index += 1) { + const arg = argv[index]; + if (arg === "--verify-archives") { + verifyArchives = true; + } else if (arg === "--seven-zip") { + index += 1; + sevenZipPath = String(argv[index] || "").trim(); + if (!sevenZipPath) { + throw new Error("--seven-zip requires an executable path"); + } + } else if (arg.startsWith("--")) { + throw new Error(`Unknown argument: ${arg}`); + } else if (!rootDir) { + rootDir = arg; + } else { + throw new Error(`Unexpected argument: ${arg}`); + } + } + if (sevenZipPath && !verifyArchives) { + throw new Error("--seven-zip requires --verify-archives"); + } + return { + rootDir: rootDir || process.cwd(), + verifyArchives, + sevenZipPath: sevenZipPath || process.env.SEVEN_ZIP_PATH || "7z" + }; +} + +const isCli = process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url); +if (isCli) { + try { + const args = parseCliArgs(process.argv.slice(2)); + const result = verifyPublicRelease(args.rootDir); + const archiveResult = args.verifyArchives + ? verifyReleaseArchives(args.rootDir, { sevenZipPath: args.sevenZipPath }) + : { verifiedArchives: [] }; + process.stdout.write(`${JSON.stringify({ ...result, ...archiveResult })}\n`); + } catch (error) { + process.stderr.write(`${String(error?.message || error)}\n`); + process.exitCode = 1; + } +} diff --git a/src/main/account-check.ts b/src/main/account-check.ts new file mode 100644 index 0000000..f26dff0 --- /dev/null +++ b/src/main/account-check.ts @@ -0,0 +1,197 @@ +import type { AppSettings, DebridAccountStatus } from "../shared/types"; +import { parseMegaDebridAccounts, type MegaDebridAccountEntry } from "../shared/mega-debrid-accounts"; +import { parseDebridLinkApiKeys, type DebridLinkApiKeyEntry } from "../shared/debrid-link-keys"; +import { logger } from "./logger"; +import { compactErrorText } from "./utils"; + +const MEGA_DEBRID_API = "https://www.mega-debrid.eu/api.php"; +const DEBRID_LINK_API = "https://debrid-link.com/api/v2"; +const CHECK_USER_AGENT = + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0 Safari/537.36"; +const CHECK_TIMEOUT_MS = 20000; + +function timeoutSignal(signal: AbortSignal | undefined, ms: number): AbortSignal { + const timeout = AbortSignal.timeout(ms); + return signal ? AbortSignal.any([signal, timeout]) : timeout; +} + +function parseJsonSafe(text: string): Record | null { + try { + const parsed = JSON.parse(text) as unknown; + return parsed && typeof parsed === "object" ? (parsed as Record) : null; + } catch { + return null; + } +} + +function formatRemaining(premiumUntilMs: number | null, now: number): string { + if (premiumUntilMs == null) { + return "Premium-Status unbekannt"; + } + if (premiumUntilMs <= 0) { + return "Kein Premium"; + } + const remainingMs = premiumUntilMs - now; + if (remainingMs <= 0) { + return "Premium abgelaufen"; + } + const days = Math.floor(remainingMs / (24 * 60 * 60 * 1000)); + if (days >= 1) { + return `Premium noch ${days} Tag${days === 1 ? "" : "e"}`; + } + const hours = Math.max(1, Math.floor(remainingMs / (60 * 60 * 1000))); + return `Premium noch ${hours} Std`; +} + +export async function checkMegaDebridAccount( + account: MegaDebridAccountEntry, + signal?: AbortSignal, + now = Date.now() +): Promise { + const base: DebridAccountStatus = { + accountId: account.id, + provider: "megadebrid", + label: account.label, + maskedLogin: account.maskedLogin, + valid: false, + isPremium: false, + premiumUntilMs: null, + message: "", + checkedAt: now + }; + try { + const url = `${MEGA_DEBRID_API}?action=connectUser&login=${encodeURIComponent(account.login)}&password=${encodeURIComponent(account.password)}`; + const response = await fetch(url, { + headers: { "User-Agent": CHECK_USER_AGENT }, + signal: timeoutSignal(signal, CHECK_TIMEOUT_MS) + }); + const text = await response.text(); + const payload = parseJsonSafe(text); + if (!response.ok || !payload) { + return { ...base, message: `Login fehlgeschlagen (HTTP ${response.status})` }; + } + if (payload.response_code !== "ok") { + const reason = String(payload.response_text || payload.response_code || "Login abgelehnt"); + return { ...base, message: `Ungueltiger Login: ${reason}` }; + } + const vipEndRaw = Number(payload.vip_end || 0); + const premiumUntilMs = Number.isFinite(vipEndRaw) && vipEndRaw > 0 ? vipEndRaw * 1000 : 0; + const isPremium = premiumUntilMs > now; + const email = String(payload.email || "").trim() || undefined; + return { + ...base, + valid: true, + isPremium, + premiumUntilMs, + email, + message: formatRemaining(premiumUntilMs, now) + }; + } catch (error) { + const errText = compactErrorText(error); + const aborted = signal?.aborted || /aborted/i.test(errText); + return { + ...base, + message: aborted ? "Pruefung abgebrochen" : `Pruefung fehlgeschlagen: ${errText}` + }; + } +} + +export async function checkDebridLinkKey( + key: DebridLinkApiKeyEntry, + signal?: AbortSignal, + now = Date.now() +): Promise { + const base: DebridAccountStatus = { + accountId: key.id, + provider: "debridlink", + label: key.label, + maskedLogin: key.masked, + valid: false, + isPremium: false, + premiumUntilMs: null, + message: "", + checkedAt: now + }; + try { + const response = await fetch(`${DEBRID_LINK_API}/account/infos`, { + headers: { + Authorization: `Bearer ${key.token}`, + "User-Agent": CHECK_USER_AGENT + }, + signal: timeoutSignal(signal, CHECK_TIMEOUT_MS) + }); + const text = await response.text(); + const payload = parseJsonSafe(text); + if (!response.ok || !payload) { + if (response.status === 401 || response.status === 403) { + return { ...base, message: "Ungueltiger API-Key (nicht autorisiert)" }; + } + return { ...base, message: `Pruefung fehlgeschlagen (HTTP ${response.status})` }; + } + if (payload.success === false) { + const reason = String(payload.error || "Key abgelehnt"); + return { ...base, message: `Ungueltiger API-Key: ${reason}` }; + } + const value = (payload.value && typeof payload.value === "object" ? payload.value : payload) as Record; + const premiumLeftSec = Number(value.premiumLeft || 0); + const accountType = Number(value.accountType || 0); + const premiumUntilMs = Number.isFinite(premiumLeftSec) && premiumLeftSec > 0 ? now + premiumLeftSec * 1000 : 0; + const isPremium = premiumUntilMs > now || accountType > 0; + const username = String(value.username || "").trim() || undefined; + return { + ...base, + valid: true, + isPremium, + premiumUntilMs: premiumUntilMs > 0 ? premiumUntilMs : (accountType > 0 ? null : 0), + email: username, + message: premiumUntilMs > 0 + ? formatRemaining(premiumUntilMs, now) + : (accountType > 0 ? "Premium aktiv" : "Kein Premium (Free)") + }; + } catch (error) { + const errText = compactErrorText(error); + const aborted = signal?.aborted || /aborted/i.test(errText); + return { + ...base, + message: aborted ? "Pruefung abgebrochen" : `Pruefung fehlgeschlagen: ${errText}` + }; + } +} + +export async function checkAllDebridAccounts( + settings: AppSettings, + signal?: AbortSignal +): Promise { + const now = Date.now(); + const megaAccounts = parseMegaDebridAccounts(settings.megaCredentials || "", settings.megaPassword || ""); + const debridLinkKeys = parseDebridLinkApiKeys(settings.debridLinkApiKeys || ""); + + const taskFns: Array<() => Promise> = [ + ...megaAccounts.map((account) => () => checkMegaDebridAccount(account, signal, now)), + ...debridLinkKeys.map((key) => () => checkDebridLinkKey(key, signal, now)) + ]; + + const results = await runWithConcurrency(taskFns, CHECK_CONCURRENCY); + logger.info( + `Account-Check abgeschlossen: ${results.length} Accounts geprueft ` + + `(${results.filter((r) => r.valid).length} gueltig, ${results.filter((r) => r.isPremium).length} premium)` + ); + return results; +} + +const CHECK_CONCURRENCY = 4; + +async function runWithConcurrency(taskFns: Array<() => Promise>, limit: number): Promise { + const results: T[] = new Array(taskFns.length); + let nextIndex = 0; + const worker = async (): Promise => { + while (nextIndex < taskFns.length) { + const current = nextIndex; + nextIndex += 1; + results[current] = await taskFns[current](); + } + }; + const workers = Array.from({ length: Math.min(limit, taskFns.length) }, () => worker()); + await Promise.all(workers); + return results; +} diff --git a/src/main/account-rotation-log.ts b/src/main/account-rotation-log.ts new file mode 100644 index 0000000..e2c2800 --- /dev/null +++ b/src/main/account-rotation-log.ts @@ -0,0 +1,204 @@ +import fs from "node:fs"; +import { logTimestamp } from "./log-timestamp"; +import path from "node:path"; +import { AsyncLocalStorage } from "node:async_hooks"; +import type { RotationEvent } from "../shared/types"; + +export type RotationItemSink = (event: RotationEvent) => void; +const rotationItemContext = new AsyncLocalStorage(); + +export function runWithRotationItemSink(sink: RotationItemSink, fn: () => Promise): Promise { + return rotationItemContext.run(sink, fn); +} + +type RotationLevel = "INFO" | "WARN" | "ERROR"; + +const ROTATION_EVENT_RING_MAX = 60; +const rotationEventRing: RotationEvent[] = []; +let rotationEventSeq = 0; +let rotationEventListener: ((event: RotationEvent) => void) | null = null; + +export function setRotationEventListener(listener: ((event: RotationEvent) => void) | null): void { + rotationEventListener = listener; +} + +export function getRecentRotationEvents(limit = ROTATION_EVENT_RING_MAX): RotationEvent[] { + const slice = rotationEventRing.slice(-limit); + slice.reverse(); + return slice; +} + +function isUiRelevantRotationEvent(event: string): boolean { + return event !== "TEST"; +} + +function pushRotationEvent( + level: RotationLevel, + provider: string, + accountLabel: string, + event: string, + fields?: Record, + at = Date.now() +): void { + rotationEventSeq += 1; + const entry: RotationEvent = { + id: `rot_${at}_${rotationEventSeq}`, + at, + level, + provider, + accountLabel, + event, + reason: fields && fields.reason != null ? String(fields.reason) : undefined, + category: fields && fields.category != null ? String(fields.category) : undefined, + cooldownSec: fields && fields.cooldownSec != null ? Number(fields.cooldownSec) || 0 : undefined, + next: fields && fields.next != null ? String(fields.next) : undefined + }; + + const itemSink = rotationItemContext.getStore(); + if (itemSink) { + try { + itemSink(entry); + } catch { + } + } + + if (!isUiRelevantRotationEvent(event)) { + return; + } + rotationEventRing.push(entry); + if (rotationEventRing.length > ROTATION_EVENT_RING_MAX) { + rotationEventRing.splice(0, rotationEventRing.length - ROTATION_EVENT_RING_MAX); + } + if (rotationEventListener) { + try { + rotationEventListener(entry); + } catch { + } + } +} + +const ROTATION_LOG_MAX_FILE_BYTES = Number(process.env.RD_ACCOUNT_ROTATION_LOG_MAX_BYTES || 5 * 1024 * 1024); +const ROTATION_LOG_RETENTION_DAYS = Number(process.env.RD_ACCOUNT_ROTATION_LOG_RETENTION_DAYS || 14); + +let rotationLogPath: string | null = null; + +function sanitizeFieldValue(value: unknown): string { + if (value === undefined || value === null) { + return ""; + } + if (typeof value === "string") { + return value.replace(/\r?\n/g, "\\n"); + } + if (typeof value === "number" || typeof value === "boolean") { + return String(value); + } + try { + return JSON.stringify(value).replace(/\r?\n/g, "\\n"); + } catch { + return String(value); + } +} + +function formatFields(fields?: Record): string { + if (!fields) { + return ""; + } + const parts = Object.entries(fields) + .filter(([, value]) => value !== undefined && value !== null && sanitizeFieldValue(value) !== "") + .map(([key, value]) => `${key}=${sanitizeFieldValue(value)}`); + return parts.length > 0 ? ` | ${parts.join(" | ")}` : ""; +} + +function rotateIfNeeded(filePath: string): void { + try { + const stat = fs.statSync(filePath); + if (stat.size < ROTATION_LOG_MAX_FILE_BYTES) { + return; + } + const backup = `${filePath}.old`; + try { + fs.rmSync(backup, { force: true }); + } catch { + } + fs.renameSync(filePath, backup); + } catch { + } +} + +function cleanupOldBackup(filePath: string): void { + const backup = `${filePath}.old`; + try { + const stat = fs.statSync(backup); + const cutoff = Date.now() - ROTATION_LOG_RETENTION_DAYS * 24 * 60 * 60 * 1000; + if (stat.mtimeMs < cutoff) { + fs.rmSync(backup, { force: true }); + } + } catch { + } +} + +export function initAccountRotationLog(baseDir: string): void { + rotationLogPath = path.join(baseDir, "account-rotation.log"); + try { + fs.mkdirSync(path.dirname(rotationLogPath), { recursive: true }); + cleanupOldBackup(rotationLogPath); + if (!fs.existsSync(rotationLogPath)) { + fs.writeFileSync(rotationLogPath, "", "utf8"); + } + rotateIfNeeded(rotationLogPath); + if (!fs.existsSync(rotationLogPath)) { + fs.writeFileSync(rotationLogPath, "", "utf8"); + } + fs.appendFileSync( + rotationLogPath, + `=== Account-Rotation Log Start: ${logTimestamp()} ===\n`, + "utf8" + ); + } catch { + rotationLogPath = null; + } +} + +export function logAccountRotation( + level: RotationLevel, + provider: string, + accountLabel: string, + event: string, + fields?: Record +): void { + pushRotationEvent(level, provider, accountLabel, event, fields); + if (!rotationLogPath) { + return; + } + try { + rotateIfNeeded(rotationLogPath); + if (!fs.existsSync(rotationLogPath)) { + fs.writeFileSync(rotationLogPath, "", "utf8"); + } + const head = `${logTimestamp()} [${level}] ${provider} | ${accountLabel} | ${event}`; + fs.appendFileSync(rotationLogPath, `${head}${formatFields(fields)}\n`, "utf8"); + } catch { + } +} + +export function getAccountRotationLogPath(): string | null { + if (!rotationLogPath) { + return null; + } + return fs.existsSync(rotationLogPath) ? rotationLogPath : null; +} + +export function shutdownAccountRotationLog(): void { + if (!rotationLogPath) { + return; + } + try { + fs.appendFileSync( + rotationLogPath, + `=== Account-Rotation Log Ende: ${logTimestamp()} ===\n`, + "utf8" + ); + } catch { + } + rotationLogPath = null; +} diff --git a/src/main/all-debrid-web.ts b/src/main/all-debrid-web.ts new file mode 100644 index 0000000..7517666 --- /dev/null +++ b/src/main/all-debrid-web.ts @@ -0,0 +1,482 @@ +import { BrowserWindow, session } from "electron"; +import { AllDebridHostInfo } from "../shared/types"; +import { UnrestrictedLink } from "./realdebrid"; +import { filenameFromUrl, sleep } from "./utils"; + +const ALLDEBRID_BASE_URL = "https://alldebrid.com"; +const ALLDEBRID_LOGIN_URL = `${ALLDEBRID_BASE_URL}/register/?from=de`; +const ALLDEBRID_SERVICE_URL = `${ALLDEBRID_BASE_URL}/service.php`; +const ALLDEBRID_SERVICE_REFERER = `${ALLDEBRID_BASE_URL}/service/?from=de`; +const ALLDEBRID_DELAYED_URL = `${ALLDEBRID_BASE_URL}/internalapi/v4/link/delayed`; +const ALLDEBRID_STATUS_URL = `${ALLDEBRID_BASE_URL}/status/`; +const ALLDEBRID_PERSISTENT_PARTITION = "persist:alldebrid-web"; +const ALLDEBRID_TRANSIENT_PARTITION = "alldebrid-web"; +const ALLDEBRID_USER_AGENT = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/133.0.0.0 Safari/537.36"; + +type DelayedStatusPayload = { + status: number; + link: string; + timeLeft: number; +}; + +type GenerateOutcome = + | { kind: "success"; value: UnrestrictedLink } + | { kind: "login_required" }; + +function abortError(): Error { + return new Error("aborted:alldebrid-web"); +} + +function withTimeoutSignal(signal: AbortSignal | undefined, timeoutMs: number): AbortSignal { + const timeoutSignal = AbortSignal.timeout(timeoutMs); + if (!signal) { + return timeoutSignal; + } + return AbortSignal.any([signal, timeoutSignal]); +} + +function throwIfAborted(signal?: AbortSignal): void { + if (signal?.aborted) { + throw abortError(); + } +} + +async function sleepWithSignal(ms: number, signal?: AbortSignal): Promise { + if (!signal) { + await sleep(ms); + return; + } + if (signal.aborted) { + throw abortError(); + } + + await new Promise((resolve, reject) => { + let timer: NodeJS.Timeout | null = setTimeout(() => { + timer = null; + signal.removeEventListener("abort", onAbort); + resolve(); + }, Math.max(0, ms)); + + const onAbort = (): void => { + if (timer) { + clearTimeout(timer); + timer = null; + } + signal.removeEventListener("abort", onAbort); + reject(abortError()); + }; + + signal.addEventListener("abort", onAbort, { once: true }); + }); +} + +function asRecord(value: unknown): Record | null { + if (!value || typeof value !== "object" || Array.isArray(value)) { + return null; + } + return value as Record; +} + +function pickString(payload: Record | null, keys: string[]): string { + if (!payload) { + return ""; + } + for (const key of keys) { + const value = payload[key]; + if (typeof value === "string" && value.trim()) { + return value.trim(); + } + } + return ""; +} + +function pickNumber(payload: Record | null, keys: string[]): number | null { + if (!payload) { + return null; + } + for (const key of keys) { + const value = Number(payload[key] ?? NaN); + if (Number.isFinite(value) && value >= 0) { + return Math.floor(value); + } + } + return null; +} + +function parseJson(text: string): Record | null { + try { + return asRecord(JSON.parse(text) as unknown); + } catch { + return null; + } +} + +function normalizeHostName(value: string): string { + return String(value || "").replace(/[^a-z0-9]+/gi, "").toLowerCase(); +} + +function toHostStateFromIcon(url: string): AllDebridHostInfo["state"] { + const normalized = String(url || "").toLowerCase(); + if (normalized.includes("up.gif")) { + return "up"; + } + if (normalized.includes("down.gif")) { + return "down"; + } + if (normalized.includes("not.tracked")) { + return "not_tracked"; + } + return "unknown"; +} + +function toHostStatusLabel(state: AllDebridHostInfo["state"]): string { + if (state === "up") { + return "Verfügbar"; + } + if (state === "down") { + return "Unverfügbar"; + } + if (state === "not_tracked") { + return "Nicht getrackt"; + } + return "Unbekannt"; +} + +function extractHostInfoFromStatusPage(html: string, host: string): AllDebridHostInfo | null { + const wanted = normalizeHostName(host); + const rowRegex = /\s*]*>[\s\S]*?]*alt=['"]([^'"]+)['"][^>]*>[\s\S]*?<\/td>\s*]*class=['"]comparatif_content['"][^>]*>[\s\S]*?]*src=['"]([^'"]+)['"][^>]*>[\s\S]*?\((?:]*data-fdate=['"](\d+)['"][^>]*><\/span>|([^<)]*))\)/gi; + + for (let match = rowRegex.exec(html); match; match = rowRegex.exec(html)) { + const hostAlt = normalizeHostName(match[1] || ""); + if (hostAlt !== wanted) { + continue; + } + + const state = toHostStateFromIcon(match[2] || ""); + const lastCheckedSeconds = Number(match[3] ?? NaN); + return { + host, + source: "web", + state, + statusLabel: toHostStatusLabel(state), + fetchedAt: Date.now(), + lastCheckedAt: Number.isFinite(lastCheckedSeconds) ? lastCheckedSeconds * 1000 : null, + quota: null, + quotaMax: null, + quotaType: "", + limitSimuDl: null, + note: "Quota und Simultan-Slots sind per Web-Login nicht öffentlich verfügbar." + }; + } + + return null; +} + +export class AllDebridWebFallback { + private queue: Promise = Promise.resolve(); + + private loginWindow: BrowserWindow | null = null; + + private loginWindowPartition = ""; + + private getRememberSession: () => boolean; + + public constructor(getRememberSession: () => boolean) { + this.getRememberSession = getRememberSession; + } + + public async unrestrict(link: string, signal?: AbortSignal): Promise { + const overallSignal = withTimeoutSignal(signal, 10 * 60 * 1000); + return this.runExclusive(async () => { + throwIfAborted(overallSignal); + if (!String(link || "").trim()) { + return null; + } + + const initial = await this.generate(link, overallSignal); + if (initial.kind === "success") { + return initial.value; + } + return this.waitForLoginAndGenerate(link, overallSignal); + }, overallSignal); + } + + public async openLoginWindow(): Promise { + const window = await this.ensureLoginWindow(); + if (window.isMinimized()) { + window.restore(); + } + window.show(); + window.focus(); + } + + public async getHostInfo(host: string): Promise { + const currentSession = session.fromPartition(this.getPartition()); + const response = await currentSession.fetch(ALLDEBRID_STATUS_URL, { + headers: { + Accept: "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + Referer: ALLDEBRID_SERVICE_REFERER, + "User-Agent": ALLDEBRID_USER_AGENT + }, + signal: withTimeoutSignal(undefined, 30_000) + }); + const text = await response.text(); + if (!response.ok) { + throw new Error(`AllDebrid Web Status HTTP ${response.status}`); + } + if (!/id=['"]statusContainer['"]/i.test(text)) { + throw new Error("AllDebrid Web-Status nicht verfügbar. Bitte zuerst im AllDebrid-Fenster einloggen."); + } + const info = extractHostInfoFromStatusPage(text, host); + if (!info) { + throw new Error(`AllDebrid Web-Status für ${host} nicht gefunden`); + } + return info; + } + + public async clearSessions(): Promise { + this.disposeLoginWindow(); + for (const partition of [ALLDEBRID_PERSISTENT_PARTITION, ALLDEBRID_TRANSIENT_PARTITION]) { + const currentSession = session.fromPartition(partition); + try { + await currentSession.clearStorageData({ + storages: ["cookies", "indexdb", "localstorage", "serviceworkers", "cachestorage"] + }); + } catch { + } + try { + await currentSession.clearCache(); + } catch { + } + } + } + + public dispose(): void { + this.disposeLoginWindow(); + } + + private getPartition(): string { + return this.getRememberSession() ? ALLDEBRID_PERSISTENT_PARTITION : ALLDEBRID_TRANSIENT_PARTITION; + } + + private disposeLoginWindow(): void { + const current = this.loginWindow; + this.loginWindow = null; + this.loginWindowPartition = ""; + if (current && !current.isDestroyed()) { + current.close(); + } + } + + private async runExclusive(job: () => Promise, signal?: AbortSignal): Promise { + const queuedAt = Date.now(); + const queueWaitTimeoutMs = 90_000; + const guardedJob = async (): Promise => { + throwIfAborted(signal); + const waited = Date.now() - queuedAt; + if (waited > queueWaitTimeoutMs) { + throw new Error(`AllDebrid-Web Queue-Timeout (${Math.floor(waited / 1000)}s gewartet)`); + } + return job(); + }; + const run = this.queue.then(guardedJob, guardedJob); + this.queue = run.then(() => undefined, () => undefined); + return run; + } + + private async ensureLoginWindow(): Promise { + const partition = this.getPartition(); + const existing = this.loginWindow; + if (existing && !existing.isDestroyed() && this.loginWindowPartition === partition) { + return existing; + } + + if (existing && !existing.isDestroyed()) { + existing.close(); + } + + const window = new BrowserWindow({ + width: 1120, + height: 900, + minWidth: 980, + minHeight: 760, + autoHideMenuBar: true, + title: "AllDebrid Web-Login", + webPreferences: { + partition, + contextIsolation: true, + nodeIntegration: false + } + }); + window.setMenuBarVisibility(false); + window.on("closed", () => { + if (this.loginWindow === window) { + this.loginWindow = null; + this.loginWindowPartition = ""; + } + }); + this.loginWindow = window; + this.loginWindowPartition = partition; + await window.loadURL(ALLDEBRID_LOGIN_URL); + return window; + } + + private async postForm( + url: string, + body: URLSearchParams, + referer: string, + signal?: AbortSignal + ): Promise<{ response: Response; text: string }> { + const currentSession = session.fromPartition(this.getPartition()); + const response = await currentSession.fetch(url, { + method: "POST", + headers: { + Accept: "application/json, text/javascript, */*; q=0.01", + "Content-Type": "application/x-www-form-urlencoded; charset=UTF-8", + Origin: ALLDEBRID_BASE_URL, + Referer: referer, + "User-Agent": ALLDEBRID_USER_AGENT, + "X-Requested-With": "XMLHttpRequest" + }, + body: body.toString(), + signal: withTimeoutSignal(signal, 30_000) + }); + const text = await response.text(); + return { response, text }; + } + + private async generate(link: string, signal?: AbortSignal): Promise { + throwIfAborted(signal); + const body = new URLSearchParams({ + link, + nb: "0", + json: "true", + pw: "" + }); + const { response, text } = await this.postForm(ALLDEBRID_SERVICE_URL, body, ALLDEBRID_SERVICE_REFERER, signal); + if (!response.ok) { + throw new Error(`AllDebrid Web HTTP ${response.status}`); + } + + const trimmed = text.trim(); + if (trimmed === "login") { + return { kind: "login_required" }; + } + + const payload = parseJson(trimmed); + if (!payload) { + throw new Error("AllDebrid Web lieferte keine JSON-Antwort"); + } + + const errorText = pickString(payload, ["error"]); + if (errorText) { + if (errorText.toLowerCase() === "premium") { + throw new Error("AllDebrid Web: Premium erforderlich"); + } + throw new Error(`AllDebrid Web: ${errorText}`); + } + + const directUrl = pickString(payload, ["link"]); + const fileName = pickString(payload, ["filename"]); + const fileSize = pickNumber(payload, ["filesize"]); + if (directUrl) { + return { + kind: "success", + value: { + directUrl, + fileName: fileName || filenameFromUrl(directUrl) || filenameFromUrl(link), + fileSize, + retriesUsed: 0 + } + }; + } + + const delayedId = payload.delayed; + if (delayedId !== undefined && delayedId !== null && delayedId !== false && String(delayedId).trim()) { + const delayed = await this.waitForDelayedLink(String(delayedId).trim(), signal); + return { + kind: "success", + value: { + directUrl: delayed.link, + fileName: fileName || filenameFromUrl(delayed.link) || filenameFromUrl(link), + fileSize: fileSize, + retriesUsed: 0 + } + }; + } + + if (Array.isArray(payload.streams) && payload.streams.length > 0) { + throw new Error("AllDebrid Web: Streaming-Auswahl wird derzeit nicht unterstützt"); + } + + throw new Error("AllDebrid Web: Antwort ohne Download-Link"); + } + + private async waitForDelayedLink(delayedId: string, signal?: AbortSignal): Promise { + for (let attempt = 1; attempt <= 120; attempt += 1) { + throwIfAborted(signal); + const body = new URLSearchParams({ id: delayedId }); + const { response, text } = await this.postForm(ALLDEBRID_DELAYED_URL, body, ALLDEBRID_SERVICE_REFERER, signal); + if (!response.ok) { + throw new Error(`AllDebrid Web delayed HTTP ${response.status}`); + } + const payload = parseJson(text.trim()); + const data = asRecord(payload?.data); + if (pickString(payload, ["status"]).toLowerCase() !== "success" || !data) { + throw new Error("AllDebrid Web: Delayed-Status ungültig"); + } + + const status = Number(data.status ?? NaN); + if (!Number.isFinite(status)) { + throw new Error("AllDebrid Web: Delayed-Status ohne Status"); + } + + if (status >= 2) { + const link = pickString(data, ["link"]); + if (!link) { + throw new Error("AllDebrid Web: Delayed-Link fehlt"); + } + return { + status, + link, + timeLeft: Math.max(0, Number(data.time_left ?? 0) || 0) + }; + } + + const timeLeft = Math.max(0, Number(data.time_left ?? 0) || 0); + const delayMs = timeLeft > 0 ? Math.min(5_000, Math.max(1_500, timeLeft * 250)) : 2_000; + await sleepWithSignal(delayMs, signal); + } + + throw new Error("AllDebrid Web: Delayed-Link Timeout"); + } + + private async waitForLoginAndGenerate(link: string, signal?: AbortSignal): Promise { + const window = await this.ensureLoginWindow(); + if (window.isMinimized()) { + window.restore(); + } + window.show(); + window.focus(); + + const startedAt = Date.now(); + while (Date.now() - startedAt < 10 * 60 * 1000) { + throwIfAborted(signal); + if (window.isDestroyed()) { + throw new Error("AllDebrid Web-Login abgebrochen"); + } + + const outcome = await this.generate(link, signal); + if (outcome.kind === "success") { + if (!window.isDestroyed()) { + window.close(); + } + return outcome.value; + } + + await sleepWithSignal(1_500, signal); + } + + throw new Error("AllDebrid Web-Login Timeout"); + } +} diff --git a/src/main/app-controller.ts b/src/main/app-controller.ts new file mode 100644 index 0000000..d651991 --- /dev/null +++ b/src/main/app-controller.ts @@ -0,0 +1,973 @@ +import path from "node:path"; +import os from "node:os"; +import v8 from "node:v8"; +import { app } from "electron"; +import { getDebridLinkApiKeyIds } from "../shared/debrid-link-keys"; +import { + AddLinksPayload, + AllDebridHostInfo, + AppSettings, + DebridAccountStatus, + DebridProvider, + DuplicatePolicy, + EnableRemoteDiagnosticsInput, + HistoryEntry, + PackagePriority, + ParsedPackageInput, + RemoteDiagnosticsInfo, + SessionStats, + StartConflictEntry, + StartConflictResolutionResult, + UiSnapshot, + UpdateCheckResult, + UpdateInstallProgress, + UpdateInstallResult +} from "../shared/types"; +import { resetDebridLinkApiKeyDailyUsage, resetProviderDailyUsage } from "../shared/provider-daily-limits"; +import { importDlcContainers } from "./container"; +import { APP_VERSION } from "./constants"; +import { DownloadManager } from "./download-manager"; +import { fetchAllDebridHostInfo, fetchDebridLinkHostLimits } from "./debrid"; +import { checkAllDebridAccounts, checkMegaDebridAccount } from "./account-check"; +import { parseMegaDebridAccounts } from "../shared/mega-debrid-accounts"; +import { parseCollectorInput } from "./link-parser"; +import { configureLogger, getLogFilePath, logger } from "./logger"; +import { AllDebridWebFallback } from "./all-debrid-web"; +import { BestDebridWebFallback } from "./bestdebrid-web"; +import { RealDebridWebFallback } from "./realdebrid-web"; +import { getItemLogPath, initItemLogs, shutdownItemLogs } from "./item-log"; +import { getPackageLogPath, initPackageLogs, shutdownPackageLogs } from "./package-log"; +import { initSessionLog, getSessionLogPath, shutdownSessionLog } from "./session-log"; +import { MegaWebFallback } from "./mega-web-fallback"; +import { addHistoryEntry, addHistoryEntryForRetention, cancelPendingAsyncSaves, clearHistory, createStoragePaths, loadHistory, loadHistoryForRetention, loadSessionWithStatus, loadSettings, normalizeHistoryEntry, normalizeLoadedSession, normalizeLoadedSessionTransientFields, normalizeSettings, removeHistoryEntry, resetHistoryForRetention, saveHistory, saveSession, saveSettings } from "./storage"; +import { abortActiveUpdateDownload, checkGitHubUpdate, installLatestUpdate } from "./update"; +import { runInstallWithResume } from "./update-install-flow"; +import { rotateDebugToken, startDebugServer, stopDebugServer, restartDebugServer, getDebugServerRuntimeStatus, getActiveDebugToken, getDebugAllowlist, writeDebugServerConfig, clearDebugToken } from "./debug-server"; +import { encodeConnectionCode, loadRemoteMeta, saveRemoteMeta } from "./connection-code"; +import { encryptBackup, decryptBackup } from "./backup-crypto"; +import { buildBackupPayload, planBackupImport, resolveRemoteDiagnosticsRestore, BackupRemoteDiagnostics } from "./backup-payload"; +import { getAuditLogPath, initAuditLog, logAuditEvent, shutdownAuditLog } from "./audit-log"; +import { initAccountRotationLog, shutdownAccountRotationLog } from "./account-rotation-log"; +import { initConversionLog, shutdownConversionLog } from "./conversion-trace"; +import { runStartupHealthCheck } from "./startup-health-check"; +import { getDebugSetupCheck } from "./debug-setup"; +import { buildLinkExportSelection, serializeLinkExportText } from "./link-export"; +import { getRenameLogPath, initRenameLog, shutdownRenameLog } from "./rename-log"; +import { getDesktopRenameLogPath, initDesktopRenameLog, shutdownDesktopRenameLog } from "./desktop-rename-log"; +import { buildAccountSummary, diffAccountSummary } from "./support-data"; +import { buildSupportBundle, getSupportBundleDefaultFileName } from "./support-bundle"; +import { getTraceConfig, getTraceLogPath, initTraceLog, logTraceEvent, setTraceEnabled, shutdownTraceLog } from "./trace-log"; +import type { DebugSetupCheckResult, SupportTraceConfig } from "../shared/types"; + +function sanitizeSettingsPatch(partial: Partial): Partial { + const entries = Object.entries(partial || {}).filter(([, value]) => value !== undefined); + return Object.fromEntries(entries) as Partial; +} + +function settingsFingerprint(settings: AppSettings): string { + return JSON.stringify(normalizeSettings(settings)); +} + +export class AppController { + private settings: AppSettings; + + private manager: DownloadManager; + + private megaWebFallback: MegaWebFallback; + + private realDebridWebFallback: RealDebridWebFallback; + + private allDebridWebFallback: AllDebridWebFallback; + + private bestDebridWebFallback: BestDebridWebFallback; + + private lastUpdateCheck: UpdateCheckResult | null = null; + + private lastUpdateCheckAt = 0; + + private storagePaths = createStoragePaths(path.join(app.getPath("userData"), "runtime")); + + private onStateHandler: ((snapshot: UiSnapshot) => void) | null = null; + + private autoResumePending = false; + private runtimeStatsTimer: NodeJS.Timeout | null = null; + private lastMemoryWarnAt = 0; + + public constructor() { + configureLogger(this.storagePaths.baseDir); + initSessionLog(this.storagePaths.baseDir); + initPackageLogs(this.storagePaths.baseDir); + initItemLogs(this.storagePaths.baseDir); + initAuditLog(this.storagePaths.baseDir); + initAccountRotationLog(this.storagePaths.baseDir); + initConversionLog(this.storagePaths.baseDir); + initRenameLog(this.storagePaths.baseDir); + let desktopDir: string | null = null; + try { + desktopDir = app.getPath("desktop"); + } catch { + desktopDir = null; + } + initDesktopRenameLog(desktopDir); + initTraceLog(this.storagePaths.baseDir); + this.settings = loadSettings(this.storagePaths); + resetHistoryForRetention(this.storagePaths, this.settings.historyRetentionMode); + const loadResult = loadSessionWithStatus(this.storagePaths); + const session = loadResult.session; + this.megaWebFallback = new MegaWebFallback(() => ({ + login: this.settings.megaLogin, + password: this.settings.megaPassword + })); + this.realDebridWebFallback = new RealDebridWebFallback(() => this.settings.rememberToken); + this.allDebridWebFallback = new AllDebridWebFallback(() => this.settings.rememberToken); + this.bestDebridWebFallback = new BestDebridWebFallback(() => this.settings.rememberToken); + this.manager = new DownloadManager(this.settings, session, this.storagePaths, { + megaWebUnrestrict: (link: string, signal?: AbortSignal, account?: { login: string; password: string }) => this.megaWebFallback.unrestrict(link, signal, account), + allDebridWebUnrestrict: (link: string, signal?: AbortSignal) => this.allDebridWebFallback.unrestrict(link, signal), + realDebridWebUnrestrict: (link: string, signal?: AbortSignal) => this.realDebridWebFallback.unrestrict(link, signal), + bestDebridWebUnrestrict: (link: string, signal?: AbortSignal) => this.bestDebridWebFallback.unrestrict(link, signal), + invalidateMegaSession: () => this.megaWebFallback.invalidateSession(), + protectEmptyClobber: loadResult.status === "empty-unreadable", + onHistoryEntry: (entry: HistoryEntry) => { + addHistoryEntryForRetention(this.storagePaths, this.settings.historyRetentionMode, entry, this.historyLimits()); + } + }); + this.manager.on("state", (snapshot: UiSnapshot) => { + this.onStateHandler?.(snapshot); + }); + logger.info(`App gestartet v${APP_VERSION}`); + logger.info(`Log-Datei: ${getLogFilePath()}`); + logAuditEvent("INFO", "App gestartet", { + appVersion: APP_VERSION, + runtimeDir: this.storagePaths.baseDir + }); + try { + const report = runStartupHealthCheck(this.settings, this.storagePaths); + if (report.errorCount > 0 || report.warnCount > 0) { + logger.warn(`Health-Check: ${report.errorCount} Fehler, ${report.warnCount} Warnungen, ${report.infoCount} Info`); + } else { + logger.info(`Health-Check: alles OK (${report.infoCount} Info)`); + } + for (const finding of report.findings) { + const line = finding.hint + ? `Health-Check [${finding.code}]: ${finding.message} — ${finding.hint}` + : `Health-Check [${finding.code}]: ${finding.message}`; + if (finding.severity === "ERROR") { + logger.error(line); + } else if (finding.severity === "WARN") { + logger.warn(line); + } else { + logger.info(line); + } + if (finding.severity !== "INFO") { + logAuditEvent(finding.severity, `Health-Check: ${finding.code}`, { + message: finding.message, + hint: finding.hint || "" + }); + } + } + } catch (err) { + logger.warn(`Health-Check uebersprungen (Fehler): ${String((err as Error).message || err)}`); + } + startDebugServer(this.manager, this.storagePaths.baseDir); + this.runtimeStatsTimer = setInterval(() => { + this.manager.persistRuntimeStats(); + this.settings = this.manager.getSettings(); + this.checkMemoryPressure(); + }, 60_000); + this.runtimeStatsTimer.unref?.(); + + if (this.settings.autoResumeOnStart) { + const snapshot = this.manager.getSnapshot(); + const hasPending = Object.values(snapshot.session.items).some((item) => item.status === "queued" || item.status === "reconnect_wait"); + if (hasPending && this.hasAnyProviderToken(this.settings)) { + if (this.onStateHandler) { + this.beginAutoResume(); + } else { + this.autoResumePending = true; + logger.info("Auto-Resume beim Start vorgemerkt"); + } + } + } + } + + // Early-warning for OOM on a long-running process. Measured against the V8 + // heap_size_limit (the real ceiling at which the process is killed), NOT against + // heapTotal: V8 routinely runs near-full of its current heapTotal just before it + // grows it, so a heapUsed/heapTotal ratio would cry wolf and — since every WARN + // now feeds the error ring — crowd real failures out. Throttled to 1 warning per + // 5 min so a genuine sustained-pressure run does not spam the log/ring. + private checkMemoryPressure(): void { + try { + const mem = process.memoryUsage(); + const heapLimit = v8.getHeapStatistics().heap_size_limit; + const ratio = heapLimit > 0 ? mem.heapUsed / heapLimit : 0; + if (ratio < 0.9) { + return; + } + const now = Date.now(); + if (now - this.lastMemoryWarnAt < 5 * 60_000) { + return; + } + this.lastMemoryWarnAt = now; + const mb = (bytes: number): number => Math.round(bytes / 1048576); + logger.warn( + `Speicherdruck: heapUsed=${mb(mem.heapUsed)}MB von Limit ${mb(heapLimit)}MB ` + + `(${Math.round(ratio * 100)}%), heapTotal=${mb(mem.heapTotal)}MB, rss=${mb(mem.rss)}MB, external=${mb(mem.external)}MB` + ); + } catch { + } + } + + private hasAnyProviderToken(settings: AppSettings): boolean { + return Boolean( + settings.token.trim() + || settings.realDebridUseWebLogin + || (settings.megaLogin.trim() && settings.megaPassword.trim()) + || settings.bestToken.trim() + || settings.bestDebridUseWebLogin + || settings.allDebridUseWebLogin + || settings.allDebridToken.trim() + || (settings.ddownloadLogin.trim() && settings.ddownloadPassword.trim()) + || settings.oneFichierApiKey.trim() + ); + } + + public get onState(): ((snapshot: UiSnapshot) => void) | null { + return this.onStateHandler; + } + + public set onState(handler: ((snapshot: UiSnapshot) => void) | null) { + this.onStateHandler = handler; + if (handler) { + handler(this.manager.getSnapshot()); + if (this.autoResumePending) { + this.autoResumePending = false; + this.beginAutoResume(); + } else { + this.manager.triggerIdleExtractions(); + } + } + } + + private beginAutoResume(): void { + void this.manager.getStartConflicts().then((conflicts) => { + const excludePackageIds = new Set(conflicts.map((conflict) => conflict.packageId)); + if (excludePackageIds.size > 0) { + const names = conflicts.map((conflict) => conflict.packageName).join(", "); + logger.info(`Auto-Resume: ${excludePackageIds.size} Paket(e) mit Start-Konflikt zurückgehalten (${names}); übrige Pakete starten`); + } else { + logger.info("Auto-Resume beim Start aktiviert (keine Start-Konflikte)"); + } + void this.manager.start(excludePackageIds.size > 0 ? { excludePackageIds } : undefined) + .catch((err) => logger.warn(`Auto-Resume Start Fehler: ${String(err)}`)); + }).catch((err) => logger.warn(`Auto-Resume Konflikt-Check Fehler: ${String(err)}`)); + } + + public getSnapshot(): UiSnapshot { + return this.manager.getSnapshot(); + } + + public getVersion(): string { + return APP_VERSION; + } + + public getSettings(): AppSettings { + return this.settings; + } + + public getAuditLogPath(): string | null { + return getAuditLogPath(); + } + + public getRenameLogPath(): string | null { + return getRenameLogPath(); + } + + public getDesktopRenameLogPath(): string | null { + return getDesktopRenameLogPath(); + } + + public getTraceLogPath(): string | null { + return getTraceLogPath(); + } + + public getTraceConfig(): SupportTraceConfig { + return getTraceConfig(); + } + + public rotateDebugToken(): { path: string; token: string } { + const rotated = rotateDebugToken(this.storagePaths.baseDir); + this.audit("WARN", "Debug-Token rotiert", { path: rotated.path }); + return rotated; + } + + private getSuggestedRemoteHosts(): string[] { + const hosts: string[] = []; + try { + const interfaces = os.networkInterfaces(); + for (const entry of Object.values(interfaces)) { + for (const net of entry || []) { + if (net.family === "IPv4" && !net.internal && net.address) { + hosts.push(net.address); + } + } + } + } catch { + } + return [...new Set(hosts)]; + } + + public getRemoteDiagnostics(): RemoteDiagnosticsInfo { + const status = getDebugServerRuntimeStatus(); + const meta = loadRemoteMeta(this.storagePaths.baseDir); + const token = getActiveDebugToken(); + const allowlist = getDebugAllowlist(); + const suggestedHosts = this.getSuggestedRemoteHosts(); + const host = meta.publicHost + || (status.localOnly ? "127.0.0.1" : (suggestedHosts[0] || status.host)); + const code = (status.hasToken && token && host) + ? encodeConnectionCode({ host, port: status.port, token, name: meta.name || undefined }) + : null; + return { + status, + code, + publicHost: meta.publicHost, + name: meta.name, + allowlist, + suggestedHosts + }; + } + + public async enableRemoteDiagnostics(input: EnableRemoteDiagnosticsInput): Promise { + const baseDir = this.storagePaths.baseDir; + const port = input.port && Number.isInteger(input.port) && input.port >= 1024 && input.port <= 65535 + ? input.port + : 9868; + const bindHost = input.hostMode === "network" ? "0.0.0.0" : "127.0.0.1"; + const allowlist = (input.allowlist || []).map((entry) => entry.trim()).filter((entry) => entry.length > 0); + if (input.hostMode === "network" && allowlist.length === 0) { + throw new Error("Netzwerk-Freigabe erfordert mindestens eine erlaubte IP oder CIDR in der Allowlist."); + } + let token = getActiveDebugToken(); + if (!token || input.rotateToken) { + token = rotateDebugToken(baseDir).token; + } + writeDebugServerConfig({ host: bindHost, port, allowlist }); + saveRemoteMeta(baseDir, { publicHost: (input.publicHost || "").trim(), name: (input.name || "").trim() }); + await restartDebugServer(); + this.audit("WARN", "Ferndiagnose aktiviert", { + host: bindHost, + port, + allowlistCount: allowlist.length, + localOnly: input.hostMode === "local" + }); + return this.getRemoteDiagnostics(); + } + + public async disableRemoteDiagnostics(): Promise { + clearDebugToken(); + await restartDebugServer(); + this.audit("WARN", "Ferndiagnose deaktiviert (Token entfernt)"); + return this.getRemoteDiagnostics(); + } + + public async rotateRemoteDiagnosticsToken(): Promise { + rotateDebugToken(this.storagePaths.baseDir); + await restartDebugServer(); + this.audit("WARN", "Ferndiagnose-Token rotiert"); + return this.getRemoteDiagnostics(); + } + + private restoreRemoteDiagnosticsFromBackup(section: unknown, restartNow: boolean): void { + const restore = resolveRemoteDiagnosticsRestore(section); + if (!restore) { + return; + } + writeDebugServerConfig({ host: restore.host, port: restore.port, allowlist: restore.allowlist }); + if (restartNow) { + void restartDebugServer().catch(() => {}); + } + this.audit("INFO", "Ferndiagnose-Einstellungen aus Backup wiederhergestellt", { + port: restore.port ?? null, + allowlistCount: restore.allowlist?.length ?? 0, + host: restore.host ?? "unveraendert", + restartNow + }); + } + + public getDebugSetupCheck(): DebugSetupCheckResult { + return getDebugSetupCheck(this.storagePaths.baseDir); + } + + private audit(level: "INFO" | "WARN" | "ERROR", message: string, fields?: Record): void { + logAuditEvent(level, message, fields); + logTraceEvent(level, "audit", message, fields); + } + + public setTraceEnabled(enabled: boolean, note = "", durationMs?: number): SupportTraceConfig { + const next = setTraceEnabled(enabled, note, durationMs); + this.audit("INFO", enabled ? "Support-Trace aktiviert" : "Support-Trace deaktiviert", { note }); + return next; + } + + // Carry the live, runtime-maintained usage/status counters onto a settings + // object about to be applied, so they are never rolled back to a stale snapshot. + // All-time totals take the max; daily/total usage and account statuses are taken + // live; per-key Debrid-Link usage is filtered to keys that still exist. + private overlayLiveUsageCounters(target: AppSettings): void { + const liveSettings = this.manager.getSettings(); + target.totalDownloadedAllTime = Math.max(target.totalDownloadedAllTime || 0, liveSettings.totalDownloadedAllTime || 0); + target.totalCompletedFilesAllTime = Math.max(target.totalCompletedFilesAllTime || 0, liveSettings.totalCompletedFilesAllTime || 0); + target.totalRuntimeAllTimeMs = Math.max(target.totalRuntimeAllTimeMs || 0, this.manager.getLiveTotalRuntimeMs()); + target.providerDailyUsageDay = liveSettings.providerDailyUsageDay; + target.providerDailyUsageBytes = { ...(liveSettings.providerDailyUsageBytes || {}) }; + target.providerTotalUsageBytes = { ...(liveSettings.providerTotalUsageBytes || {}) }; + target.debridLinkApiKeyDailyUsageBytes = Object.fromEntries( + Object.entries(liveSettings.debridLinkApiKeyDailyUsageBytes || {}).filter(([keyId]) => getDebridLinkApiKeyIds(target.debridLinkApiKeys).includes(keyId)) + ); + target.debridLinkApiKeyTotalUsageBytes = Object.fromEntries( + Object.entries(liveSettings.debridLinkApiKeyTotalUsageBytes || {}).filter(([keyId]) => getDebridLinkApiKeyIds(target.debridLinkApiKeys).includes(keyId)) + ); + target.debridAccountStatuses = { ...(liveSettings.debridAccountStatuses || {}) }; + } + + public updateSettings(partial: Partial): AppSettings { + const sanitizedPatch = sanitizeSettingsPatch(partial); + const previousSettings = this.settings; + const nextSettings = normalizeSettings({ + ...previousSettings, + ...sanitizedPatch + }); + + if (settingsFingerprint(nextSettings) === settingsFingerprint(previousSettings)) { + return previousSettings; + } + + this.overlayLiveUsageCounters(nextSettings); + const retentionChanged = previousSettings.historyRetentionMode !== nextSettings.historyRetentionMode; + const historyLimitsChanged = previousSettings.historyMaxEntries !== nextSettings.historyMaxEntries + || previousSettings.historyMaxAgeDays !== nextSettings.historyMaxAgeDays; + this.settings = nextSettings; + if (retentionChanged) { + resetHistoryForRetention(this.storagePaths, this.settings.historyRetentionMode); + } else if (historyLimitsChanged && this.settings.historyRetentionMode !== "never") { + saveHistory(this.storagePaths, loadHistory(this.storagePaths), this.historyLimits()); + } + saveSettings(this.storagePaths, this.settings); + this.manager.setSettings(this.settings); + this.audit("INFO", "Einstellungen aktualisiert", { + changedKeys: Object.keys(sanitizedPatch), + accountChanges: diffAccountSummary(previousSettings, this.settings) + }); + if (previousSettings.rememberToken && !this.settings.rememberToken) { + void this.realDebridWebFallback.clearSessions().catch((error) => { + logger.warn(`Real-Debrid Web-Session konnte nicht gelöscht werden: ${String(error)}`); + }); + void this.allDebridWebFallback.clearSessions().catch((error) => { + logger.warn(`AllDebrid Web-Session konnte nicht gelöscht werden: ${String(error)}`); + }); + void this.bestDebridWebFallback.clearSessions().catch((error) => { + logger.warn(`BestDebrid Web-Session konnte nicht gelöscht werden: ${String(error)}`); + }); + } + return this.settings; + } + + public resetProviderDailyUsage(provider: DebridProvider): AppSettings { + const liveSettings = this.manager.getSettings(); + const nextSettings = normalizeSettings({ + ...liveSettings, + ...resetProviderDailyUsage(liveSettings, provider) + }); + this.settings = nextSettings; + saveSettings(this.storagePaths, this.settings); + this.manager.setSettings(this.settings); + this.audit("INFO", "Provider-Tagesnutzung zurückgesetzt", { provider }); + return this.settings; + } + + public resetDebridLinkApiKeyDailyUsage(keyId: string): AppSettings { + const liveSettings = this.manager.getSettings(); + const nextSettings = normalizeSettings({ + ...liveSettings, + ...resetDebridLinkApiKeyDailyUsage(liveSettings, keyId) + }); + this.settings = nextSettings; + saveSettings(this.storagePaths, this.settings); + this.manager.setSettings(this.settings); + this.audit("INFO", "Debrid-Link-Key-Tagesnutzung zurückgesetzt", { keyId }); + return this.settings; + } + + public async openRealDebridLoginWindow(): Promise { + this.audit("INFO", "Real-Debrid Login-Fenster geöffnet"); + await this.realDebridWebFallback.openLoginWindow(); + } + + public async openAllDebridLoginWindow(): Promise { + this.audit("INFO", "AllDebrid Login-Fenster geöffnet"); + await this.allDebridWebFallback.openLoginWindow(); + } + + public async importBestDebridCookies(filePath: string): Promise { + const imported = await this.bestDebridWebFallback.importCookiesFromFile(filePath); + this.audit("INFO", "BestDebrid Cookies importiert", { + filePath, + imported + }); + return imported; + } + + public async getAllDebridHostInfo(host = "rapidgator"): Promise { + if (this.settings.allDebridUseWebLogin) { + return this.allDebridWebFallback.getHostInfo(host); + } + const token = this.settings.allDebridToken.trim(); + if (!token) { + throw new Error("AllDebrid ist nicht konfiguriert"); + } + return fetchAllDebridHostInfo(token, host); + } + + public async getDebridLinkHostLimits(host = "rapidgator") { + return fetchDebridLinkHostLimits(this.settings.debridLinkApiKeys, host); + } + +public async checkDebridAccounts(): Promise { + const statuses = await checkAllDebridAccounts(this.settings); + this.manager.applyDebridAccountStatuses(statuses); + this.audit("INFO", "Debrid-Accounts geprueft", { + total: statuses.length, + valid: statuses.filter((s) => s.valid).length, + premium: statuses.filter((s) => s.isPremium).length + }); + return statuses; + } + + public async checkSingleMegaDebridAccount(login: string, password: string): Promise { + const entry = parseMegaDebridAccounts(`${login.trim()}:${password.trim()}`)[0]; + if (!entry) { + return null; + } + const status = await checkMegaDebridAccount(entry); + this.manager.applyDebridAccountStatuses([status]); + this.audit("INFO", "Mega-Debrid-Account einzeln geprueft", { valid: status.valid, premium: status.isPremium }); + return status; + } + public async checkUpdates(): Promise { + const result = await checkGitHubUpdate(this.settings.updateRepo); + if (!result.error) { + this.lastUpdateCheck = result; + this.lastUpdateCheckAt = Date.now(); + } + return result; + } + + public async installUpdate(onProgress?: (progress: UpdateInstallProgress) => void): Promise { + const cacheAgeMs = Date.now() - this.lastUpdateCheckAt; + const cached = this.lastUpdateCheck && !this.lastUpdateCheck.error && cacheAgeMs <= 10 * 60 * 1000 + ? this.lastUpdateCheck + : undefined; + const result = await runInstallWithResume( + this.manager, + () => installLatestUpdate(this.settings.updateRepo, cached, onProgress) + ); + if (result.started) { + this.lastUpdateCheck = null; + this.lastUpdateCheckAt = 0; + } + return result; + } + + public addLinks(payload: AddLinksPayload): { addedPackages: number; addedLinks: number; invalidCount: number } { + const parsed = parseCollectorInput(payload.rawText, payload.packageName || this.settings.packageName); + if (parsed.length === 0) { + this.audit("WARN", "Links hinzufügen ohne gültigen Inhalt", { + hasPackageName: Boolean(payload.packageName) + }); + return { addedPackages: 0, addedLinks: 0, invalidCount: 1 }; + } + const result = this.manager.addPackages(parsed); + this.audit("INFO", "Links hinzugefügt", { + addedPackages: result.addedPackages, + addedLinks: result.addedLinks, + requestedPackages: parsed.length + }); + return { ...result, invalidCount: 0 }; + } + + public async addContainers(filePaths: string[]): Promise<{ addedPackages: number; addedLinks: number }> { + const packages = await importDlcContainers(filePaths); + const merged: ParsedPackageInput[] = packages.map((pkg) => ({ + name: pkg.name, + links: pkg.links, + ...(pkg.fileNames ? { fileNames: pkg.fileNames } : {}) + })); + const result = this.manager.addPackages(merged); + this.audit("INFO", "Container importiert", { + files: filePaths.length, + addedPackages: result.addedPackages, + addedLinks: result.addedLinks + }); + return result; + } + + public async getStartConflicts(): Promise { + return this.manager.getStartConflicts(); + } + + public async resolveStartConflict(packageId: string, policy: DuplicatePolicy): Promise { + return this.manager.resolveStartConflict(packageId, policy); + } + + public clearAll(): void { + this.audit("WARN", "Queue komplett geleert"); + this.manager.clearAll(); + } + + public async start(): Promise { + this.audit("INFO", "Session-Start ausgelöst"); + await this.manager.start(); + } + + public async startPackages(packageIds: string[]): Promise { + this.audit("INFO", "Paket-Start ausgelöst", { packageIds }); + await this.manager.startPackages(packageIds); + } + + public async startItems(itemIds: string[]): Promise { + this.audit("INFO", "Item-Start ausgelöst", { itemIds }); + await this.manager.startItems(itemIds); + } + + public stop(): void { + this.audit("INFO", "Session-Stopp ausgelöst"); + this.manager.stop(); + } + + public togglePause(): boolean { + const paused = this.manager.togglePause(); + this.audit("INFO", "Pause umgeschaltet", { paused }); + return paused; + } + + public retryExtraction(packageId: string): void { + this.audit("INFO", "Extraktion manuell wiederholt", { packageId }); + this.manager.retryExtraction(packageId); + } + + public extractNow(packageId: string): void { + this.audit("INFO", "Jetzt entpacken ausgelöst", { packageId }); + this.manager.extractNow(packageId); + } + + public resetPackage(packageId: string): void { + this.audit("INFO", "Paket zurückgesetzt", { packageId }); + this.manager.resetPackage(packageId); + } + + public cancelPackage(packageId: string): void { + this.audit("WARN", "Paket abgebrochen", { packageId }); + this.manager.cancelPackage(packageId); + } + + public renamePackage(packageId: string, newName: string): void { + this.audit("INFO", "Paket umbenannt", { packageId, newName }); + this.manager.renamePackage(packageId, newName); + } + + public reorderPackages(packageIds: string[]): void { + this.audit("INFO", "Paketreihenfolge geändert", { packageIds }); + this.manager.reorderPackages(packageIds); + } + + public removeItem(itemId: string): void { + this.audit("WARN", "Item entfernt", { itemId }); + this.manager.removeItem(itemId); + } + + public togglePackage(packageId: string): void { + this.audit("INFO", "Paket aktiviert/deaktiviert", { packageId }); + this.manager.togglePackage(packageId); + } + + public exportPackageSelection(packageIds: string[]): { text: string; defaultFileName: string; packageCount: number; linkCount: number } { + const selection = buildLinkExportSelection(this.manager.getSnapshot(), packageIds, []); + this.audit("INFO", "Paket-Auswahl exportiert", { + packageCount: selection.packageCount, + linkCount: selection.linkCount, + packageIds + }); + return { + text: serializeLinkExportText(selection.packages), + defaultFileName: selection.defaultFileName, + packageCount: selection.packageCount, + linkCount: selection.linkCount + }; + } + + public exportItemSelection(itemIds: string[]): { text: string; defaultFileName: string; packageCount: number; linkCount: number } { + const selection = buildLinkExportSelection(this.manager.getSnapshot(), [], itemIds); + this.audit("INFO", "Item-Auswahl exportiert", { + packageCount: selection.packageCount, + linkCount: selection.linkCount, + itemIds + }); + return { + text: serializeLinkExportText(selection.packages), + defaultFileName: selection.defaultFileName, + packageCount: selection.packageCount, + linkCount: selection.linkCount + }; + } + + public exportQueue(): string { + return this.manager.exportQueue(); + } + + public importQueue(json: string): { addedPackages: number; addedLinks: number } { + const result = this.manager.importQueue(json); + this.audit("INFO", "Import-Datei verarbeitet", result); + return result; + } + + public getSessionStats(): SessionStats { + return this.manager.getSessionStats(); + } + + public resetSessionStats(): void { + this.audit("INFO", "Session-Statistik zurückgesetzt"); + this.manager.resetSessionStats(); + } + + public resetDownloadStats(): void { + this.manager.resetDownloadStats(); + this.settings = this.manager.getSettings(); + this.audit("INFO", "Download-Statistik zurückgesetzt"); + } + + public exportBackup(): Buffer { + let remoteDiagnostics: BackupRemoteDiagnostics | undefined; + if (Boolean(this.settings.backupIncludeRemoteDiagnostics)) { + const status = getDebugServerRuntimeStatus(); + remoteDiagnostics = { + allowlist: getDebugAllowlist(), + port: status.port, + hostMode: status.host === "0.0.0.0" ? "network" : "local" + }; + } + const payloadObj = buildBackupPayload({ + settings: { ...this.settings }, + appVersion: APP_VERSION, + exportedAt: new Date().toISOString(), + session: this.manager.getSession(), + history: loadHistoryForRetention(this.storagePaths, this.settings.historyRetentionMode, this.historyLimits()), + remoteDiagnostics + }); + this.audit("INFO", "Backup exportiert", { + kind: payloadObj.kind, + historyEntries: payloadObj.history ? payloadObj.history.length : 0, + sessionItems: payloadObj.session ? Object.keys(payloadObj.session.items).length : 0, + sessionPackages: payloadObj.session ? Object.keys(payloadObj.session.packages).length : 0 + }); + return encryptBackup(JSON.stringify(payloadObj)); + } + + public async exportSupportBundle(): Promise<{ buffer: Buffer; defaultFileName: string }> { + this.audit("INFO", "Support-Bundle exportiert"); + logTraceEvent("INFO", "support", "Support-Bundle erstellt", { + packageCount: Object.keys(this.manager.getSnapshot().session.packages).length, + itemCount: Object.keys(this.manager.getSnapshot().session.items).length + }); + return { + buffer: await buildSupportBundle(this.manager, this.storagePaths.baseDir, { hostDiagnosticsMode: "cached" }), + defaultFileName: getSupportBundleDefaultFileName() + }; + } + + public getSupportBundleDefaultFileName(): string { + return getSupportBundleDefaultFileName(); + } + + public importBackup(data: Buffer): { restored: boolean; relaunch: boolean; message: string } { + let parsed: Record; + try { + const json = decryptBackup(data); + parsed = JSON.parse(json) as Record; + } catch { + try { + const json = data.toString("utf8"); + parsed = JSON.parse(json) as Record; + } catch { + return { restored: false, relaunch: false, message: "Backup-Datei konnte nicht entschlüsselt werden" }; + } + } + const plan = planBackupImport(parsed); + if (!plan.valid) { + return { restored: false, relaunch: false, message: plan.message }; + } + const hasSession = plan.restoreDownloads; + + const importedSettings = parsed.settings as AppSettings; + const importedSettingsRecord = importedSettings as unknown as Record; + const currentSettingsRecord = this.settings as unknown as Record; + const SENSITIVE_KEYS: (keyof AppSettings)[] = [ + "token", "megaLogin", "megaPassword", "bestToken", "allDebridToken", + "ddownloadLogin", "ddownloadPassword", "oneFichierApiKey", + "debridLinkApiKeys", "linkSnappyLogin", "linkSnappyPassword", + "notifyUrl" + ]; + for (const key of SENSITIVE_KEYS) { + const val = importedSettingsRecord[key]; + if (typeof val === "string" && val.startsWith("***")) { + importedSettingsRecord[key] = currentSettingsRecord[key]; + } + } + const restoredSettings = normalizeSettings(importedSettings); + + // Settings-only backup: keep the running queue AND the live counters untouched. + // Overlay the live usage/status counters so they don't roll back to the backup's + // (older) snapshot (BUG I), and suppress the retroactive cleanup sweep so the + // backup's cleanup policy can't purge the live completed queue here (BUG B) — the + // policy still governs FUTURE completions through the normal path. Do NOT stop the + // manager, wipe the session, block persistence or relaunch. + if (!hasSession) { + this.overlayLiveUsageCounters(restoredSettings); + this.settings = restoredSettings; + saveSettings(this.storagePaths, this.settings); + this.manager.setSettings(this.settings, { suppressRetroactiveCleanup: true }); + this.restoreRemoteDiagnosticsFromBackup(parsed.remoteDiagnostics, true); + this.audit("INFO", "Backup importiert (nur Einstellungen)", { + accountSummary: buildAccountSummary(this.settings) + }); + return { + restored: true, + relaunch: false, + message: "Einstellungen wiederhergestellt" + }; + } + + this.settings = restoredSettings; + saveSettings(this.storagePaths, this.settings); + this.manager.setSettings(this.settings); + + this.manager.stop(); + this.manager.abortAllPostProcessing(); + this.manager.clearPersistTimer(); + cancelPendingAsyncSaves(); + + const restoredSession = normalizeLoadedSessionTransientFields( + normalizeLoadedSession(parsed.session) + ); + saveSession(this.storagePaths, restoredSession); + + if (Array.isArray(parsed.history) && parsed.history.length > 0) { + const normalizedHistory = (parsed.history as unknown[]) + .map((raw, idx) => normalizeHistoryEntry(raw, idx)) + .filter((entry): entry is HistoryEntry => entry !== null); + if (normalizedHistory.length > 0) { + saveHistory(this.storagePaths, normalizedHistory); + logger.info(`Backup: ${normalizedHistory.length} History-Einträge wiederhergestellt`); + } + } + + resetHistoryForRetention(this.storagePaths, this.settings.historyRetentionMode); + + this.restoreRemoteDiagnosticsFromBackup(parsed.remoteDiagnostics, false); + + this.manager.skipShutdownPersist = true; + this.manager.blockAllPersistence = true; + logger.info("Backup wiederhergestellt — App startet automatisch neu"); + this.audit("WARN", "Backup importiert", { + historyEntries: Array.isArray(parsed.history) ? parsed.history.length : 0, + accountSummary: buildAccountSummary(this.settings) + }); + return { restored: true, relaunch: true, message: "Backup wiederhergestellt – App startet automatisch neu…" }; + } + + public getSessionLogPath(): string | null { + return getSessionLogPath(); + } + + public getPackageLogPath(packageId: string): string | null { + return this.manager.getPackageLogPath(packageId) || getPackageLogPath(packageId); + } + + public getItemLogPath(itemId: string): string | null { + return this.manager.getItemLogPath(itemId) || getItemLogPath(itemId); + } + + public shutdown(): void { + if (this.runtimeStatsTimer) { + clearInterval(this.runtimeStatsTimer); + this.runtimeStatsTimer = null; + } + stopDebugServer(); + abortActiveUpdateDownload(); + cancelPendingAsyncSaves(); + this.manager.prepareForShutdown(); + this.megaWebFallback.dispose(); + this.realDebridWebFallback.dispose(); + this.allDebridWebFallback.dispose(); + this.bestDebridWebFallback.dispose(); + shutdownSessionLog(); + shutdownPackageLogs(); + shutdownItemLogs(); + shutdownRenameLog(); + shutdownDesktopRenameLog(); + this.audit("INFO", "App beendet"); + shutdownTraceLog(); + shutdownAccountRotationLog(); + shutdownConversionLog(); + shutdownAuditLog(); + if (this.settings.historyRetentionMode === "session") { + clearHistory(this.storagePaths); + } + logger.info("App beendet"); + } + + private historyLimits(): { maxEntries: number; maxAgeDays: number } { + return { maxEntries: this.settings.historyMaxEntries, maxAgeDays: this.settings.historyMaxAgeDays }; + } + + public getHistory(): HistoryEntry[] { + return loadHistoryForRetention(this.storagePaths, this.settings.historyRetentionMode, this.historyLimits()); + } + + public clearHistory(): void { + this.audit("WARN", "Verlauf geleert"); + clearHistory(this.storagePaths); + } + + public setPackagePriority(packageId: string, priority: PackagePriority): void { + this.audit("INFO", "Paket-Priorität geändert", { packageId, priority }); + this.manager.setPackagePriority(packageId, priority); + } + + public skipItems(itemIds: string[]): void { + this.audit("INFO", "Items übersprungen", { itemIds }); + this.manager.skipItems(itemIds); + } + + public resetItems(itemIds: string[]): void { + this.audit("INFO", "Items zurückgesetzt", { itemIds }); + this.manager.resetItems(itemIds); + } + + public removeHistoryEntry(entryId: string): void { + this.audit("INFO", "Verlaufseintrag entfernt", { entryId }); + removeHistoryEntry(this.storagePaths, entryId); + } + + public addToHistory(entry: HistoryEntry): void { + this.audit("INFO", "Verlaufseintrag hinzugefügt", { + id: entry.id, + name: entry.name, + status: entry.status, + provider: entry.provider, + fileCount: entry.fileCount + }); + addHistoryEntry(this.storagePaths, entry); + } +} diff --git a/src/main/audit-log.ts b/src/main/audit-log.ts new file mode 100644 index 0000000..86c6534 --- /dev/null +++ b/src/main/audit-log.ts @@ -0,0 +1,119 @@ +import fs from "node:fs"; +import { logTimestamp } from "./log-timestamp"; +import path from "node:path"; + +type AuditLevel = "INFO" | "WARN" | "ERROR"; + +const AUDIT_LOG_MAX_FILE_BYTES = Number(process.env.RD_AUDIT_LOG_MAX_BYTES || 10 * 1024 * 1024); +const AUDIT_LOG_RETENTION_DAYS = Number(process.env.RD_AUDIT_LOG_RETENTION_DAYS || 30); + +let auditLogPath: string | null = null; + +function sanitizeFieldValue(value: unknown): string { + if (value === undefined || value === null) { + return ""; + } + if (typeof value === "string") { + return value.replace(/\r?\n/g, "\\n"); + } + if (typeof value === "number" || typeof value === "boolean") { + return String(value); + } + try { + return JSON.stringify(value).replace(/\r?\n/g, "\\n"); + } catch { + return String(value); + } +} + +function formatFields(fields?: Record): string { + if (!fields) { + return ""; + } + const parts = Object.entries(fields) + .filter(([, value]) => value !== undefined && value !== null && sanitizeFieldValue(value) !== "") + .map(([key, value]) => `${key}=${sanitizeFieldValue(value)}`); + return parts.length > 0 ? ` | ${parts.join(" | ")}` : ""; +} + +function rotateIfNeeded(filePath: string): void { + try { + const stat = fs.statSync(filePath); + if (stat.size < AUDIT_LOG_MAX_FILE_BYTES) { + return; + } + const backup = `${filePath}.old`; + try { + fs.rmSync(backup, { force: true }); + } catch { + } + fs.renameSync(filePath, backup); + } catch { + } +} + +function cleanupOldBackup(filePath: string): void { + const backup = `${filePath}.old`; + try { + const stat = fs.statSync(backup); + const cutoff = Date.now() - AUDIT_LOG_RETENTION_DAYS * 24 * 60 * 60 * 1000; + if (stat.mtimeMs < cutoff) { + fs.rmSync(backup, { force: true }); + } + } catch { + } +} + +export function initAuditLog(baseDir: string): void { + auditLogPath = path.join(baseDir, "audit.log"); + try { + fs.mkdirSync(path.dirname(auditLogPath), { recursive: true }); + cleanupOldBackup(auditLogPath); + if (!fs.existsSync(auditLogPath)) { + fs.writeFileSync(auditLogPath, "", "utf8"); + } + rotateIfNeeded(auditLogPath); + if (!fs.existsSync(auditLogPath)) { + fs.writeFileSync(auditLogPath, "", "utf8"); + } + fs.appendFileSync(auditLogPath, `=== Audit-Log Start: ${logTimestamp()} ===\n`, "utf8"); + } catch { + auditLogPath = null; + } +} + +export function logAuditEvent(level: AuditLevel, message: string, fields?: Record): void { + if (!auditLogPath) { + return; + } + try { + rotateIfNeeded(auditLogPath); + if (!fs.existsSync(auditLogPath)) { + fs.writeFileSync(auditLogPath, "", "utf8"); + } + fs.appendFileSync( + auditLogPath, + `${logTimestamp()} [${level}] ${message}${formatFields(fields)}\n`, + "utf8" + ); + } catch { + } +} + +export function getAuditLogPath(): string | null { + if (!auditLogPath) { + return null; + } + return fs.existsSync(auditLogPath) ? auditLogPath : null; +} + +export function shutdownAuditLog(): void { + if (!auditLogPath) { + return; + } + try { + fs.appendFileSync(auditLogPath, `=== Audit-Log Ende: ${logTimestamp()} ===\n`, "utf8"); + } catch { + } + auditLogPath = null; +} diff --git a/src/main/backup-crypto.ts b/src/main/backup-crypto.ts new file mode 100644 index 0000000..617821e --- /dev/null +++ b/src/main/backup-crypto.ts @@ -0,0 +1,39 @@ +import crypto from "node:crypto"; + +const APP_KEY_MATERIAL = "MDD-v2-backup-aes256gcm-2026"; +const ALGORITHM = "aes-256-gcm"; +const IV_LENGTH = 12; +const AUTH_TAG_LENGTH = 16; +const MAGIC = Buffer.from("MDD1"); + +function deriveKey(): Buffer { + return crypto.createHash("sha256").update(APP_KEY_MATERIAL).digest(); +} + +export function encryptBackup(plaintext: string): Buffer { + const key = deriveKey(); + const iv = crypto.randomBytes(IV_LENGTH); + const cipher = crypto.createCipheriv(ALGORITHM, key, iv, { authTagLength: AUTH_TAG_LENGTH }); + const encrypted = Buffer.concat([cipher.update(plaintext, "utf8"), cipher.final()]); + const authTag = cipher.getAuthTag(); + return Buffer.concat([MAGIC, iv, authTag, encrypted]); +} + +export function decryptBackup(data: Buffer): string { + if (data.length < MAGIC.length + IV_LENGTH + AUTH_TAG_LENGTH) { + throw new Error("Backup-Datei zu kurz oder ungültig"); + } + const magic = data.subarray(0, MAGIC.length); + if (!magic.equals(MAGIC)) { + throw new Error("Keine gültige MDD-Backup-Datei (falsche Signatur)"); + } + const iv = data.subarray(MAGIC.length, MAGIC.length + IV_LENGTH); + const authTag = data.subarray(MAGIC.length + IV_LENGTH, MAGIC.length + IV_LENGTH + AUTH_TAG_LENGTH); + const ciphertext = data.subarray(MAGIC.length + IV_LENGTH + AUTH_TAG_LENGTH); + + const key = deriveKey(); + const decipher = crypto.createDecipheriv(ALGORITHM, key, iv, { authTagLength: AUTH_TAG_LENGTH }); + decipher.setAuthTag(authTag); + const decrypted = Buffer.concat([decipher.update(ciphertext), decipher.final()]); + return decrypted.toString("utf8"); +} diff --git a/src/main/backup-payload.ts b/src/main/backup-payload.ts new file mode 100644 index 0000000..8e22fbf --- /dev/null +++ b/src/main/backup-payload.ts @@ -0,0 +1,115 @@ +import type { AppSettings, SessionState, HistoryEntry } from "../shared/types"; + +export type BackupKind = "full" | "settings-only"; + +export interface BackupRemoteDiagnostics { + allowlist: string[]; + port: number; + hostMode: "local" | "network"; +} + +export interface BackupPayload { + version: 2; + kind: BackupKind; + appVersion: string; + exportedAt: string; + settings: AppSettings; + session?: SessionState; + history?: HistoryEntry[]; + remoteDiagnostics?: BackupRemoteDiagnostics; +} + +export interface BuildBackupInput { + settings: AppSettings; + appVersion: string; + exportedAt: string; + /** Only bundled when includeDownloads is true. */ + session: SessionState; + history: HistoryEntry[]; + remoteDiagnostics?: BackupRemoteDiagnostics; +} + +/** + * Build the backup payload. By default ("Download-Liste mitsichern" off) the + * payload contains ONLY settings — no session, no history. The download list is + * bundled solely when settings.backupIncludeDownloads is true. An explicit kind + * marker makes the import side unambiguous and survives hand-edited files. + */ +export function buildBackupPayload(input: BuildBackupInput): BackupPayload { + const includeDownloads = Boolean(input.settings.backupIncludeDownloads); + const base: BackupPayload = { + version: 2, + kind: includeDownloads ? "full" : "settings-only", + appVersion: input.appVersion, + exportedAt: input.exportedAt, + settings: input.settings + }; + if (includeDownloads) { + base.session = input.session; + base.history = input.history; + } + if (Boolean(input.settings.backupIncludeRemoteDiagnostics) && input.remoteDiagnostics) { + base.remoteDiagnostics = input.remoteDiagnostics; + } + return base; +} + +export interface RemoteDiagnosticsRestore { + host?: "127.0.0.1" | "0.0.0.0"; + port?: number; + allowlist?: string[]; +} + +export function resolveRemoteDiagnosticsRestore(section: unknown): RemoteDiagnosticsRestore | null { + if (!section || typeof section !== "object") { + return null; + } + const s = section as { allowlist?: unknown; port?: unknown; hostMode?: unknown }; + const allowlist = Array.isArray(s.allowlist) + ? s.allowlist.filter((entry): entry is string => typeof entry === "string" && entry.trim().length > 0).map((entry) => entry.trim()) + : undefined; + const port = (typeof s.port === "number" && Number.isInteger(s.port) && s.port >= 1024 && s.port <= 65535) ? s.port : undefined; + let host: "127.0.0.1" | "0.0.0.0" | undefined; + if (s.hostMode === "network") { + host = allowlist && allowlist.length > 0 ? "0.0.0.0" : "127.0.0.1"; + } else if (s.hostMode === "local") { + host = "127.0.0.1"; + } + if (host === undefined && port === undefined && allowlist === undefined) { + return null; + } + return { host, port, allowlist }; +} + +export interface ImportPlan { + valid: boolean; + /** Restore the download list (session + history) and relaunch. */ + restoreDownloads: boolean; + message: string; +} + +/** + * Decide how to apply an imported backup based on what the FILE physically + * contains — NOT the local toggle. A backup without a session restores settings + * only (no queue wipe, no relaunch); a full backup (with session) restores the + * queue too. This way an old full backup still restores fully even if the local + * toggle is currently off, and a settings-only backup never disturbs a running + * queue. + */ +export function planBackupImport(parsed: unknown): ImportPlan { + if (!parsed || typeof parsed !== "object") { + return { valid: false, restoreDownloads: false, message: "Kein gültiges Backup (settings fehlen)" }; + } + const record = parsed as Record; + if (!record.settings || typeof record.settings !== "object") { + return { valid: false, restoreDownloads: false, message: "Kein gültiges Backup (settings fehlen)" }; + } + const hasSession = Boolean(record.session) && typeof record.session === "object"; + return { + valid: true, + restoreDownloads: hasSession, + message: hasSession + ? "Backup wiederhergestellt – App startet automatisch neu…" + : "Einstellungen wiederhergestellt" + }; +} diff --git a/src/main/bestdebrid-web.ts b/src/main/bestdebrid-web.ts new file mode 100644 index 0000000..52eeb89 --- /dev/null +++ b/src/main/bestdebrid-web.ts @@ -0,0 +1,346 @@ +import fs from "node:fs"; +import { session, type Session } from "electron"; +import { UnrestrictedLink } from "./realdebrid"; +import { filenameFromUrl, sleep } from "./utils"; +import { logger } from "./logger"; + +const BESTDEBRID_BASE_URL = "https://bestdebrid.com"; +const BESTDEBRID_DOWNLOADER_URL = `${BESTDEBRID_BASE_URL}/en/downloader/`; +const BESTDEBRID_GENERATE_URL = `${BESTDEBRID_BASE_URL}/api/v1/generateLink`; +const BESTDEBRID_PERSISTENT_PARTITION = "persist:bestdebrid-web"; +const BESTDEBRID_TRANSIENT_PARTITION = "bestdebrid-web"; +const BESTDEBRID_USER_AGENT = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/133.0.0.0 Safari/537.36"; + +function abortError(): Error { + return new Error("aborted:bestdebrid-web"); +} + +function withTimeoutSignal(signal: AbortSignal | undefined, timeoutMs: number): AbortSignal { + const timeoutSignal = AbortSignal.timeout(timeoutMs); + if (!signal) { + return timeoutSignal; + } + return AbortSignal.any([signal, timeoutSignal]); +} + +function throwIfAborted(signal?: AbortSignal): void { + if (signal?.aborted) { + throw abortError(); + } +} + +function parseJson(text: string): Record | null { + try { + const parsed = JSON.parse(text) as unknown; + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { + return null; + } + return parsed as Record; + } catch { + return null; + } +} + +interface NetscapeCookie { + domain: string; + includeSubdomains: boolean; + httpOnly: boolean; + path: string; + secure: boolean; + expirationDate: number; + name: string; + value: string; +} + +function normalizeCookieDomain(domain: string): string { + return String(domain || "").trim().replace(/^\./, "").toLowerCase(); +} + +function dedupeCookies(cookies: NetscapeCookie[]): NetscapeCookie[] { + const deduped = new Map(); + for (const cookie of cookies) { + const key = `${normalizeCookieDomain(cookie.domain)}\t${cookie.path}\t${cookie.name}`; + const existing = deduped.get(key); + if (!existing) { + deduped.set(key, cookie); + continue; + } + + if (cookie.httpOnly && !existing.httpOnly) { + deduped.set(key, cookie); + continue; + } + + if (cookie.expirationDate > existing.expirationDate) { + deduped.set(key, cookie); + } + } + return [...deduped.values()]; +} + +function parseNetscapeCookieFile(text: string): NetscapeCookie[] { + const cookies: NetscapeCookie[] = []; + for (const line of text.split(/\r?\n/)) { + const trimmed = line.trim(); + if (!trimmed) { + continue; + } + + let normalizedLine = trimmed; + let httpOnly = false; + if (normalizedLine.startsWith("#HttpOnly_")) { + httpOnly = true; + normalizedLine = normalizedLine.slice("#HttpOnly_".length); + } else if (normalizedLine.startsWith("#")) { + continue; + } + const parts = normalizedLine.split("\t"); + if (parts.length < 7) { + continue; + } + cookies.push({ + domain: parts[0], + includeSubdomains: parts[1].toUpperCase() === "TRUE", + httpOnly, + path: parts[2], + secure: parts[3].toUpperCase() === "TRUE", + expirationDate: Number(parts[4]) || 0, + name: parts[5], + value: parts[6] + }); + } + return cookies; +} + +function isLikelyBestDebridAuthCookie(name: string): boolean { + const normalized = String(name || "").trim(); + return /phpsessid|sess(?:ion)?|auth|login/i.test(normalized); +} + +function isAuthenticatedBestDebridHtml(html: string): boolean { + const normalized = String(html || ""); + if (!normalized) { + return false; + } + return /href\s*=\s*["']logout["']/i.test(normalized) + || /title\s*=\s*["'][^"']*premium until/i.test(normalized) + || (/user-profile-image/i.test(normalized) && !/>\s*guest\s* = Promise.resolve(); + + private cookiesImported = false; + + private getRememberSession: () => boolean; + + public constructor(getRememberSession: () => boolean) { + this.getRememberSession = getRememberSession; + } + + public async unrestrict(link: string, signal?: AbortSignal): Promise { + const overallSignal = withTimeoutSignal(signal, 60_000); + return this.runExclusive(async () => { + throwIfAborted(overallSignal); + if (!String(link || "").trim()) { + return null; + } + + if (!this.cookiesImported) { + throw new Error("BestDebrid: Keine Cookies importiert. Bitte zuerst über Einstellungen eine Cookie-Datei importieren."); + } + + const result = await this.generate(link, overallSignal); + if (result.kind === "success") { + return result.value; + } + this.cookiesImported = false; + throw new Error("BestDebrid: Nicht eingeloggt. Bitte neue Cookie-Datei importieren."); + }, overallSignal); + } + + public async importCookiesFromFile(filePath: string): Promise { + const text = fs.readFileSync(filePath, "utf-8"); + const cookies = parseNetscapeCookieFile(text); + const bestDebridCookies = dedupeCookies(cookies.filter((c) => + c.domain.includes("bestdebrid.com") + )); + + if (bestDebridCookies.length === 0) { + throw new Error("Keine BestDebrid-Cookies in der Datei gefunden"); + } + + if (!bestDebridCookies.some((cookie) => isLikelyBestDebridAuthCookie(cookie.name))) { + throw new Error("BestDebrid: Cookie-Datei enthält keinen Login-Cookie. Bitte nach dem Login erneut exportieren."); + } + + const currentSession = session.fromPartition(this.getPartition()); + await this.clearPartitionState(currentSession); + + for (const cookie of bestDebridCookies) { + const url = `https://${cookie.domain.replace(/^\./, "")}${cookie.path}`; + const details: Parameters[0] = { + url, + name: cookie.name, + value: cookie.value, + path: cookie.path, + secure: cookie.secure, + httpOnly: cookie.httpOnly, + expirationDate: cookie.expirationDate > 0 ? cookie.expirationDate : undefined + }; + if (cookie.includeSubdomains || cookie.domain.startsWith(".")) { + details.domain = cookie.domain; + } + await currentSession.cookies.set(details); + } + + this.cookiesImported = true; + logger.info(`BestDebrid: ${bestDebridCookies.length} Cookies importiert aus ${filePath}`); + return bestDebridCookies.length; + } + + public async clearSessions(): Promise { + this.cookiesImported = false; + for (const partition of [BESTDEBRID_PERSISTENT_PARTITION, BESTDEBRID_TRANSIENT_PARTITION]) { + const currentSession = session.fromPartition(partition); + try { + await currentSession.clearStorageData({ + storages: ["cookies", "indexdb", "localstorage", "serviceworkers", "cachestorage"] + }); + } catch { + } + try { + await currentSession.clearCache(); + } catch { + } + } + } + + public dispose(): void { + } + + private getPartition(): string { + return this.getRememberSession() ? BESTDEBRID_PERSISTENT_PARTITION : BESTDEBRID_TRANSIENT_PARTITION; + } + + private async runExclusive(job: () => Promise, signal?: AbortSignal): Promise { + const queuedAt = Date.now(); + const queueWaitTimeoutMs = 90_000; + const guardedJob = async (): Promise => { + throwIfAborted(signal); + const waited = Date.now() - queuedAt; + if (waited > queueWaitTimeoutMs) { + throw new Error(`BestDebrid-Web Queue-Timeout (${Math.floor(waited / 1000)}s gewartet)`); + } + return job(); + }; + const run = this.queue.then(guardedJob, guardedJob); + this.queue = run.then(() => undefined, () => undefined); + return run; + } + + private async generate(link: string, signal?: AbortSignal): Promise<{ kind: "success"; value: UnrestrictedLink } | { kind: "login_required" }> { + throwIfAborted(signal); + const currentSession = session.fromPartition(this.getPartition()); + const response = await currentSession.fetch(BESTDEBRID_GENERATE_URL, { + method: "POST", + headers: { + Accept: "application/json, text/javascript, */*; q=0.01", + "Content-Type": "application/x-www-form-urlencoded; charset=UTF-8", + Origin: BESTDEBRID_BASE_URL, + Referer: BESTDEBRID_DOWNLOADER_URL, + "User-Agent": BESTDEBRID_USER_AGENT, + "X-Requested-With": "XMLHttpRequest" + }, + body: new URLSearchParams({ link, pass: "", boxlinklist: "" }).toString(), + signal: withTimeoutSignal(signal, 30_000) + }); + + const text = await response.text(); + + if (!response.ok || text.trim().startsWith(" null); + if (authenticated === false) { + return { kind: "login_required" }; + } + } + throw new Error(`BestDebrid Web: ${message || "Unbekannter Fehler"}`); + } + + const directUrl = String(payload.link || "").trim(); + if (!directUrl) { + throw new Error("BestDebrid Web: Antwort ohne Download-Link"); + } + + const fileName = String(payload.filename || "").trim() || filenameFromUrl(directUrl) || filenameFromUrl(link); + const fileSizeRaw = String(payload.size || "").trim(); + let fileSize: number | null = null; + if (fileSizeRaw) { + const match = fileSizeRaw.match(/([\d.]+)\s*(KB|KiB|MB|MiB|GB|GiB|TB|TiB|B)/i); + if (match) { + const value = parseFloat(match[1]); + const unit = match[2].toUpperCase().replace("IB", "B"); + const multipliers: Record = { B: 1, KB: 1024, MB: 1024 * 1024, GB: 1024 * 1024 * 1024, TB: 1024 * 1024 * 1024 * 1024 }; + fileSize = Math.floor(value * (multipliers[unit] || 1)); + } + } + + return { + kind: "success", + value: { + directUrl, + fileName, + fileSize, + retriesUsed: 0 + } + }; + } + + private async isAuthenticated(currentSession: Session, signal?: AbortSignal): Promise { + throwIfAborted(signal); + const response = await currentSession.fetch(BESTDEBRID_DOWNLOADER_URL, { + method: "GET", + headers: { + Accept: "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + Referer: BESTDEBRID_BASE_URL, + "User-Agent": BESTDEBRID_USER_AGENT + }, + signal: withTimeoutSignal(signal, 20_000) + }); + if (!response.ok) { + return false; + } + const text = await response.text(); + return isAuthenticatedBestDebridHtml(text); + } + + private async clearPartitionState(currentSession: Session): Promise { + await currentSession.clearStorageData({ + storages: ["cookies", "indexdb", "localstorage", "serviceworkers", "cachestorage"] + }); + try { + await currentSession.clearCache(); + } catch { + } + } +} diff --git a/src/main/cleanup.ts b/src/main/cleanup.ts new file mode 100644 index 0000000..2ab0cef --- /dev/null +++ b/src/main/cleanup.ts @@ -0,0 +1,266 @@ +import fs from "node:fs"; +import path from "node:path"; +import { ARCHIVE_TEMP_EXTENSIONS, LINK_ARTIFACT_EXTENSIONS, MAX_LINK_ARTIFACT_BYTES, RAR_SPLIT_RE, SAMPLE_DIR_NAMES, SAMPLE_TOKEN_RE, SAMPLE_VIDEO_EXTENSIONS } from "./constants"; + +async function yieldToLoop(): Promise { + await new Promise((resolve) => { + setTimeout(resolve, 0); + }); +} + +export function isArchiveOrTempFile(filePath: string): boolean { + const lowerName = path.basename(filePath).toLowerCase(); + const ext = path.extname(lowerName); + if (ARCHIVE_TEMP_EXTENSIONS.has(ext)) { + return true; + } + if (lowerName.includes(".part") && lowerName.endsWith(".rar")) { + return true; + } + return RAR_SPLIT_RE.test(lowerName); +} + +export function cleanupCancelledPackageArtifacts(packageDir: string): number { + if (!fs.existsSync(packageDir)) { + return 0; + } + let removed = 0; + const stack = [packageDir]; + while (stack.length > 0) { + const current = stack.pop() as string; + let entries: fs.Dirent[] = []; + try { entries = fs.readdirSync(current, { withFileTypes: true }); } catch { continue; } + for (const entry of entries) { + const full = path.join(current, entry.name); + if (entry.isDirectory() && !entry.isSymbolicLink()) { + stack.push(full); + } else if (entry.isFile() && isArchiveOrTempFile(full)) { + try { + fs.rmSync(full, { force: true }); + removed += 1; + } catch { + } + } + } + } + return removed; +} + +export async function cleanupCancelledPackageArtifactsAsync( + packageDir: string, + options: { shouldAbort?: () => boolean } = {} +): Promise { + try { + await fs.promises.access(packageDir, fs.constants.F_OK); + } catch { + return 0; + } + + let removed = 0; + let touched = 0; + const stack = [packageDir]; + while (stack.length > 0) { + if (options.shouldAbort?.()) { + return removed; + } + const current = stack.pop() as string; + let entries: fs.Dirent[] = []; + try { + entries = await fs.promises.readdir(current, { withFileTypes: true }); + } catch { + continue; + } + + for (const entry of entries) { + if (options.shouldAbort?.()) { + return removed; + } + const full = path.join(current, entry.name); + if (entry.isDirectory() && !entry.isSymbolicLink()) { + stack.push(full); + } else if (entry.isFile() && isArchiveOrTempFile(full)) { + try { + await fs.promises.rm(full, { force: true }); + removed += 1; + } catch { + } + } + + touched += 1; + if (touched % 80 === 0) { + await yieldToLoop(); + } + } + } + return removed; +} + +export async function removeDownloadLinkArtifacts( + extractDir: string, + options: { shouldAbort?: () => boolean } = {} +): Promise { + try { + await fs.promises.access(extractDir); + } catch { + return 0; + } + let removed = 0; + const stack = [extractDir]; + while (stack.length > 0) { + if (options.shouldAbort?.()) { + return removed; + } + const current = stack.pop() as string; + let entries: fs.Dirent[] = []; + try { entries = await fs.promises.readdir(current, { withFileTypes: true }); } catch { continue; } + for (const entry of entries) { + if (options.shouldAbort?.()) { + return removed; + } + const full = path.join(current, entry.name); + if (entry.isDirectory() && !entry.isSymbolicLink()) { + stack.push(full); + continue; + } + if (!entry.isFile()) { + continue; + } + + const ext = path.extname(entry.name).toLowerCase(); + const name = entry.name.toLowerCase(); + let shouldDelete = LINK_ARTIFACT_EXTENSIONS.has(ext); + if (!shouldDelete && [".txt", ".html", ".htm", ".nfo"].includes(ext)) { + if (/[._\- ](links?|downloads?|urls?|dlc)([._\- ]|$)/i.test(name)) { + try { + const stat = await fs.promises.stat(full); + if (stat.size <= MAX_LINK_ARTIFACT_BYTES) { + const text = await fs.promises.readFile(full, "utf8"); + shouldDelete = /https?:\/\//i.test(text); + } + } catch { + shouldDelete = false; + } + } + } + + if (shouldDelete) { + try { + await fs.promises.rm(full, { force: true }); + removed += 1; + } catch { + } + } + } + } + return removed; +} + +export async function removeSampleArtifacts( + extractDir: string, + options: { shouldAbort?: () => boolean } = {} +): Promise<{ files: number; dirs: number }> { + try { + await fs.promises.access(extractDir); + } catch { + return { files: 0, dirs: 0 }; + } + + let removedFiles = 0; + let removedDirs = 0; + const sampleDirs: string[] = []; + const stack = [extractDir]; + + const countFilesRecursive = async (rootDir: string): Promise => { + let count = 0; + const dirs = [rootDir]; + while (dirs.length > 0) { + const current = dirs.pop() as string; + let entries: fs.Dirent[] = []; + try { + entries = await fs.promises.readdir(current, { withFileTypes: true }); + } catch { + continue; + } + for (const entry of entries) { + const full = path.join(current, entry.name); + if (entry.isDirectory()) { + try { + const stat = await fs.promises.lstat(full); + if (stat.isSymbolicLink()) { + continue; + } + } catch { + continue; + } + dirs.push(full); + } else if (entry.isFile()) { + count += 1; + } + } + } + return count; + }; + + while (stack.length > 0) { + if (options.shouldAbort?.()) { + return { files: removedFiles, dirs: removedDirs }; + } + const current = stack.pop() as string; + let entries: fs.Dirent[] = []; + try { entries = await fs.promises.readdir(current, { withFileTypes: true }); } catch { continue; } + for (const entry of entries) { + if (options.shouldAbort?.()) { + return { files: removedFiles, dirs: removedDirs }; + } + const full = path.join(current, entry.name); + if (entry.isDirectory() || entry.isSymbolicLink()) { + const base = entry.name.toLowerCase(); + if (SAMPLE_DIR_NAMES.has(base)) { + sampleDirs.push(full); + continue; + } + if (entry.isDirectory()) { + stack.push(full); + } + continue; + } + if (!entry.isFile()) { + continue; + } + + const stem = path.parse(entry.name).name.toLowerCase(); + const ext = path.extname(entry.name).toLowerCase(); + const isSampleVideo = SAMPLE_VIDEO_EXTENSIONS.has(ext) && SAMPLE_TOKEN_RE.test(stem); + + if (isSampleVideo) { + try { + await fs.promises.rm(full, { force: true }); + removedFiles += 1; + } catch { + } + } + } + } + + sampleDirs.sort((a, b) => b.length - a.length); + for (const dir of sampleDirs) { + if (options.shouldAbort?.()) { + return { files: removedFiles, dirs: removedDirs }; + } + try { + const stat = await fs.promises.lstat(dir); + if (stat.isSymbolicLink()) { + await fs.promises.rm(dir, { force: true }); + removedDirs += 1; + continue; + } + const filesInDir = await countFilesRecursive(dir); + await fs.promises.rm(dir, { recursive: true, force: true }); + removedFiles += filesInDir; + removedDirs += 1; + } catch { + } + } + + return { files: removedFiles, dirs: removedDirs }; +} diff --git a/src/main/connection-code.ts b/src/main/connection-code.ts new file mode 100644 index 0000000..42617cf --- /dev/null +++ b/src/main/connection-code.ts @@ -0,0 +1,59 @@ +import fs from "node:fs"; +import path from "node:path"; + +const PREFIX = "rddiag:v1:"; + +function base64urlEncode(value: string): string { + return Buffer.from(value, "utf8") + .toString("base64") + .replace(/\+/g, "-") + .replace(/\//g, "_") + .replace(/=+$/, ""); +} + +export interface ConnectionCodeInput { + host: string; + port: number; + token: string; + name?: string; + scheme?: "http" | "https"; + fingerprint?: string; +} + +export function encodeConnectionCode(input: ConnectionCodeInput): string { + const host = String(input.host || "").trim(); + if (!host) throw new Error("Host fehlt fuer Verbindungscode"); + const port = Number(input.port); + if (!Number.isInteger(port) || port < 1 || port > 65535) throw new Error("Port ungueltig fuer Verbindungscode"); + if (!input.token) throw new Error("Token fehlt fuer Verbindungscode"); + const payload: Record = { v: 1, h: host, p: port, t: input.token }; + if (input.name) payload.n = String(input.name); + if (input.fingerprint) payload.fp = String(input.fingerprint); + if (input.scheme && input.scheme !== "http") payload.s = String(input.scheme); + return PREFIX + base64urlEncode(JSON.stringify(payload)); +} + +export interface RemoteMeta { + publicHost: string; + name: string; +} + +function remoteMetaPath(baseDir: string): string { + return path.join(baseDir, "debug_remote.json"); +} + +export function loadRemoteMeta(baseDir: string): RemoteMeta { + try { + const parsed = JSON.parse(fs.readFileSync(remoteMetaPath(baseDir), "utf8")); + return { + publicHost: String(parsed.publicHost || ""), + name: String(parsed.name || "") + }; + } catch { + return { publicHost: "", name: "" }; + } +} + +export function saveRemoteMeta(baseDir: string, meta: RemoteMeta): void { + fs.writeFileSync(remoteMetaPath(baseDir), JSON.stringify({ publicHost: meta.publicHost, name: meta.name }, null, 2), "utf8"); +} diff --git a/src/main/constants.ts b/src/main/constants.ts new file mode 100644 index 0000000..2c1564f --- /dev/null +++ b/src/main/constants.ts @@ -0,0 +1,141 @@ +import path from "node:path"; +import os from "node:os"; +import { AppSettings } from "../shared/types"; +import { getProviderUsageDayKey } from "../shared/provider-daily-limits"; +import packageJson from "../../package.json"; + +export const APP_NAME = "Multi Debrid Downloader"; +export const APP_VERSION: string = packageJson.version; +export const API_BASE_URL = "https://api.real-debrid.com/rest/1.0"; + +export const DCRYPT_UPLOAD_URL = "https://dcrypt.it/decrypt/upload"; +export const DCRYPT_PASTE_URL = "https://dcrypt.it/decrypt/paste"; +export const DLC_SERVICE_URL = "https://service.jdownloader.org/dlcrypt/service.php?srcType=dlc&destType=pylo&data={KEY}"; +export const DLC_AES_KEY = Buffer.from("cb99b5cbc24db398", "utf8"); +export const DLC_AES_IV = Buffer.from("9bc24cb995cb8db3", "utf8"); + +export const REQUEST_RETRIES = 3; +export const CHUNK_SIZE = 512 * 1024; + +export const WRITE_BUFFER_SIZE = 512 * 1024; +export const WRITE_FLUSH_TIMEOUT_MS = 2000; +export const ALLOCATION_UNIT_SIZE = 4096; +export const STREAM_HIGH_WATER_MARK = 512 * 1024; +export const DISK_BUSY_THRESHOLD_MS = 300; +export const DISK_BUSY_STATUS_THRESHOLD_MS = 500; + +export const SAMPLE_DIR_NAMES = new Set(["sample", "samples"]); +export const SAMPLE_VIDEO_EXTENSIONS = new Set([".mkv", ".mp4", ".avi", ".mov", ".wmv", ".m4v", ".ts", ".m2ts", ".webm"]); +export const LINK_ARTIFACT_EXTENSIONS = new Set([".url", ".webloc", ".dlc", ".rsdf", ".ccf"]); +export const SAMPLE_TOKEN_RE = /(^|[._\-\s])sample([._\-\s]|$)/i; + +export const ARCHIVE_TEMP_EXTENSIONS = new Set([".rar", ".zip", ".7z", ".tmp", ".part", ".tar", ".gz", ".bz2", ".xz", ".rev"]); +export const RAR_SPLIT_RE = /\.r\d{2,3}$/i; + +export const MAX_MANIFEST_FILE_BYTES = 5 * 1024 * 1024; +export const MAX_LINK_ARTIFACT_BYTES = 256 * 1024; +export const SPEED_WINDOW_SECONDS = 1; +export const CLIPBOARD_POLL_INTERVAL_MS = 2000; + +export const DEFAULT_UPDATE_REPO = "Sucukdeluxe/multi-debrid-downloader"; + +export function defaultSettings(): AppSettings { + const baseDir = path.join(os.homedir(), "Downloads", "RealDebrid"); + return { + token: "", + realDebridUseWebLogin: false, + megaLogin: "", + megaPassword: "", + megaCredentials: "", + megaDebridApiEnabled: false, + megaDebridWebEnabled: false, + megaDebridPreferApi: true, + bestToken: "", + bestDebridUseWebLogin: false, + allDebridToken: "", + allDebridUseWebLogin: false, + ddownloadLogin: "", + ddownloadPassword: "", + oneFichierApiKey: "", + debridLinkApiKeys: "", + debridLinkDisabledKeyIds: [], + linkSnappyLogin: "", + linkSnappyPassword: "", + archivePasswordList: "", + rememberToken: true, + providerOrder: ["realdebrid", "megadebrid-api", "bestdebrid"], + providerPrimary: "realdebrid", + providerSecondary: "megadebrid-api", + providerTertiary: "bestdebrid", + autoProviderFallback: true, + outputDir: baseDir, + packageName: "", + autoExtract: true, + autoRename4sf4sj: false, + keepGermanAudioOnly: false, + germanAudioMode: "tag", + extractDir: path.join(baseDir, "_entpackt"), + collectMkvToLibrary: false, + mkvLibraryDir: path.join(baseDir, "_mkv"), + createExtractSubfolder: true, + hybridExtract: true, + cleanupMode: "none", + extractConflictMode: "overwrite", + removeLinkFilesAfterExtract: false, + removeSamplesAfterExtract: false, + enableIntegrityCheck: true, + autoResumeOnStart: true, + autoReconnect: false, + reconnectWaitSeconds: 45, + completedCleanupPolicy: "never", + maxParallel: 4, + maxParallelExtract: 2, + retryLimit: 0, + speedLimitEnabled: false, + speedLimitKbps: 0, + speedLimitMode: "global", + updateRepo: DEFAULT_UPDATE_REPO, + autoUpdateCheck: true, + clipboardWatch: false, + minimizeToTray: false, + theme: "dark" as const, + collapseNewPackages: true, + historyRetentionMode: "permanent", + historyMaxEntries: 500, + historyMaxAgeDays: 0, + accountListShowDetailedDebridLinkKeys: false, + autoSortPackagesByProgress: true, + autoSkipExtracted: false, + hideExtractedItems: true, + confirmDeleteSelection: true, + backupIncludeDownloads: false, + backupIncludeRemoteDiagnostics: false, + notifyUrl: "", + notifyMention: "", + notifyOnPackageCompleted: false, + notifyOnPackageFailed: false, + notifyOnRunFinished: false, + totalDownloadedAllTime: 0, + totalCompletedFilesAllTime: 0, + totalRuntimeAllTimeMs: 0, + bandwidthSchedules: [], + columnOrder: ["name", "size", "progress", "hoster", "account", "prio", "status", "speed"], + extractCpuPriority: "high", + autoExtractWhenStopped: true, + disabledProviders: [], + hosterRouting: {}, + providerDailyLimitBytes: {}, + providerDailyUsageBytes: {}, + providerTotalUsageBytes: {}, + debridLinkApiKeyDailyLimitBytes: {}, + debridLinkApiKeyDailyUsageBytes: {}, + debridLinkApiKeyTotalUsageBytes: {}, + megaDebridDisabledAccountIds: [], + megaDebridAccountDailyLimitBytes: {}, + megaDebridAccountDailyUsageBytes: {}, + megaDebridAccountTotalUsageBytes: {}, + debridAccountStatuses: {}, + providerDailyUsageDay: getProviderUsageDayKey(), + scheduledStartEpochMs: 0 + }; +} diff --git a/src/main/container.ts b/src/main/container.ts new file mode 100644 index 0000000..847cecb --- /dev/null +++ b/src/main/container.ts @@ -0,0 +1,316 @@ +import fs from "node:fs"; +import path from "node:path"; +import crypto from "node:crypto"; +import { DCRYPT_PASTE_URL, DCRYPT_UPLOAD_URL, DLC_AES_IV, DLC_AES_KEY, DLC_SERVICE_URL } from "./constants"; +import { compactErrorText, inferPackageNameFromLinks, isHttpLink, sanitizeFilename, uniquePreserveOrder } from "./utils"; +import { ParsedPackageInput } from "../shared/types"; + +const MAX_DLC_FILE_BYTES = 8 * 1024 * 1024; + +function isContainerSizeValidationError(error: unknown): boolean { + const text = compactErrorText(error); + return /zu groß/i.test(text) || /DLC-Datei ungültig oder zu groß/i.test(text); +} + +function decodeDcryptPayload(responseText: string): unknown { + let text = String(responseText || "").trim(); + const m = text.match(/]*>([\s\S]*?)<\/textarea>/i); + if (m) { + text = m[1].replace(/"/g, '"').replace(/&/g, "&").trim(); + } + if (!text) { + return ""; + } + try { + return JSON.parse(text); + } catch { + return text; + } +} + +function extractUrlsRecursive(data: unknown): string[] { + if (typeof data === "string") { + const found = data.match(/https?:\/\/[^\s"'<>]+/gi) ?? []; + return uniquePreserveOrder(found.filter((url) => isHttpLink(url))); + } + if (Array.isArray(data)) { + return uniquePreserveOrder(data.flatMap((item) => extractUrlsRecursive(item))); + } + if (data && typeof data === "object") { + return uniquePreserveOrder(Object.values(data as Record).flatMap((value) => extractUrlsRecursive(value))); + } + return []; +} + +function groupLinksByName(links: string[]): ParsedPackageInput[] { + const unique = uniquePreserveOrder(links.filter((link) => isHttpLink(link))); + const grouped = new Map(); + for (const link of unique) { + const name = sanitizeFilename(inferPackageNameFromLinks([link]) || "Paket"); + const current = grouped.get(name) ?? []; + current.push(link); + grouped.set(name, current); + } + return Array.from(grouped.entries()).map(([name, packageLinks]) => ({ name, links: packageLinks })); +} + +function extractPackagesFromPayload(payload: unknown): ParsedPackageInput[] { + const urls = extractUrlsRecursive(payload); + if (urls.length === 0) { + return []; + } + return groupLinksByName(urls); +} + +function decryptRcPayload(base64Rc: string): Buffer { + const rcBytes = Buffer.from(base64Rc, "base64"); + const decipher = crypto.createDecipheriv("aes-128-cbc", DLC_AES_KEY, DLC_AES_IV); + decipher.setAutoPadding(false); + return Buffer.concat([decipher.update(rcBytes), decipher.final()]); +} + +function readDlcFileWithLimit(filePath: string): Buffer { + const stat = fs.statSync(filePath); + if (stat.size <= 0 || stat.size > MAX_DLC_FILE_BYTES) { + throw new Error(`DLC-Datei ungültig oder zu groß (${Math.floor(stat.size)} B)`); + } + return fs.readFileSync(filePath); +} + +function parsePackagesFromDlcXml(xml: string): ParsedPackageInput[] { + const packages: ParsedPackageInput[] = []; + const packageRegex = /]*name="([^"]*)"[^>]*>([\s\S]*?)<\/package>/gi; + + for (let m = packageRegex.exec(xml); m; m = packageRegex.exec(xml)) { + const encodedName = m[1] || ""; + const packageBody = m[2] || ""; + let packageName = ""; + if (encodedName) { + try { + packageName = Buffer.from(encodedName, "base64").toString("utf8"); + } catch { + packageName = encodedName; + } + } + + const links: string[] = []; + const fileNames: string[] = []; + const fileRegex = /([\s\S]*?)<\/file>/gi; + for (let fm = fileRegex.exec(packageBody); fm; fm = fileRegex.exec(packageBody)) { + const fileBody = fm[1] || ""; + const urlMatch = fileBody.match(/(.*?)<\/url>/i); + if (!urlMatch) { + continue; + } + try { + const url = Buffer.from((urlMatch[1] || "").trim(), "base64").toString("utf8").trim(); + if (!isHttpLink(url)) { + continue; + } + let fileName = ""; + const fnMatch = fileBody.match(/(.*?)<\/filename>/i); + if (fnMatch?.[1]) { + try { + fileName = Buffer.from(fnMatch[1].trim(), "base64").toString("utf8").trim(); + } catch { + } + } + links.push(url); + fileNames.push(sanitizeFilename(fileName)); + } catch { + } + } + + if (links.length === 0) { + const urlRegex = /(.*?)<\/url>/gi; + for (let um = urlRegex.exec(packageBody); um; um = urlRegex.exec(packageBody)) { + try { + const url = Buffer.from((um[1] || "").trim(), "base64").toString("utf8").trim(); + if (isHttpLink(url)) { + links.push(url); + } + } catch { + } + } + } + + const uniqueLinks = uniquePreserveOrder(links); + const hasFileNames = fileNames.some((fn) => fn.length > 0); + if (uniqueLinks.length > 0) { + const pkg: ParsedPackageInput = { + name: sanitizeFilename(packageName || inferPackageNameFromLinks(uniqueLinks) || `Paket-${packages.length + 1}`), + links: uniqueLinks + }; + if (hasFileNames) { + pkg.fileNames = fileNames; + } + packages.push(pkg); + } + } + + return packages; +} + +async function decryptDlcLocal(filePath: string): Promise { + const content = readDlcFileWithLimit(filePath).toString("ascii").trim(); + if (content.length < 89) { + return []; + } + + const dlcKey = content.slice(-88); + const dlcData = content.slice(0, -88); + + const rcUrl = DLC_SERVICE_URL.replace("{KEY}", encodeURIComponent(dlcKey)); + const rcResponse = await fetch(rcUrl, { method: "GET", signal: AbortSignal.timeout(30000) }); + if (!rcResponse.ok) { + return []; + } + const rcText = await rcResponse.text(); + const rcMatch = rcText.match(/(.*?)<\/rc>/i); + if (!rcMatch) { + return []; + } + + const realKey = decryptRcPayload(rcMatch[1]).subarray(0, 16); + const encrypted = Buffer.from(dlcData, "base64"); + const decipher = crypto.createDecipheriv("aes-128-cbc", realKey, realKey); + decipher.setAutoPadding(false); + let decrypted = Buffer.concat([decipher.update(encrypted), decipher.final()]); + + if (decrypted.length === 0) { + return []; + } + const pad = decrypted[decrypted.length - 1]; + if (pad > 0 && pad <= 16 && pad <= decrypted.length) { + let validPad = true; + for (let index = 1; index <= pad; index += 1) { + if (decrypted[decrypted.length - index] !== pad) { + validPad = false; + break; + } + } + if (validPad) { + decrypted = decrypted.subarray(0, decrypted.length - pad); + } + } + + const xmlData = Buffer.from(decrypted.toString("utf8"), "base64").toString("utf8"); + return parsePackagesFromDlcXml(xmlData); +} + +function extractLinksFromResponse(text: string): string[] { + const payload = decodeDcryptPayload(text); + let links = extractUrlsRecursive(payload); + if (links.length === 0) { + links = extractUrlsRecursive(text); + } + return uniquePreserveOrder(links.filter((l) => isHttpLink(l))); +} + +async function tryDcryptUpload(fileContent: Buffer, fileName: string): Promise { + const blob = new Blob([new Uint8Array(fileContent)]); + const form = new FormData(); + form.set("dlcfile", blob, fileName); + + const response = await fetch(DCRYPT_UPLOAD_URL, { + method: "POST", + body: form, + signal: AbortSignal.timeout(30000) + }); + if (response.status === 413) { + return null; + } + const text = await response.text(); + if (!response.ok) { + throw new Error(compactErrorText(text)); + } + return extractLinksFromResponse(text); +} + +async function tryDcryptPaste(fileContent: Buffer): Promise { + const form = new FormData(); + form.set("content", fileContent.toString("ascii")); + + const response = await fetch(DCRYPT_PASTE_URL, { + method: "POST", + body: form, + signal: AbortSignal.timeout(30000) + }); + if (response.status === 413) { + return null; + } + const text = await response.text(); + if (!response.ok) { + throw new Error(compactErrorText(text)); + } + return extractLinksFromResponse(text); +} + +async function decryptDlcViaDcrypt(filePath: string): Promise { + const fileContent = readDlcFileWithLimit(filePath); + const fileName = path.basename(filePath); + const packageName = sanitizeFilename(path.basename(filePath, ".dlc")) || "Paket"; + + let links = await tryDcryptUpload(fileContent, fileName); + if (links === null) { + links = await tryDcryptPaste(fileContent); + } + if (links === null) { + throw new Error("DLC-Datei zu groß für dcrypt.it"); + } + if (links.length === 0) { + return []; + } + return [{ name: packageName, links }]; +} + +export async function importDlcContainers(filePaths: string[]): Promise { + const out: ParsedPackageInput[] = []; + const failures: string[] = []; + let sawDlc = false; + for (const filePath of filePaths) { + if (path.extname(filePath).toLowerCase() !== ".dlc") { + continue; + } + sawDlc = true; + let packages: ParsedPackageInput[] = []; + let fileFailed = false; + let fileFailureReasons: string[] = []; + try { + packages = await decryptDlcLocal(filePath); + } catch (error) { + if (isContainerSizeValidationError(error)) { + failures.push(`${path.basename(filePath)}: ${compactErrorText(error)}`); + continue; + } + fileFailed = true; + fileFailureReasons.push(`lokal: ${compactErrorText(error)}`); + packages = []; + } + if (packages.length === 0) { + try { + packages = await decryptDlcViaDcrypt(filePath); + } catch (error) { + if (isContainerSizeValidationError(error)) { + failures.push(`${path.basename(filePath)}: ${compactErrorText(error)}`); + continue; + } + fileFailed = true; + fileFailureReasons.push(`dcrypt: ${compactErrorText(error)}`); + packages = []; + } + } + if (packages.length === 0 && fileFailed) { + failures.push(`${path.basename(filePath)}: ${fileFailureReasons.join("; ")}`); + } + out.push(...packages); + } + + if (out.length === 0 && sawDlc && failures.length > 0) { + const details = failures.slice(0, 2).join(" | "); + const suffix = failures.length > 2 ? ` (+${failures.length - 2} weitere)` : ""; + throw new Error(`DLC konnte nicht importiert werden: ${details}${suffix}`); + } + + return out; +} diff --git a/src/main/conversion-trace.ts b/src/main/conversion-trace.ts new file mode 100644 index 0000000..ea83c0d --- /dev/null +++ b/src/main/conversion-trace.ts @@ -0,0 +1,190 @@ +import fs from "node:fs"; +import path from "node:path"; +import { AsyncLocalStorage } from "node:async_hooks"; +import { logTimestamp } from "./log-timestamp"; + +export interface ConversionPhase { + atMs: number; + phase: string; + provider?: string; + account?: string; + tokenState?: string; + queueWaitMs?: number; + workMs?: number; + outcome?: string; + detail?: string; +} + +export interface ConversionTrace { + startedAt: number; + itemId: string; + itemName: string; + link: string; + providerOrder: string; + notes: Record; + phases: ConversionPhase[]; +} + +const conversionContext = new AsyncLocalStorage(); + +function shortLink(link: string): string { + const raw = String(link || "").trim(); + return raw.length > 90 ? `${raw.slice(0, 90)}…` : raw; +} + +export function traceConversionPhase(phase: Omit): void { + const trace = conversionContext.getStore(); + if (!trace) { + return; + } + trace.phases.push({ ...phase, atMs: Date.now() - trace.startedAt }); +} + +export function traceConversionNote(key: string, value: string | number): void { + const trace = conversionContext.getStore(); + if (!trace) { + return; + } + trace.notes[key] = value; +} + +export function hasActiveConversionTrace(): boolean { + return conversionContext.getStore() !== undefined; +} + +export function formatConversionBlock( + trace: ConversionTrace, + outcome: string, + detail: string, + totalMs: number +): string { + const noteParts = Object.entries(trace.notes) + .map(([key, value]) => `${key}=${value}`) + .join(" "); + const header = `${logTimestamp()} [CONV] item=${trace.itemName || trace.itemId} | order=${trace.providerOrder || "?"}` + + ` | result=${outcome}${detail ? ` (${detail})` : ""} | total=${totalMs}ms${noteParts ? ` | ${noteParts}` : ""}` + + ` | link=${shortLink(trace.link)}`; + const lines = trace.phases.map((p) => { + const parts: string[] = []; + if (p.provider) parts.push(`provider=${p.provider}`); + if (p.account) parts.push(`account=${p.account}`); + if (p.tokenState) parts.push(`token=${p.tokenState}`); + if (typeof p.queueWaitMs === "number") parts.push(`queueWaitMs=${p.queueWaitMs}`); + if (typeof p.workMs === "number") parts.push(`workMs=${p.workMs}`); + if (p.outcome) parts.push(`outcome=${p.outcome}`); + if (p.detail) parts.push(`detail=${String(p.detail).replace(/\r?\n/g, "\\n")}`); + return ` +${p.atMs}ms ${p.phase}${parts.length ? ` | ${parts.join(" | ")}` : ""}`; + }); + return [header, ...lines].join("\n"); +} + +const CONVERSION_LOG_MAX_FILE_BYTES = Number(process.env.RD_CONVERSION_LOG_MAX_BYTES || 5 * 1024 * 1024); +const CONVERSION_LOG_RETENTION_DAYS = Number(process.env.RD_CONVERSION_LOG_RETENTION_DAYS || 14); + +let conversionLogPath: string | null = null; + +function rotateIfNeeded(filePath: string): void { + try { + const stat = fs.statSync(filePath); + if (stat.size < CONVERSION_LOG_MAX_FILE_BYTES) { + return; + } + const backup = `${filePath}.old`; + try { + fs.rmSync(backup, { force: true }); + } catch { + } + fs.renameSync(filePath, backup); + } catch { + } +} + +function cleanupOldBackup(filePath: string): void { + const backup = `${filePath}.old`; + try { + const stat = fs.statSync(backup); + const cutoff = Date.now() - CONVERSION_LOG_RETENTION_DAYS * 24 * 60 * 60 * 1000; + if (stat.mtimeMs < cutoff) { + fs.rmSync(backup, { force: true }); + } + } catch { + } +} + +export function initConversionLog(baseDir: string): void { + conversionLogPath = path.join(baseDir, "conversion.log"); + try { + fs.mkdirSync(path.dirname(conversionLogPath), { recursive: true }); + cleanupOldBackup(conversionLogPath); + if (!fs.existsSync(conversionLogPath)) { + fs.writeFileSync(conversionLogPath, "", "utf8"); + } + rotateIfNeeded(conversionLogPath); + if (!fs.existsSync(conversionLogPath)) { + fs.writeFileSync(conversionLogPath, "", "utf8"); + } + fs.appendFileSync(conversionLogPath, `=== Conversion Log Start: ${logTimestamp()} ===\n`, "utf8"); + } catch { + conversionLogPath = null; + } +} + +export function getConversionLogPath(): string | null { + if (!conversionLogPath) { + return null; + } + return fs.existsSync(conversionLogPath) ? conversionLogPath : null; +} + +export function shutdownConversionLog(): void { + if (!conversionLogPath) { + return; + } + try { + fs.appendFileSync(conversionLogPath, `=== Conversion Log Ende: ${logTimestamp()} ===\n`, "utf8"); + } catch { + } + conversionLogPath = null; +} + +function writeConversionBlock(block: string): void { + if (!conversionLogPath) { + return; + } + try { + rotateIfNeeded(conversionLogPath); + if (!fs.existsSync(conversionLogPath)) { + fs.writeFileSync(conversionLogPath, "", "utf8"); + } + fs.appendFileSync(conversionLogPath, `${block}\n`, "utf8"); + } catch { + } +} + +export async function runWithConversionTrace( + meta: { itemId: string; itemName: string; link: string; providerOrder: string }, + fn: () => Promise +): Promise { + const trace: ConversionTrace = { + startedAt: Date.now(), + itemId: meta.itemId, + itemName: meta.itemName, + link: meta.link, + providerOrder: meta.providerOrder, + notes: {}, + phases: [] + }; + let outcome = "OK"; + let detail = ""; + try { + const result = await conversionContext.run(trace, fn); + return result; + } catch (error) { + outcome = "FAIL"; + detail = String((error as { message?: string })?.message || error || "").replace(/^Error:\s*/i, "").slice(0, 160); + throw error; + } finally { + const totalMs = Date.now() - trace.startedAt; + writeConversionBlock(formatConversionBlock(trace, outcome, detail, totalMs)); + } +} diff --git a/src/main/debrid.ts b/src/main/debrid.ts new file mode 100644 index 0000000..a2acd03 --- /dev/null +++ b/src/main/debrid.ts @@ -0,0 +1,4156 @@ +import { parseDebridLinkApiKeys } from "../shared/debrid-link-keys"; +import { parseMegaDebridAccounts, type MegaDebridAccountEntry } from "../shared/mega-debrid-accounts"; +import { AllDebridHostInfo, AppSettings, DebridFallbackProvider, DebridLinkHostLimitInfo, DebridProvider } from "../shared/types"; +import { isDebridLinkApiKeyDailyLimitReached, isMegaDebridAccountDisabled, isMegaDebridAccountDailyLimitReached, isProviderDailyLimitReached } from "../shared/provider-daily-limits"; +import { isMegaDebridResolveFailure, germanMegaDebridResolveReason } from "../shared/mega-debrid-errors"; +import { APP_VERSION, REQUEST_RETRIES } from "./constants"; +import { logger } from "./logger"; +import { logAccountRotation } from "./account-rotation-log"; +import { traceConversionPhase } from "./conversion-trace"; +import { RealDebridClient, UnrestrictedLink } from "./realdebrid"; +import { MEGA_DEBRID_NO_SERVER_RE } from "./mega-web-fallback"; +import { isMegaFileUrl, resolveMegaFilename } from "./mega-public-api"; +import { compactErrorText, filenameFromUrl, looksLikeOpaqueFilename, sleep } from "./utils"; + +const API_TIMEOUT_MS = 30000; +const DEBRID_USER_AGENT = `RD-Node-Downloader/${APP_VERSION}`; +const RAPIDGATOR_SCAN_MAX_BYTES = 512 * 1024; + +const BEST_DEBRID_API_BASE = "https://bestdebrid.com/api/v1"; +const ALL_DEBRID_API_BASE = "https://api.alldebrid.com/v4"; +const ALL_DEBRID_API_BASE_V41 = "https://api.alldebrid.com/v4.1"; + +const MEGA_DEBRID_API_BASE = "https://www.mega-debrid.eu/api.php"; + +const ONEFICHIER_API_BASE = "https://api.1fichier.com/v1"; +const ONEFICHIER_URL_RE = /^https?:\/\/(?:www\.)?(?:1fichier\.com|alterupload\.com|cjoint\.net|desfichiers\.com|dfichiers\.com|megadl\.fr|mesfichiers\.org|piecejointe\.net|pjointe\.com|tenvoi\.com|dl4free\.com)\/\?([a-z0-9]{5,20})$/i; + +const DEBRID_LINK_API_BASE = "https://debrid-link.com/api/v2"; +const DEBRID_LINK_KEY_QUOTA_ERRORS = new Set(["maxLink", "maxData"]); +const DEBRID_LINK_HOST_QUOTA_ERRORS = new Set(["maxLinkHost", "maxDataHost"]); +const DEBRID_LINK_QUOTA_ERRORS = new Set([...DEBRID_LINK_KEY_QUOTA_ERRORS, ...DEBRID_LINK_HOST_QUOTA_ERRORS]); +const DEBRID_LINK_INVALID_TOKEN_ERRORS = new Set(["badToken", "hidedToken", "expired_token"]); +const DEBRID_LINK_RATE_LIMIT_ERRORS = new Set(["floodDetected"]); +const DEBRID_LINK_RETRYABLE_ERRORS = new Set(["internalError", "server_error"]); +const DEBRID_LINK_PROVIDER_WIDE_ERRORS = new Set(["notDebrid"]); +const DEBRID_LINK_SKIP_KEY_ERRORS = new Set([ + "disabledServerHost", + "notFreeHost", + "serverNotAllowed", + "freeServerOverload", + "maintenanceHost", + "noServerHost", + "fileNotAvailable" +]); +const DEBRID_LINK_FATAL_LINK_ERRORS = new Set(["badArguments", "badFileUrl", "badFilePassword", "fileNotFound", "hostNotValid"]); +const debridLinkKeyCooldowns = new Map(); +type DebridLinkCooldownCategory = "invalid" | "rate_limit" | "quota" | "temporary" | "skip"; +type DebridLinkCooldownDetail = { message: string; category: DebridLinkCooldownCategory }; +type DebridLinkRuntimeState = DebridLinkHostLimitInfo["state"]; +type DebridLinkRuntimeStatus = { + state: DebridLinkRuntimeState; + detail: string; + updatedAt: number; +}; +const debridLinkKeyCooldownDetails = new Map(); +const debridLinkKeyRuntimeStatuses = new Map(); +const DEBRID_LINK_KEY_COOLDOWN_MS = 120_000; +const DEBRID_LINK_INVALID_KEY_COOLDOWN_MS = 60 * 60 * 1000; +const DEBRID_LINK_RATE_LIMIT_COOLDOWN_MS = 60 * 60 * 1000; + +export function resetDebridLinkRuntimeStateForTests(): void { + debridLinkKeyCooldowns.clear(); + debridLinkKeyCooldownDetails.clear(); + debridLinkKeyRuntimeStatuses.clear(); + debridLinkKeyHostCooldowns.clear(); + debridLinkKeyHostCooldownDetails.clear(); +} + +export function pruneDebridLinkRuntimeStateForKeys(activeKeyIds: Set): void { + for (const keyId of debridLinkKeyCooldowns.keys()) { + if (!activeKeyIds.has(keyId)) { + debridLinkKeyCooldowns.delete(keyId); + debridLinkKeyCooldownDetails.delete(keyId); + } + } + for (const keyId of debridLinkKeyRuntimeStatuses.keys()) { + if (!activeKeyIds.has(keyId)) { + debridLinkKeyRuntimeStatuses.delete(keyId); + } + } + for (const stateKey of debridLinkKeyHostCooldowns.keys()) { + const sepIdx = stateKey.indexOf("|"); + const keyId = sepIdx >= 0 ? stateKey.slice(0, sepIdx) : stateKey; + if (!activeKeyIds.has(keyId)) { + debridLinkKeyHostCooldowns.delete(stateKey); + debridLinkKeyHostCooldownDetails.delete(stateKey); + } + } +} + +export function pruneExpiredDebridLinkRuntimeState(now = Date.now()): number { + let removed = 0; + const grace = 60 * 60 * 1000; + for (const [keyId, until] of debridLinkKeyCooldowns) { + if (until + grace < now) { + debridLinkKeyCooldowns.delete(keyId); + debridLinkKeyCooldownDetails.delete(keyId); + removed += 1; + } + } + for (const [keyId, status] of debridLinkKeyRuntimeStatuses) { + if (!debridLinkKeyCooldowns.has(keyId) && now - status.updatedAt > grace) { + debridLinkKeyRuntimeStatuses.delete(keyId); + removed += 1; + } + } + for (const [stateKey, until] of debridLinkKeyHostCooldowns) { + if (until + grace < now) { + debridLinkKeyHostCooldowns.delete(stateKey); + debridLinkKeyHostCooldownDetails.delete(stateKey); + removed += 1; + } + } + return removed; +} + +export function primeDebridLinkRuntimeCooldownForTests(keyId: string, cooldownMs: number, message = "Debrid-Link Key im Cooldown"): void { + setDebridLinkKeyCooldownState(keyId, cooldownMs, message, "temporary"); +} + +export function getDebridLinkKeyRuntimeStateForTests(keyId: string): DebridLinkRuntimeState | null { + const status = debridLinkKeyRuntimeStatuses.get(keyId); + return status ? status.state : null; +} + +export function getDebridLinkKeyCooldownStateForTests(keyId: string, now = Date.now()): { remainingMs: number; message: string } | null { + const state = getDebridLinkKeyCooldownState(keyId, now); + return state ? { remainingMs: state.remainingMs, message: state.message } : null; +} + +function clearDebridLinkKeyCooldownState(keyId: string): void { + debridLinkKeyCooldowns.delete(keyId); + debridLinkKeyCooldownDetails.delete(keyId); +} + +function setDebridLinkKeyRuntimeStatus(keyId: string, state: DebridLinkRuntimeState, detail: string): void { + debridLinkKeyRuntimeStatuses.set(keyId, { + state, + detail: String(detail || "").trim(), + updatedAt: Date.now() + }); +} + +function getDebridLinkKeyRuntimeStatus(keyId: string): DebridLinkRuntimeStatus | null { + return debridLinkKeyRuntimeStatuses.get(keyId) || null; +} + +function mapDebridLinkCooldownCategoryToRuntimeState(category: DebridLinkCooldownCategory): DebridLinkRuntimeState { + if (category === "invalid") { + return "invalid"; + } + if (category === "quota") { + return "quota"; + } + if (category === "rate_limit") { + return "rate_limit"; + } + return "cooldown"; +} + +function setDebridLinkKeyCooldownState( + keyId: string, + cooldownMs: number, + message: string, + category: DebridLinkCooldownCategory +): void { + if (!Number.isFinite(cooldownMs) || cooldownMs <= 0) { + clearDebridLinkKeyCooldownState(keyId); + return; + } + const newUntil = Date.now() + Math.max(1000, Math.floor(cooldownMs)); + const existingUntil = Number(debridLinkKeyCooldowns.get(keyId) || 0); + const existingDetail = debridLinkKeyCooldownDetails.get(keyId); + const newIsStrongCategory = category === "rate_limit" || category === "quota" || category === "invalid"; + const existingIsStrongCategory = existingDetail + ? (existingDetail.category === "rate_limit" || existingDetail.category === "quota" || existingDetail.category === "invalid") + : false; + if (existingUntil > Date.now()) { + if (existingUntil >= newUntil && (!newIsStrongCategory || existingIsStrongCategory)) { + return; + } + if (existingIsStrongCategory && !newIsStrongCategory) { + return; + } + } + debridLinkKeyCooldowns.set(keyId, newUntil); + debridLinkKeyCooldownDetails.set(keyId, { message, category }); + setDebridLinkKeyRuntimeStatus(keyId, mapDebridLinkCooldownCategoryToRuntimeState(category), message); +} + +function getDebridLinkKeyCooldownState( + keyId: string, + now = Date.now() +): { until: number; remainingMs: number; message: string; category: DebridLinkCooldownCategory } | null { + const until = Number(debridLinkKeyCooldowns.get(keyId) || 0); + if (!until) { + return null; + } + if (until <= now) { + clearDebridLinkKeyCooldownState(keyId); + return null; + } + const detail = debridLinkKeyCooldownDetails.get(keyId); + return { + until, + remainingMs: until - now, + message: detail?.message || "Debrid-Link Key im Cooldown", + category: detail?.category || "temporary" + }; +} + +const debridLinkKeyHostCooldowns = new Map(); +const debridLinkKeyHostCooldownDetails = new Map(); + +function makeDebridLinkKeyHostCooldownKey(keyId: string, hoster: string): string { + return `${keyId}|${hoster.toLowerCase()}`; +} + +function clearDebridLinkKeyHostCooldownState(keyId: string, hoster: string): void { + const stateKey = makeDebridLinkKeyHostCooldownKey(keyId, hoster); + debridLinkKeyHostCooldowns.delete(stateKey); + debridLinkKeyHostCooldownDetails.delete(stateKey); +} + +function setDebridLinkKeyHostCooldownState( + keyId: string, + hoster: string, + cooldownMs: number, + message: string, + category: DebridLinkCooldownCategory +): void { + if (!hoster) { + setDebridLinkKeyCooldownState(keyId, cooldownMs, message, category); + return; + } + if (!Number.isFinite(cooldownMs) || cooldownMs <= 0) { + clearDebridLinkKeyHostCooldownState(keyId, hoster); + return; + } + const stateKey = makeDebridLinkKeyHostCooldownKey(keyId, hoster); + const newUntil = Date.now() + Math.max(1000, Math.floor(cooldownMs)); + const existingUntil = Number(debridLinkKeyHostCooldowns.get(stateKey) || 0); + const existingDetail = debridLinkKeyHostCooldownDetails.get(stateKey); + const newIsStrongCategory = category === "rate_limit" || category === "quota" || category === "invalid"; + const existingIsStrongCategory = existingDetail + ? (existingDetail.category === "rate_limit" || existingDetail.category === "quota" || existingDetail.category === "invalid") + : false; + if (existingUntil > Date.now()) { + if (existingUntil >= newUntil && (!newIsStrongCategory || existingIsStrongCategory)) { + return; + } + if (existingIsStrongCategory && !newIsStrongCategory) { + return; + } + } + debridLinkKeyHostCooldowns.set(stateKey, newUntil); + debridLinkKeyHostCooldownDetails.set(stateKey, { message, category }); +} + +function getDebridLinkKeyHostCooldownState( + keyId: string, + hoster: string, + now = Date.now() +): { until: number; remainingMs: number; message: string; category: DebridLinkCooldownCategory } | null { + if (!hoster) { + return null; + } + const stateKey = makeDebridLinkKeyHostCooldownKey(keyId, hoster); + const until = Number(debridLinkKeyHostCooldowns.get(stateKey) || 0); + if (!until) { + return null; + } + if (until <= now) { + debridLinkKeyHostCooldowns.delete(stateKey); + debridLinkKeyHostCooldownDetails.delete(stateKey); + return null; + } + const detail = debridLinkKeyHostCooldownDetails.get(stateKey); + return { + until, + remainingMs: until - now, + message: detail?.message || "Debrid-Link Key fuer Host im Cooldown", + category: detail?.category || "quota" + }; +} + +type MegaDebridCooldownCategory = "invalid" | "rate_limit" | "quota" | "temporary" | "skip"; +type MegaDebridCooldownDetail = { until: number; message: string; category: MegaDebridCooldownCategory; untilRestart?: boolean }; +const megaDebridAccountCooldowns = new Map(); +const MEGA_DEBRID_ACCOUNT_COOLDOWN_MS = 120_000; +const MEGA_DEBRID_SLOW_LINK_RETRY_MS = 120_000; +const MEGA_DEBRID_INVALID_ACCOUNT_COOLDOWN_MS = 60 * 60 * 1000; + +// A Mega-Web account abort (the shared unrestrict timeout firing while this +// account ran) only cools the account down — so the next attempt rotates on — +// if it actually ran this long. Below this, it's treated as a quick user-cancel +// (no cooldown). Env-overridable for tests. +const MEGA_DEBRID_ABORT_MIN_RUN_MS_DEFAULT = 8000; +function getMegaDebridAbortMinRunMs(): number { + const fromEnv = Number(process.env.RD_MEGA_ABORT_MIN_RUN_MS ?? NaN); + return Number.isFinite(fromEnv) && fromEnv >= 0 ? Math.floor(fromEnv) : MEGA_DEBRID_ABORT_MIN_RUN_MS_DEFAULT; +} + +const megaDebridEmptyResponseStreaks = new Map(); +export const MEGA_DEBRID_EMPTY_STREAK_UNTIL_RESTART = 10; + +let megaDebridRotationCursor = 0; +let megaDebridStickyCount = 0; +// Mega-Web cacht Sessions pro Account (~20 Min). Wuerde jede Link-Aufloesung den +// Account wechseln (reines Round-Robin), zahlte JEDER Link einen kalten Login in +// die serielle Single-Flight-Queue → minutenlanger Vorlauf. Stattdessen bleibt die +// Rotation "klebrig": ein funktionierender Account wird fuer einen Schwung Links +// behalten (warm/schnell), erst danach (oder bei Limit/Cooldown/Fehler) auf den +// naechsten gewechselt. So bleibt es schnell UND ueber die Zeit kommen alle dran. +export const MEGA_DEBRID_STICKY_LINKS = 25; + +// Wie viele Umwandlungen pro Account (key `${id}:${mode}`) GERADE laufen/anstehen. Gleichzeitige +// Aufloesungen waehlen den am WENIGSTEN belegten Account → verschiedene Accounts wandeln parallel +// um (je eigene Queue in MegaWebFallback), und auch bei mehr gleichzeitigen Links als Accounts +// verteilt es sich gleichmaessig statt sich hinter einem Account zu stauen. Tiefe (nicht nur +// belegt/frei), damit ein frueh fertiger Erst-Job einen noch laufenden Folge-Job nicht "frei" meldet. +const megaDebridInFlight = new Map(); + +export function recordMegaDebridEmptyResponseStreak(accountId: string): number { + const streak = (megaDebridEmptyResponseStreaks.get(accountId) || 0) + 1; + megaDebridEmptyResponseStreaks.set(accountId, streak); + return streak; +} + +export function clearMegaDebridEmptyResponseStreak(accountId: string): void { + megaDebridEmptyResponseStreaks.delete(accountId); +} + +export function resetMegaDebridRuntimeStateForTests(): void { + megaDebridAccountCooldowns.clear(); + megaDebridEmptyResponseStreaks.clear(); + megaDebridRotationCursor = 0; + megaDebridStickyCount = 0; + megaDebridInFlight.clear(); +} + +export function getMegaDebridInFlightCountForMode(mode: "api" | "web"): number { + const suffix = `:${mode}`; + let total = 0; + for (const [key, count] of megaDebridInFlight) { + if (key.endsWith(suffix)) { + total += count; + } + } + return total; +} + +export function primeMegaDebridInFlightForTests(key: string, count: number): void { + if (count <= 0) { + megaDebridInFlight.delete(key); + return; + } + megaDebridInFlight.set(key, count); +} + +export function pruneExpiredMegaDebridRuntimeState(now = Date.now()): number { + let removed = 0; + const grace = 60 * 60 * 1000; + for (const [id, detail] of megaDebridAccountCooldowns) { + if (detail.until + grace < now) { + megaDebridAccountCooldowns.delete(id); + removed += 1; + } + } + return removed; +} + +export function primeMegaDebridRuntimeCooldownForTests(accountId: string, cooldownMs: number, message = "Mega-Debrid Account im Cooldown"): void { + setMegaDebridAccountCooldownState(accountId, cooldownMs, message, "temporary"); +} + +export function primeMegaDebridUntilRestartForTests(accountId: string, message = "Tageslimit (Test) — bis zum Tagesreset gesperrt"): void { + setMegaDebridAccountCooldownState(accountId, 0, message, "quota", true); +} + +export function classifyMegaDebridAccountFailureForTests( + error: unknown +): { fatal: boolean; cooldownMs: number; message: string; category: MegaDebridCooldownCategory; limitSignal?: boolean } { + return MegaDebridClient.classifyAccountFailure(error); +} + +function clearMegaDebridAccountCooldownState(accountId: string): void { + megaDebridAccountCooldowns.delete(accountId); +} + +function megaDebridDailyParkExpiry(now: number): number { + const midnight = new Date(now); + midnight.setHours(24, 0, 0, 0); + return Math.max(midnight.getTime(), now + MEGA_DEBRID_ACCOUNT_COOLDOWN_MS); +} + +function setMegaDebridAccountCooldownState( + accountId: string, + cooldownMs: number, + message: string, + category: MegaDebridCooldownCategory, + untilRestart = false +): void { + if (untilRestart) { + megaDebridAccountCooldowns.set(accountId, { + until: megaDebridDailyParkExpiry(Date.now()), + message, + category, + untilRestart: true + }); + return; + } + if (!Number.isFinite(cooldownMs) || cooldownMs <= 0) { + clearMegaDebridAccountCooldownState(accountId); + return; + } + megaDebridAccountCooldowns.set(accountId, { + until: Date.now() + Math.max(1000, Math.floor(cooldownMs)), + message, + category + }); +} + +export function getMegaDebridAccountCooldownState( + accountId: string, + now = Date.now() +): { until: number; remainingMs: number; message: string; category: MegaDebridCooldownCategory; untilRestart: boolean } | null { + const detail = megaDebridAccountCooldowns.get(accountId); + if (!detail) { + return null; + } + if (detail.until <= now) { + clearMegaDebridAccountCooldownState(accountId); + return null; + } + return { + until: detail.until, + remainingMs: detail.until - now, + message: detail.message, + category: detail.category, + untilRestart: detail.untilRestart === true + }; +} + +export interface ProviderRuntimeCooldown { + untilMs: number; + remainingMs: number; + message: string; + category: string; + untilRestart?: boolean; +} + +export interface ProviderRuntimeSnapshot { + capturedAtMs: number; + megaDebrid: { + rotationCursor: number; + stickyCount: number; + accounts: Array<{ + key: string; + cooldown: ProviderRuntimeCooldown | null; + inFlight: number; + emptyResponseStreak: number; + }>; + }; + debridLink: { + keys: Array<{ + keyId: string; + cooldown: ProviderRuntimeCooldown | null; + runtimeStatus: { state: string; detail: string; updatedAt: number } | null; + }>; + hostCooldowns: Array<{ key: string; cooldown: ProviderRuntimeCooldown }>; + }; +} + +export function getProviderRuntimeSnapshot(now = Date.now()): ProviderRuntimeSnapshot { + const megaKeys = new Set([ + ...megaDebridAccountCooldowns.keys(), + ...megaDebridInFlight.keys(), + ...megaDebridEmptyResponseStreaks.keys() + ]); + const megaAccounts = [...megaKeys].sort().map((key) => { + const detail = megaDebridAccountCooldowns.get(key); + return { + key, + cooldown: detail + ? { + untilMs: detail.until, + remainingMs: Math.max(0, detail.until - now), + message: detail.message, + category: detail.category, + untilRestart: detail.untilRestart === true + } + : null, + inFlight: megaDebridInFlight.get(key) ?? 0, + emptyResponseStreak: megaDebridEmptyResponseStreaks.get(key) ?? 0 + }; + }); + + const dlKeyIds = new Set([ + ...debridLinkKeyCooldowns.keys(), + ...debridLinkKeyRuntimeStatuses.keys() + ]); + const dlKeys = [...dlKeyIds].sort().map((keyId) => { + const until = Number(debridLinkKeyCooldowns.get(keyId) || 0); + const detail = debridLinkKeyCooldownDetails.get(keyId); + const status = debridLinkKeyRuntimeStatuses.get(keyId) || null; + return { + keyId, + cooldown: until > 0 + ? { + untilMs: until, + remainingMs: Math.max(0, until - now), + message: detail?.message ?? "", + category: detail?.category ?? "temporary" + } + : null, + runtimeStatus: status ? { state: status.state, detail: status.detail, updatedAt: status.updatedAt } : null + }; + }); + + const dlHostCooldowns = [...debridLinkKeyHostCooldowns].map(([key, until]) => { + const detail = debridLinkKeyHostCooldownDetails.get(key); + return { + key, + cooldown: { + untilMs: until, + remainingMs: Math.max(0, until - now), + message: detail?.message ?? "", + category: detail?.category ?? "temporary" + } + }; + }); + + return { + capturedAtMs: now, + megaDebrid: { + rotationCursor: megaDebridRotationCursor, + stickyCount: megaDebridStickyCount, + accounts: megaAccounts + }, + debridLink: { keys: dlKeys, hostCooldowns: dlHostCooldowns } + }; +} + +const LINKSNAPPY_API_BASE = "https://linksnappy.com/api"; + +const PROVIDER_LABELS: Record = { + realdebrid: "Real-Debrid", + megadebrid: "Mega-Debrid", + "megadebrid-api": "Mega-Debrid API", + "megadebrid-web": "Mega-Debrid Web", + bestdebrid: "BestDebrid", + alldebrid: "AllDebrid", + ddownload: "DDownload", + onefichier: "1Fichier", + debridlink: "Debrid-Link", + linksnappy: "LinkSnappy" +}; + +function extractHosterFromUrl(url: string): string { + try { + const host = new URL(url).hostname.replace(/^www\./, "").toLowerCase(); + const parts = host.split("."); + return parts.length >= 2 ? parts[parts.length - 2] : host; + } catch { + return ""; + } +} + +interface ProviderUnrestrictedLink extends UnrestrictedLink { + provider: DebridProvider; + providerLabel: string; +} + +export type MegaWebUnrestrictor = (link: string, signal?: AbortSignal, account?: { login: string; password: string }) => Promise; +export type AllDebridWebUnrestrictor = (link: string, signal?: AbortSignal) => Promise; +export type RealDebridWebUnrestrictor = (link: string, signal?: AbortSignal) => Promise; +export type BestDebridWebUnrestrictor = (link: string, signal?: AbortSignal) => Promise; + +interface DebridServiceOptions { + megaWebUnrestrict?: MegaWebUnrestrictor; + allDebridWebUnrestrict?: AllDebridWebUnrestrictor; + realDebridWebUnrestrict?: RealDebridWebUnrestrictor; + bestDebridWebUnrestrict?: BestDebridWebUnrestrictor; +} + +function cloneSettings(settings: AppSettings): AppSettings { + return { + ...settings, + bandwidthSchedules: (settings.bandwidthSchedules || []).map((entry) => ({ ...entry })), + debridLinkDisabledKeyIds: [...(settings.debridLinkDisabledKeyIds || [])], + providerDailyLimitBytes: { ...(settings.providerDailyLimitBytes || {}) }, + providerDailyUsageBytes: { ...(settings.providerDailyUsageBytes || {}) }, + providerTotalUsageBytes: { ...(settings.providerTotalUsageBytes || {}) }, + debridLinkApiKeyDailyLimitBytes: { ...(settings.debridLinkApiKeyDailyLimitBytes || {}) }, + debridLinkApiKeyDailyUsageBytes: { ...(settings.debridLinkApiKeyDailyUsageBytes || {}) }, + debridLinkApiKeyTotalUsageBytes: { ...(settings.debridLinkApiKeyTotalUsageBytes || {}) }, + megaDebridDisabledAccountIds: [...(settings.megaDebridDisabledAccountIds || [])], + megaDebridAccountDailyLimitBytes: { ...(settings.megaDebridAccountDailyLimitBytes || {}) }, + megaDebridAccountDailyUsageBytes: { ...(settings.megaDebridAccountDailyUsageBytes || {}) }, + megaDebridAccountTotalUsageBytes: { ...(settings.megaDebridAccountTotalUsageBytes || {}) } + }; +} + +export function isDebridLinkApiKeyDisabled(settings: AppSettings, keyId: string): boolean { + return (settings.debridLinkDisabledKeyIds || []).includes(keyId); +} + +export function getAvailableDebridLinkApiKeys(settings: AppSettings, epochMs = Date.now()) { + return parseDebridLinkApiKeys(settings.debridLinkApiKeys).filter( + (entry) => !isDebridLinkApiKeyDisabled(settings, entry.id) && !isDebridLinkApiKeyDailyLimitReached(settings, entry.id, epochMs) + ); +} + +export function getAvailableMegaDebridAccounts(settings: AppSettings, epochMs = Date.now()): MegaDebridAccountEntry[] { + return getMegaDebridAccountList(settings).filter( + (entry) => !isMegaDebridAccountDisabled(settings, entry.id) && !isMegaDebridAccountDailyLimitReached(settings, entry.id, epochMs) + ); +} + +function getMegaDebridAccountList(settings: AppSettings): MegaDebridAccountEntry[] { + const multiAccounts = parseMegaDebridAccounts(settings.megaCredentials || ""); + if (multiAccounts.length > 0) { + return multiAccounts; + } + if (settings.megaLogin?.trim() && settings.megaPassword?.trim()) { + return parseMegaDebridAccounts(settings.megaLogin.trim(), settings.megaPassword.trim()); + } + return []; +} + +function hasMegaDebridCredentials(settings: AppSettings): boolean { + return getMegaDebridAccountList(settings).length > 0; +} + +function isMegaDebridModeEnabled(settings: AppSettings, mode: "api" | "web"): boolean { + if (mode === "api") { + return settings.megaDebridApiEnabled + || (hasMegaDebridCredentials(settings) && !settings.megaDebridApiEnabled && !settings.megaDebridWebEnabled && settings.megaDebridPreferApi); + } + return settings.megaDebridWebEnabled + || (hasMegaDebridCredentials(settings) && !settings.megaDebridApiEnabled && !settings.megaDebridWebEnabled && !settings.megaDebridPreferApi); +} + +function resolveMegaDebridProvider(settings: AppSettings, provider: DebridProvider): DebridProvider { + if (provider !== "megadebrid") { + return provider; + } + if (isMegaDebridModeEnabled(settings, "api") && !isMegaDebridModeEnabled(settings, "web")) { + return "megadebrid-api"; + } + if (isMegaDebridModeEnabled(settings, "web") && !isMegaDebridModeEnabled(settings, "api")) { + return "megadebrid-web"; + } + return settings.megaDebridPreferApi ? "megadebrid-api" : "megadebrid-web"; +} + +type BestDebridRequest = { + url: string; + useAuthHeader: boolean; +}; + +function canonicalLink(link: string): string { + try { + const parsed = new URL(link); + return `${parsed.host.toLowerCase()}${parsed.pathname}${parsed.search}`; + } catch { + return link.trim().toLowerCase(); + } +} + +function shouldRetryStatus(status: number): boolean { + return status === 429 || status >= 500; +} + +function retryDelay(attempt: number): number { + return Math.min(5000, 400 * 2 ** attempt); +} + +function parseRetryAfterMs(value: string | null): number { + const text = String(value || "").trim(); + if (!text) { + return 0; + } + + const maxRetryMs = 60 * 60 * 1000; + const asSeconds = Number(text); + if (Number.isFinite(asSeconds) && asSeconds >= 0) { + return Math.min(maxRetryMs, Math.floor(asSeconds * 1000)); + } + + const asDate = Date.parse(text); + if (Number.isFinite(asDate)) { + return Math.min(maxRetryMs, Math.max(0, asDate - Date.now())); + } + + return 0; +} + +function retryDelayForResponse(response: Response, attempt: number): number { + if (response.status !== 429) { + return retryDelay(attempt); + } + const fromHeader = parseRetryAfterMs(response.headers.get("retry-after")); + return fromHeader > 0 ? fromHeader : retryDelay(attempt); +} + +function readHttpStatusFromErrorText(text: string): number { + const match = String(text || "").match(/HTTP\s+(\d{3})/i); + return match ? Number(match[1]) : 0; +} + +function isRetryableErrorText(text: string): boolean { + const status = readHttpStatusFromErrorText(text); + if (status === 429 || status >= 500) { + return true; + } + const lower = String(text || "").toLowerCase(); + return lower.includes("timeout") + || lower.includes("network") + || lower.includes("fetch failed") + || lower.includes("aborted") + || lower.includes("econnreset") + || lower.includes("enotfound") + || lower.includes("etimedout") + || lower.includes("html statt json"); +} + +async function sleepWithSignal(ms: number, signal?: AbortSignal): Promise { + if (!signal) { + await sleep(ms); + return; + } + if (signal.aborted) { + throw new Error("aborted:debrid"); + } + await new Promise((resolve, reject) => { + let timer: NodeJS.Timeout | null = setTimeout(() => { + timer = null; + signal.removeEventListener("abort", onAbort); + resolve(); + }, Math.max(0, ms)); + + const onAbort = (): void => { + if (timer) { + clearTimeout(timer); + timer = null; + } + signal.removeEventListener("abort", onAbort); + reject(new Error("aborted:debrid")); + }; + + signal.addEventListener("abort", onAbort, { once: true }); + }); +} + +function asRecord(value: unknown): Record | null { + if (!value || typeof value !== "object" || Array.isArray(value)) { + return null; + } + return value as Record; +} + +function parseJson(text: string): unknown { + try { + return JSON.parse(text) as unknown; + } catch { + return null; + } +} + +function parseJsonSafe(text: string): Record | null { + const parsed = parseJson(text); + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { + return null; + } + return parsed as Record; +} + +function pickString(payload: Record | null, keys: string[]): string { + if (!payload) { + return ""; + } + for (const key of keys) { + const value = payload[key]; + if (typeof value === "string" && value.trim()) { + return value.trim(); + } + } + return ""; +} + +function pickNumber(payload: Record | null, keys: string[]): number | null { + if (!payload) { + return null; + } + for (const key of keys) { + const value = Number(payload[key] ?? NaN); + if (Number.isFinite(value) && value >= 0) { + return Math.floor(value); + } + } + return null; +} + +function parseError(status: number, responseText: string, payload: Record | null): string { + const fromPayload = pickString(payload, ["response_text", "error", "message", "detail", "error_description"]); + if (fromPayload) { + return fromPayload; + } + const compact = compactErrorText(responseText); + if (compact && compact !== "Unbekannter Fehler") { + return compact; + } + return `HTTP ${status}`; +} + +function parseAllDebridError(payload: Record | null): string { + const errorValue = payload?.error; + if (typeof errorValue === "string" && errorValue.trim()) { + return errorValue.trim(); + } + const errorObj = asRecord(errorValue); + return pickString(errorObj, ["message", "code"]) || "AllDebrid API error"; +} + +function normalizeAllDebridHostKey(value: string): string { + return String(value || "").replace(/[^a-z0-9]+/gi, "").toLowerCase(); +} + +function toAllDebridHostState(value: unknown): AllDebridHostInfo["state"] { + if (value === true) { + return "up"; + } + if (value === false) { + return "down"; + } + const normalized = String(value || "").trim().toLowerCase(); + if (normalized === "up" || normalized === "online" || normalized === "available") { + return "up"; + } + if (normalized === "down" || normalized === "offline" || normalized === "unavailable") { + return "down"; + } + if (normalized === "not_tracked" || normalized === "not tracked") { + return "not_tracked"; + } + return "unknown"; +} + +function toAllDebridHostStatusLabel(state: AllDebridHostInfo["state"]): string { + if (state === "up") { + return "Verfügbar"; + } + if (state === "down") { + return "Unverfügbar"; + } + if (state === "not_tracked") { + return "Nicht getrackt"; + } + return "Unbekannt"; +} + +function normalizeDebridLinkHostKey(value: string): string { + return String(value || "").replace(/[^a-z0-9]+/gi, "").toLowerCase(); +} + +function parseDebridLinkSuccess(payload: Record | null): boolean { + if (!payload) { + return false; + } + if (typeof payload.success === "boolean") { + return payload.success; + } + return pickString(payload, ["result"]).toUpperCase() === "OK"; +} + +function parseDebridLinkHosters(payload: Record | null): Record[] { + const value = asRecord(payload?.value); + const hosters = value?.hosters ?? payload?.hosters; + if (Array.isArray(hosters)) { + return hosters.filter((entry): entry is Record => Boolean(asRecord(entry))).map((entry) => entry as Record); + } + return []; +} + +function findDebridLinkHostEntry(payload: Record | null, host: string): Record | null { + const wanted = normalizeDebridLinkHostKey(host); + for (const entry of parseDebridLinkHosters(payload)) { + const name = normalizeDebridLinkHostKey(pickString(entry, ["name", "host"])); + if (name === wanted) { + return entry; + } + } + return null; +} + +function parseDebridLinkErrorCode(payload: Record | null): string { + return pickString(payload, ["error", "ERR"]); +} + +function parseDebridLinkErrorDescription(payload: Record | null): string { + return pickString(payload, ["error_description", "message", "detail", "response_text", "error", "ERR"]); +} + +function looksLikeHtmlResponse(contentType: string, body: string): boolean { + const type = String(contentType || "").toLowerCase(); + if (type.includes("text/html") || type.includes("application/xhtml+xml")) { + return true; + } + return /^\s*<(!doctype\s+html|html\b)/i.test(String(body || "")); +} + +function parsePositiveNumber(value: unknown): number | null { + const numeric = Number(value ?? NaN); + if (!Number.isFinite(numeric) || numeric <= 0) { + return null; + } + return Math.floor(numeric); +} + +function parseDebridLinkNextResetMs(payload: Record | null): number { + const value = asRecord(payload?.value); + const nextReset = value?.nextResetSeconds; + const nextResetRecord = asRecord(nextReset); + const seconds = parsePositiveNumber(nextReset) + ?? parsePositiveNumber(nextResetRecord?.current) + ?? parsePositiveNumber(nextResetRecord?.value); + if (!seconds) { + return 0; + } + return Math.min(24 * 60 * 60 * 1000, seconds * 1000); +} + +function parseDebridLinkLinkEntries(value: unknown): Record[] { + if (Array.isArray(value)) { + return value + .map((entry) => asRecord(entry)) + .filter((entry): entry is Record => Boolean(entry)); + } + const entry = asRecord(value); + return entry ? [entry] : []; +} + +class DebridLinkApiError extends Error { + public readonly status: number; + public readonly code: string; + public readonly retryAfterMs: number; + public readonly payload: Record | null; + + public constructor( + status: number, + code: string, + description: string, + retryAfterMs: number, + payload: Record | null + ) { + super(description || code || `HTTP ${status || 0}`); + this.name = "DebridLinkApiError"; + this.status = status; + this.code = code; + this.retryAfterMs = retryAfterMs; + this.payload = payload; + } +} + +function toDebridLinkKeyStateLabel(state: DebridLinkHostLimitInfo["state"]): string { + if (state === "ready") { + return "Bereit"; + } + if (state === "cooldown") { + return "Cooldown"; + } + if (state === "invalid") { + return "Ungueltig"; + } + if (state === "quota") { + return "Quota"; + } + if (state === "rate_limit") { + return "Rate-Limit"; + } + if (state === "error") { + return "Fehler"; + } + return "Unbekannt"; +} + +function toDebridLinkHostStateLabel(state: DebridLinkHostLimitInfo["hostState"]): string { + if (state === "up") { + return "Online"; + } + if (state === "down") { + return "Offline"; + } + return "Unbekannt"; +} + +function shouldRetryDebridLinkApiError(error: DebridLinkApiError, attempt: number, maxAttempts: number): boolean { + if (attempt >= maxAttempts) { + return false; + } + if (error.status === 429 || error.status >= 500) { + return true; + } + return DEBRID_LINK_RETRYABLE_ERRORS.has(error.code); +} + +function retryDelayForDebridLinkApiError(error: DebridLinkApiError, attempt: number): number { + if (error.retryAfterMs > 0) { + return error.retryAfterMs; + } + return retryDelay(attempt); +} + +async function requestDebridLinkPayloadWithKey( + apiKey: { token: string }, + method: "GET" | "POST" | "DELETE", + apiPath: string, + body: Record | undefined, + signal?: AbortSignal, + maxAttempts = REQUEST_RETRIES +): Promise> { + let lastTransportError = ""; + for (let attempt = 1; attempt <= maxAttempts; attempt += 1) { + try { + const headers: Record = { + Accept: "application/json", + Authorization: `Bearer ${apiKey.token}`, + "User-Agent": DEBRID_USER_AGENT + }; + let payloadBody: string | undefined; + if (method !== "GET" && method !== "DELETE" && body) { + headers["Content-Type"] = "application/json"; + payloadBody = JSON.stringify(body); + } + + const response = await fetch(`${DEBRID_LINK_API_BASE}${apiPath}`, { + method, + headers, + body: payloadBody, + signal: withTimeoutSignal(signal, API_TIMEOUT_MS) + }); + const responseText = await response.text(); + const payload = parseJsonSafe(responseText); + if (!payload) { + const description = looksLikeHtmlResponse(response.headers.get("content-type") || "", responseText) + ? `Debrid-Link lieferte HTML statt JSON (HTTP ${response.status})` + : compactErrorText(responseText) || `Debrid-Link lieferte kein JSON (HTTP ${response.status})`; + const error = new DebridLinkApiError( + response.status, + "requestError", + description, + parseRetryAfterMs(response.headers.get("retry-after")), + null + ); + if (shouldRetryDebridLinkApiError(error, attempt, maxAttempts)) { + await sleepWithSignal(retryDelayForDebridLinkApiError(error, attempt), signal); + continue; + } + throw error; + } + + if (!response.ok || !parseDebridLinkSuccess(payload)) { + const error = new DebridLinkApiError( + response.status, + parseDebridLinkErrorCode(payload) || `HTTP ${response.status}`, + parseDebridLinkErrorDescription(payload) || `HTTP ${response.status}`, + parseRetryAfterMs(response.headers.get("retry-after")), + payload + ); + if (shouldRetryDebridLinkApiError(error, attempt, maxAttempts)) { + await sleepWithSignal(retryDelayForDebridLinkApiError(error, attempt), signal); + continue; + } + throw error; + } + + return payload; + } catch (error) { + if (error instanceof DebridLinkApiError) { + throw error; + } + lastTransportError = compactErrorText(error); + if (signal?.aborted || (/aborted/i.test(lastTransportError) && !/timeout/i.test(lastTransportError))) { + throw error; + } + if (attempt >= maxAttempts || !isRetryableErrorText(lastTransportError)) { + throw new Error(lastTransportError || "Debrid-Link Request fehlgeschlagen"); + } + await sleepWithSignal(retryDelay(attempt), signal); + } + } + throw new Error(lastTransportError || "Debrid-Link Request fehlgeschlagen"); +} + +async function fetchDebridLinkPublicHostInfo( + host: string, + signal?: AbortSignal +): Promise> { + const hostLabel = host.trim() || "rapidgator"; + try { + const response = await fetch(`${DEBRID_LINK_API_BASE}/downloader/hosts?keys=name,status,domains`, { + method: "GET", + headers: { + Accept: "application/json", + "User-Agent": DEBRID_USER_AGENT + }, + signal: withTimeoutSignal(signal, API_TIMEOUT_MS) + }); + const responseText = await response.text(); + const payload = parseJsonSafe(responseText); + if (!response.ok || !payload || !parseDebridLinkSuccess(payload)) { + throw new Error(parseError(response.status, responseText, payload)); + } + const entries = Array.isArray(payload.value) + ? payload.value.map((entry) => asRecord(entry)).filter((entry): entry is Record => Boolean(entry)) + : []; + const wanted = normalizeDebridLinkHostKey(hostLabel); + const hostEntry = entries.find((entry) => { + const name = normalizeDebridLinkHostKey(pickString(entry, ["name"])); + if (name === wanted) { + return true; + } + const domains = Array.isArray(entry.domains) ? entry.domains.map((value) => normalizeDebridLinkHostKey(String(value || ""))) : []; + return domains.some((domain) => domain === wanted); + }); + if (!hostEntry) { + return { + hostState: "unknown", + hostStateLabel: toDebridLinkHostStateLabel("unknown"), + hostNote: `${hostLabel} nicht in /downloader/hosts gefunden.` + }; + } + const statusValue = Number(hostEntry.status ?? NaN); + const hostState: DebridLinkHostLimitInfo["hostState"] = Number.isFinite(statusValue) + ? (statusValue >= 1 ? "up" : "down") + : "unknown"; + return { + hostState, + hostStateLabel: toDebridLinkHostStateLabel(hostState), + hostNote: hostState === "down" + ? `${hostLabel} ist laut Debrid-Link /downloader/hosts aktuell offline.` + : `${hostLabel} ist laut Debrid-Link /downloader/hosts erreichbar.` + }; + } catch (error) { + return { + hostState: "unknown", + hostStateLabel: toDebridLinkHostStateLabel("unknown"), + hostNote: `Hoststatus konnte nicht geladen werden: ${compactErrorText(error)}` + }; + } +} + +async function fetchDebridLinkHostLimitForKey(apiKey: { id: string; label: string; token: string }, host: string, signal?: AbortSignal): Promise { + let lastError = ""; + const hostLabel = host.trim() || "rapidgator"; + const endpoints = [`${DEBRID_LINK_API_BASE}/downloader/limits/all`, `${DEBRID_LINK_API_BASE}/downloader/limits`]; + + for (const endpoint of endpoints) { + for (let attempt = 1; attempt <= REQUEST_RETRIES; attempt += 1) { + try { + const response = await fetch(endpoint, { + method: "GET", + headers: { + Authorization: `Bearer ${apiKey.token}`, + "User-Agent": DEBRID_USER_AGENT + }, + signal: withTimeoutSignal(signal, API_TIMEOUT_MS) + }); + const text = await response.text(); + const payload = parseJsonSafe(text); + + if (response.status === 404 && endpoint.endsWith("/all")) { + break; + } + + if (!response.ok) { + const reason = parseError(response.status, text, payload); + if (shouldRetryStatus(response.status) && attempt < REQUEST_RETRIES) { + await sleepWithSignal(retryDelayForResponse(response, attempt), signal); + continue; + } + throw new Error(reason); + } + + if (!payload) { + throw new Error("Debrid-Link Limits Antwort ist kein JSON-Objekt"); + } + if (!parseDebridLinkSuccess(payload)) { + throw new Error(pickString(payload, ["error_description", "error", "message"]) || "Debrid-Link Limits fehlgeschlagen"); + } + + const hostEntry = findDebridLinkHostEntry(payload, hostLabel); + if (!hostEntry) { + if (endpoint.endsWith("/all")) { + break; + } + return { + keyId: apiKey.id, + keyLabel: apiKey.label, + host: hostLabel, + fetchedAt: Date.now(), + trafficCurrentBytes: null, + trafficMaxBytes: null, + linksCurrent: null, + linksMax: null, + note: `${hostLabel} nicht in der Debrid-Link-Limits-Antwort vorhanden.`, + state: "unknown", + stateLabel: toDebridLinkKeyStateLabel("unknown"), + stateDetail: "", + cooldownUntil: null, + cooldownRemainingMs: 0, + lastCheckedAt: Date.now(), + hostState: "unknown", + hostStateLabel: toDebridLinkHostStateLabel("unknown"), + hostNote: "" + }; + } + + const daySize = asRecord(hostEntry.daySize); + const dayCount = asRecord(hostEntry.dayCount); + return { + keyId: apiKey.id, + keyLabel: apiKey.label, + host: pickString(hostEntry, ["name", "host"]) || hostLabel, + fetchedAt: Date.now(), + trafficCurrentBytes: pickNumber(daySize, ["current"]), + trafficMaxBytes: pickNumber(daySize, ["value", "max"]), + linksCurrent: pickNumber(dayCount, ["current"]), + linksMax: pickNumber(dayCount, ["value", "max"]), + note: "", + state: "ready", + stateLabel: toDebridLinkKeyStateLabel("ready"), + stateDetail: "API erreichbar", + cooldownUntil: null, + cooldownRemainingMs: 0, + lastCheckedAt: Date.now(), + hostState: "unknown", + hostStateLabel: toDebridLinkHostStateLabel("unknown"), + hostNote: "" + }; + } catch (error) { + lastError = compactErrorText(error); + if (signal?.aborted || (/aborted/i.test(lastError) && !/timeout/i.test(lastError))) { + break; + } + if (attempt >= REQUEST_RETRIES || !isRetryableErrorText(lastError)) { + break; + } + await sleepWithSignal(retryDelay(attempt), signal); + } + } + } + + throw new Error(String(lastError || `Debrid-Link Limits für ${apiKey.label} fehlgeschlagen`).replace(/^Error:\s*/i, "")); +} + +async function fetchDebridLinkHostLimitForKeyDetailed( + apiKey: { id: string; label: string; token: string }, + host: string, + publicHostInfo: Pick, + signal?: AbortSignal +): Promise { + const hostLabel = host.trim() || "rapidgator"; + const runtimeStatus = getDebridLinkKeyRuntimeStatus(apiKey.id); + const buildInfo = (overrides: Partial): DebridLinkHostLimitInfo => ({ + keyId: apiKey.id, + keyLabel: apiKey.label, + host: hostLabel, + fetchedAt: Date.now(), + trafficCurrentBytes: null, + trafficMaxBytes: null, + linksCurrent: null, + linksMax: null, + note: "", + state: runtimeStatus?.state || "unknown", + stateLabel: toDebridLinkKeyStateLabel(runtimeStatus?.state || "unknown"), + stateDetail: runtimeStatus?.detail || "", + cooldownUntil: null, + cooldownRemainingMs: 0, + lastCheckedAt: runtimeStatus?.updatedAt || null, + hostState: publicHostInfo.hostState, + hostStateLabel: publicHostInfo.hostStateLabel, + hostNote: publicHostInfo.hostNote, + ...overrides + }); + + const cooldownState = getDebridLinkKeyCooldownState(apiKey.id); + if (cooldownState) { + const state = mapDebridLinkCooldownCategoryToRuntimeState(cooldownState.category); + return buildInfo({ + state, + stateLabel: toDebridLinkKeyStateLabel(state), + stateDetail: cooldownState.message, + cooldownUntil: cooldownState.until, + cooldownRemainingMs: cooldownState.remainingMs, + lastCheckedAt: runtimeStatus?.updatedAt || Date.now(), + note: cooldownState.message + }); + } + + for (const apiPath of ["/downloader/limits/all", "/downloader/limits"]) { + try { + const payload = await requestDebridLinkPayloadWithKey(apiKey, "GET", apiPath, undefined, signal); + const hostEntry = findDebridLinkHostEntry(payload, hostLabel); + if (!hostEntry) { + if (apiPath.endsWith("/all")) { + continue; + } + clearDebridLinkKeyCooldownState(apiKey.id); + setDebridLinkKeyRuntimeStatus(apiKey.id, "ready", "API erreichbar"); + return buildInfo({ + state: "ready", + stateLabel: toDebridLinkKeyStateLabel("ready"), + stateDetail: "API erreichbar", + lastCheckedAt: Date.now(), + note: `${hostLabel} nicht in der Debrid-Link-Limits-Antwort vorhanden.` + }); + } + + const daySize = asRecord(hostEntry.daySize); + const dayCount = asRecord(hostEntry.dayCount); + clearDebridLinkKeyCooldownState(apiKey.id); + setDebridLinkKeyRuntimeStatus(apiKey.id, "ready", "API erreichbar"); + return buildInfo({ + host: pickString(hostEntry, ["name", "host"]) || hostLabel, + trafficCurrentBytes: pickNumber(daySize, ["current"]), + trafficMaxBytes: pickNumber(daySize, ["value", "max"]), + linksCurrent: pickNumber(dayCount, ["current"]), + linksMax: pickNumber(dayCount, ["value", "max"]), + state: "ready", + stateLabel: toDebridLinkKeyStateLabel("ready"), + stateDetail: "API erreichbar", + lastCheckedAt: Date.now(), + note: "" + }); + } catch (error) { + if (error instanceof DebridLinkApiError && error.status === 404 && apiPath.endsWith("/all")) { + continue; + } + + const checkedAt = Date.now(); + if (error instanceof DebridLinkApiError) { + const code = String(error.code || "").trim() || `HTTP ${error.status}`; + const description = error.message || code; + + if (DEBRID_LINK_INVALID_TOKEN_ERRORS.has(code)) { + const detail = `API-Key ungueltig oder deaktiviert (${code}: ${description})`; + setDebridLinkKeyCooldownState(apiKey.id, DEBRID_LINK_INVALID_KEY_COOLDOWN_MS, detail, "invalid"); + const nextCooldown = getDebridLinkKeyCooldownState(apiKey.id, checkedAt); + return buildInfo({ + state: "invalid", + stateLabel: toDebridLinkKeyStateLabel("invalid"), + stateDetail: detail, + cooldownUntil: nextCooldown?.until || null, + cooldownRemainingMs: nextCooldown?.remainingMs || 0, + lastCheckedAt: checkedAt, + note: detail + }); + } + + if (DEBRID_LINK_RATE_LIMIT_ERRORS.has(code) || error.status === 429) { + const detail = `API-Rate-Limit erreicht (${code}: ${description})`; + setDebridLinkKeyCooldownState(apiKey.id, error.retryAfterMs || DEBRID_LINK_RATE_LIMIT_COOLDOWN_MS, detail, "rate_limit"); + const nextCooldown = getDebridLinkKeyCooldownState(apiKey.id, checkedAt); + return buildInfo({ + state: "rate_limit", + stateLabel: toDebridLinkKeyStateLabel("rate_limit"), + stateDetail: detail, + cooldownUntil: nextCooldown?.until || null, + cooldownRemainingMs: nextCooldown?.remainingMs || 0, + lastCheckedAt: checkedAt, + note: detail + }); + } + + if (DEBRID_LINK_QUOTA_ERRORS.has(code)) { + const detail = `Quota erreicht (${code}: ${description})`; + setDebridLinkKeyCooldownState(apiKey.id, parseDebridLinkNextResetMs(error.payload) || DEBRID_LINK_KEY_COOLDOWN_MS, detail, "quota"); + const nextCooldown = getDebridLinkKeyCooldownState(apiKey.id, checkedAt); + return buildInfo({ + state: "quota", + stateLabel: toDebridLinkKeyStateLabel("quota"), + stateDetail: detail, + cooldownUntil: nextCooldown?.until || null, + cooldownRemainingMs: nextCooldown?.remainingMs || 0, + lastCheckedAt: checkedAt, + note: detail + }); + } + + const detail = `${code}: ${description}`; + setDebridLinkKeyRuntimeStatus(apiKey.id, "error", detail); + return buildInfo({ + state: "error", + stateLabel: toDebridLinkKeyStateLabel("error"), + stateDetail: detail, + lastCheckedAt: checkedAt, + note: detail + }); + } + + const detail = compactErrorText(error).replace(/^Error:\s*/i, "") || `Debrid-Link Limits fuer ${apiKey.label} fehlgeschlagen`; + setDebridLinkKeyRuntimeStatus(apiKey.id, "error", detail); + return buildInfo({ + state: "error", + stateLabel: toDebridLinkKeyStateLabel("error"), + stateDetail: detail, + lastCheckedAt: checkedAt, + note: detail + }); + } + } + + return buildInfo({ + state: "unknown", + stateLabel: toDebridLinkKeyStateLabel("unknown"), + stateDetail: `Keine Limits fuer ${apiKey.label} gefunden`, + lastCheckedAt: Date.now(), + note: `Keine Limits fuer ${apiKey.label} gefunden` + }); +} + +function uniqueProviderOrder(order: readonly DebridProvider[]): DebridProvider[] { + const seen = new Set(); + const result: DebridProvider[] = []; + for (const provider of order) { + if (seen.has(provider)) { + continue; + } + seen.add(provider); + result.push(provider); + } + return result; +} + +function toProviderOrder(primary: DebridProvider, secondary: DebridFallbackProvider, tertiary: DebridFallbackProvider): DebridProvider[] { + const order: DebridProvider[] = [primary]; + if (secondary !== "none") { + order.push(secondary); + } + if (tertiary !== "none") { + order.push(tertiary); + } + return uniqueProviderOrder(order); +} + +export function leadProviderChainWith(order: readonly DebridProvider[], preferred: DebridProvider | null | undefined): DebridProvider[] { + if (!preferred || !order.includes(preferred)) { + return [...order]; + } + return [preferred, ...order.filter((provider) => provider !== preferred)]; +} + +function isRapidgatorLink(link: string): boolean { + try { + const hostname = new URL(link).hostname.toLowerCase(); + return hostname === "rapidgator.net" + || hostname.endsWith(".rapidgator.net") + || hostname === "rg.to" + || hostname.endsWith(".rg.to") + || hostname === "rapidgator.asia" + || hostname.endsWith(".rapidgator.asia"); + } catch { + return false; + } +} + +function decodeHtmlEntities(text: string): string { + return text + .replace(/&/g, "&") + .replace(/"/g, '"') + .replace(/'/g, "'") + .replace(/</g, "<") + .replace(/>/g, ">"); +} + +function safeDecode(value: string): string { + try { + return decodeURIComponent(value); + } catch { + return value; + } +} + +function looksLikeFileName(value: string): boolean { + return /\.(?:part\d+\.rar|r\d{2}|rar|zip|7z|tar|gz|bz2|xz|iso|mkv|mp4|avi|mov|wmv|m4v|m2ts|ts|webm|mp3|flac|aac|srt|ass|sub)$/i.test(value); +} + +export function normalizeResolvedFilename(value: string): string { + const candidate = decodeHtmlEntities(String(value || "")) + .replace(/<[^>]*>/g, " ") + .replace(/\s+/g, " ") + .replace(/^['"]+|['"]+$/g, "") + .replace(/^download\s+file\s+/i, "") + .replace(/\s*[-|]\s*rapidgator.*$/i, "") + .trim(); + if (!candidate || candidate.length > 260 || !looksLikeFileName(candidate) || looksLikeOpaqueFilename(candidate)) { + return ""; + } + return candidate; +} + +export function filenameFromRapidgatorUrlPath(link: string): string { + try { + const parsed = new URL(link); + const pathParts = parsed.pathname.split("/").filter(Boolean); + for (let index = pathParts.length - 1; index >= 0; index -= 1) { + const raw = safeDecode(pathParts[index]).replace(/\.html?$/i, "").trim(); + const normalized = normalizeResolvedFilename(raw); + if (normalized) { + return normalized; + } + } + return ""; + } catch { + return ""; + } +} + +export function extractRapidgatorFilenameFromHtml(html: string): string { + const patterns = [ + /]+(?:property=["']og:title["'][^>]+content=["']([^"']+)["']|content=["']([^"']+)["'][^>]+property=["']og:title["'])/i, + /]+(?:name=["']title["'][^>]+content=["']([^"']+)["']|content=["']([^"']+)["'][^>]+name=["']title["'])/i, + /([^<]{1,260})<\/title>/i, + /(?:Dateiname|File\s*name)\s*[:\-]\s*<[^>]*>\s*([^<]{1,260})\s*</i, + /(?:Dateiname|File\s*name)\s*[:\-]\s*([^<\r\n]{1,260})/i + ]; + + for (const pattern of patterns) { + const match = html.match(pattern); + const raw = match?.[1] || match?.[2] || ""; + const normalized = normalizeResolvedFilename(raw); + if (normalized) { + return normalized; + } + } + + return ""; +} + +async function runWithConcurrency<T>(items: T[], concurrency: number, worker: (item: T) => Promise<void>): Promise<void> { + if (items.length === 0) { + return; + } + const size = Math.max(1, Math.min(concurrency, items.length)); + let index = 0; + let firstError: unknown = null; + const next = (): T | undefined => { + if (firstError || index >= items.length) { + return undefined; + } + const item = items[index]; + index += 1; + return item; + }; + const runners = Array.from({ length: size }, async () => { + let current = next(); + while (current !== undefined) { + try { + await worker(current); + } catch (error) { + if (!firstError) { + firstError = error; + } + } + current = next(); + } + }); + await Promise.all(runners); + if (firstError) { + throw firstError; + } +} + +function withTimeoutSignal(signal: AbortSignal | undefined, timeoutMs: number): AbortSignal { + if (!signal) { + return AbortSignal.timeout(timeoutMs); + } + return AbortSignal.any([signal, AbortSignal.timeout(timeoutMs)]); +} + +async function readResponseTextLimited(response: Response, maxBytes: number, signal?: AbortSignal): Promise<string> { + const body = response.body; + if (!body) { + return ""; + } + + const reader = body.getReader(); + const chunks: Buffer[] = []; + let readBytes = 0; + + try { + while (readBytes < maxBytes) { + if (signal?.aborted) { + throw new Error("aborted:debrid"); + } + + const { done, value } = await reader.read(); + if (done || !value || value.byteLength === 0) { + break; + } + + const remaining = maxBytes - readBytes; + const slice = value.byteLength > remaining ? value.subarray(0, remaining) : value; + chunks.push(Buffer.from(slice)); + readBytes += slice.byteLength; + } + } finally { + try { + await reader.cancel(); + } catch { + } + try { + reader.releaseLock(); + } catch { + } + } + + return Buffer.concat(chunks).toString("utf8"); +} + +async function resolveRapidgatorFilename(link: string, signal?: AbortSignal): Promise<string> { + if (!isRapidgatorLink(link)) { + return ""; + } + const fromUrl = filenameFromRapidgatorUrlPath(link); + if (fromUrl) { + return fromUrl; + } + + if (signal?.aborted) { + throw new Error("aborted:debrid"); + } + + for (let attempt = 1; attempt <= REQUEST_RETRIES + 2; attempt += 1) { + try { + const response = await fetch(link, { + method: "GET", + headers: { + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/133.0.0.0 Safari/537.36", + Accept: "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + "Accept-Language": "en-US,en;q=0.9,de;q=0.8" + }, + signal: withTimeoutSignal(signal, API_TIMEOUT_MS) + }); + if (!response.ok) { + try { await response.body?.cancel(); } catch { } + if (shouldRetryStatus(response.status) && attempt < REQUEST_RETRIES + 2) { + await sleepWithSignal(retryDelayForResponse(response, attempt), signal); + continue; + } + return ""; + } + + const contentType = String(response.headers.get("content-type") || "").toLowerCase(); + const contentLength = Number(response.headers.get("content-length") || NaN); + if (contentType + && !contentType.includes("text/html") + && !contentType.includes("application/xhtml") + && !contentType.includes("text/plain") + && !contentType.includes("text/xml") + && !contentType.includes("application/xml")) { + try { await response.body?.cancel(); } catch { } + return ""; + } + if (!contentType && Number.isFinite(contentLength) && contentLength > RAPIDGATOR_SCAN_MAX_BYTES) { + try { await response.body?.cancel(); } catch { } + return ""; + } + + const html = await readResponseTextLimited(response, RAPIDGATOR_SCAN_MAX_BYTES, signal); + const fromHtml = extractRapidgatorFilenameFromHtml(html); + if (fromHtml) { + return fromHtml; + } + return ""; + } catch (error) { + const errorText = compactErrorText(error); + if (signal?.aborted || (/aborted/i.test(errorText) && !/timeout/i.test(errorText))) { + throw error; + } + if (attempt >= REQUEST_RETRIES + 2 || !isRetryableErrorText(errorText)) { + return ""; + } + } + + if (attempt < REQUEST_RETRIES + 2) { + await sleepWithSignal(retryDelay(attempt), signal); + } + } + + return ""; +} + +export interface RapidgatorCheckResult { + online: boolean; + fileName: string; + fileSize: string | null; +} + +const RG_FILE_ID_RE = /\/file\/([a-z0-9]{32}|\d+)/i; +const RG_FILE_NOT_FOUND_RE = />\s*404\s*File not found/i; +const RG_FILESIZE_RE = /File\s*size:\s*<strong>([^<>"]+)<\/strong>/i; + +export async function checkRapidgatorOnline( + link: string, + signal?: AbortSignal +): Promise<RapidgatorCheckResult | null> { + if (!isRapidgatorLink(link)) { + return null; + } + + const fileIdMatch = link.match(RG_FILE_ID_RE); + if (!fileIdMatch) { + return null; + } + const fileId = fileIdMatch[1]; + const headers = { + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/133.0.0.0 Safari/537.36", + Accept: "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + "Accept-Language": "en-US,en;q=0.9,de;q=0.8" + }; + + for (let attempt = 1; attempt <= REQUEST_RETRIES + 1; attempt += 1) { + try { + if (signal?.aborted) throw new Error("aborted:debrid"); + + const response = await fetch(link, { + method: "HEAD", + redirect: "follow", + headers, + signal: withTimeoutSignal(signal, 15000) + }); + + if (response.status === 404) { + return { online: false, fileName: "", fileSize: null }; + } + + if (response.ok) { + const finalUrl = response.url || link; + if (!finalUrl.includes(fileId)) { + return { online: false, fileName: "", fileSize: null }; + } + const fileName = filenameFromRapidgatorUrlPath(link); + return { online: true, fileName, fileSize: null }; + } + + if (shouldRetryStatus(response.status) && attempt <= REQUEST_RETRIES) { + await sleepWithSignal(retryDelayForResponse(response, attempt), signal); + continue; + } + + break; + } catch (error) { + const errorText = compactErrorText(error); + if (signal?.aborted || (/aborted/i.test(errorText) && !/timeout/i.test(errorText))) throw error; + if (attempt > REQUEST_RETRIES || !isRetryableErrorText(errorText)) { + break; + } + await sleepWithSignal(retryDelay(attempt), signal); + } + } + + for (let attempt = 1; attempt <= REQUEST_RETRIES + 1; attempt += 1) { + try { + if (signal?.aborted) throw new Error("aborted:debrid"); + + const response = await fetch(link, { + method: "GET", + redirect: "follow", + headers, + signal: withTimeoutSignal(signal, API_TIMEOUT_MS) + }); + + if (response.status === 404) { + try { await response.body?.cancel(); } catch { } + return { online: false, fileName: "", fileSize: null }; + } + + if (!response.ok) { + try { await response.body?.cancel(); } catch { } + if (shouldRetryStatus(response.status) && attempt <= REQUEST_RETRIES) { + await sleepWithSignal(retryDelayForResponse(response, attempt), signal); + continue; + } + return null; + } + + const finalUrl = response.url || link; + if (!finalUrl.includes(fileId)) { + try { await response.body?.cancel(); } catch { } + return { online: false, fileName: "", fileSize: null }; + } + + const html = await readResponseTextLimited(response, RAPIDGATOR_SCAN_MAX_BYTES, signal); + + if (RG_FILE_NOT_FOUND_RE.test(html)) { + return { online: false, fileName: "", fileSize: null }; + } + + const fileName = extractRapidgatorFilenameFromHtml(html) || filenameFromRapidgatorUrlPath(link); + const sizeMatch = html.match(RG_FILESIZE_RE); + const fileSize = sizeMatch ? sizeMatch[1].trim() : null; + + return { online: true, fileName, fileSize }; + } catch (error) { + const errorText = compactErrorText(error); + if (signal?.aborted || (/aborted/i.test(errorText) && !/timeout/i.test(errorText))) throw error; + if (attempt > REQUEST_RETRIES || !isRetryableErrorText(errorText)) { + return null; + } + } + + if (attempt <= REQUEST_RETRIES) { + await sleepWithSignal(retryDelay(attempt), signal); + } + } + + return null; +} + +function buildBestDebridRequests(link: string, token: string): BestDebridRequest[] { + const linkParam = encodeURIComponent(link); + const safeToken = String(token || "").trim(); + const useAuthHeader = Boolean(safeToken); + return [ + { + url: `${BEST_DEBRID_API_BASE}/generateLink?link=${linkParam}`, + useAuthHeader + } + ]; +} + +class MegaDebridClient { + private megaWebUnrestrict?: MegaWebUnrestrictor; + + private login: string; + + private password: string; + + private mode: "api" | "web"; + + private allowApiFallback: boolean; + + private static cachedApiTokens = new Map<string, { token: string; at: number }>(); + + private static pendingConnects = new Map<string, Promise<string | null>>(); + + public static pruneCachedTokensNotIn(activeLogins: Iterable<string>): void { + const keep = new Set<string>(); + for (const login of activeLogins) { + keep.add(String(login || "").toLowerCase()); + } + for (const login of MegaDebridClient.cachedApiTokens.keys()) { + if (!keep.has(login)) { + MegaDebridClient.cachedApiTokens.delete(login); + } + } + for (const login of MegaDebridClient.pendingConnects.keys()) { + if (!keep.has(login)) { + MegaDebridClient.pendingConnects.delete(login); + } + } + } + + public static clearCachedApiToken(login: string): void { + const key = String(login || "").toLowerCase(); + MegaDebridClient.cachedApiTokens.delete(key); + MegaDebridClient.pendingConnects.delete(key); + } + + public constructor(login: string, password: string, mode: "api" | "web", allowApiFallback: boolean, megaWebUnrestrict?: MegaWebUnrestrictor) { + this.login = login; + this.password = password; + this.mode = mode; + this.allowApiFallback = allowApiFallback; + this.megaWebUnrestrict = megaWebUnrestrict; + } + + private get cacheKey(): string { + return this.login.trim().toLowerCase(); + } + + private async connectApi(signal?: AbortSignal): Promise<string | null> { + const key = this.cacheKey; + const cached = MegaDebridClient.cachedApiTokens.get(key); + if (cached && cached.token && Date.now() - cached.at < 20 * 60 * 1000) { + traceConversionPhase({ phase: "token", provider: "megadebrid-api", tokenState: `cached(${Math.floor((Date.now() - cached.at) / 1000)}s)`, outcome: "ok" }); + return cached.token; + } + + const pending = MegaDebridClient.pendingConnects.get(key); + if (pending) { + traceConversionPhase({ phase: "token", provider: "megadebrid-api", tokenState: "pending-join", outcome: "ok" }); + return pending; + } + + const promise = this.doConnectApi(signal).finally(() => { + MegaDebridClient.pendingConnects.delete(key); + }); + MegaDebridClient.pendingConnects.set(key, promise); + return promise; + } + + private clearTokenCache(): void { + MegaDebridClient.cachedApiTokens.delete(this.cacheKey); + } + + private async doConnectApi(signal?: AbortSignal): Promise<string | null> { + const connectStartedAt = Date.now(); + const url = `${MEGA_DEBRID_API_BASE}?action=connectUser&login=${encodeURIComponent(this.login)}&password=${encodeURIComponent(this.password)}`; + const response = await fetch(url, { + headers: { "User-Agent": DEBRID_USER_AGENT }, + signal: withTimeoutSignal(signal, API_TIMEOUT_MS) + }); + const text = await response.text(); + if (!response.ok) { + if (response.status === 401 || response.status === 403) { + this.clearTokenCache(); + } + traceConversionPhase({ phase: "token", provider: "megadebrid-api", tokenState: "fresh-login", workMs: Date.now() - connectStartedAt, outcome: "error", detail: `HTTP ${response.status}` }); + return null; + } + const payload = parseJsonSafe(text); + if (!payload || payload.response_code !== "ok") { + if (payload && String(payload.response_code || "").toLowerCase().includes("token")) { + this.clearTokenCache(); + } + traceConversionPhase({ phase: "token", provider: "megadebrid-api", tokenState: "fresh-login", workMs: Date.now() - connectStartedAt, outcome: "error", detail: `response_code=${payload?.response_code || "?"} ${String(payload?.response_text || "").slice(0, 80)}`.trim() }); + return null; + } + const token = String(payload.token || "").trim(); + if (!token) { + traceConversionPhase({ phase: "token", provider: "megadebrid-api", tokenState: "fresh-login", workMs: Date.now() - connectStartedAt, outcome: "error", detail: "leeres Token" }); + return null; + } + MegaDebridClient.cachedApiTokens.set(this.cacheKey, { token, at: Date.now() }); + traceConversionPhase({ phase: "token", provider: "megadebrid-api", tokenState: "fresh-login", workMs: Date.now() - connectStartedAt, outcome: "ok" }); + return token; + } + + private async unrestrictViaApi(link: string, signal?: AbortSignal): Promise<UnrestrictedLink | null> { + const token = await this.connectApi(signal); + if (!token) { + return null; + } + + const getLinkStartedAt = Date.now(); + const url = `${MEGA_DEBRID_API_BASE}?action=getLink&token=${encodeURIComponent(token)}`; + const response = await fetch(url, { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + "User-Agent": DEBRID_USER_AGENT + }, + body: new URLSearchParams({ link }), + signal: withTimeoutSignal(signal, API_TIMEOUT_MS) + }); + const text = await response.text(); + if (!response.ok) { + if (response.status === 401 || response.status === 403) { + this.clearTokenCache(); + } + traceConversionPhase({ phase: "api-getlink", provider: "megadebrid-api", workMs: Date.now() - getLinkStartedAt, outcome: "error", detail: `HTTP ${response.status}` }); + return null; + } + const payload = parseJsonSafe(text); + if (!payload || payload.response_code !== "ok") { + const tokenInvalidated = Boolean(payload && String(payload.response_code || "").includes("token")); + if (tokenInvalidated) { + this.clearTokenCache(); + } + const errorText = String(payload?.response_text || "").trim(); + traceConversionPhase({ phase: "api-getlink", provider: "megadebrid-api", workMs: Date.now() - getLinkStartedAt, outcome: "error", detail: `response_code=${payload?.response_code || "?"}${tokenInvalidated ? " (token-cache-geleert)" : ""} ${errorText}`.trim() }); + if (errorText) { + throw new Error(`Mega-Debrid API: ${errorText}`); + } + return null; + } + + const directUrl = String(payload.debridLink || "").trim(); + if (!directUrl) { + traceConversionPhase({ phase: "api-getlink", provider: "megadebrid-api", workMs: Date.now() - getLinkStartedAt, outcome: "error", detail: "kein debridLink" }); + return null; + } + traceConversionPhase({ phase: "api-getlink", provider: "megadebrid-api", workMs: Date.now() - getLinkStartedAt, outcome: "ok" }); + const fileName = String(payload.filename || "").trim() || filenameFromUrl(directUrl) || filenameFromUrl(link); + return { + directUrl, + fileName, + fileSize: null, + retriesUsed: 0, + sourceLabel: "API" + }; + } + + private async unrestrictViaWeb(link: string, signal?: AbortSignal): Promise<UnrestrictedLink> { + if (!this.megaWebUnrestrict) { + throw new Error("Mega-Web-Fallback nicht verfügbar"); + } + let lastError = ""; + for (let attempt = 1; attempt <= REQUEST_RETRIES; attempt += 1) { + if (signal?.aborted) { + throw new Error("aborted:debrid"); + } + const web = await this.megaWebUnrestrict(link, signal, { login: this.login, password: this.password }).catch((error) => { + lastError = compactErrorText(error); + return null; + }); + if (signal?.aborted) { + if (/queue.?timeout/i.test(lastError)) { + throw new Error(lastError.replace(/^Error:\s*/i, "")); + } + throw new Error("aborted:debrid"); + } + if (web?.directUrl) { + web.retriesUsed = attempt - 1; + web.sourceLabel = "Web"; + return web; + } + if (web && !web.directUrl) { + throw new Error("Mega-Web Antwort ohne Download-Link"); + } + if (!lastError) { + lastError = "Mega-Web Antwort leer"; + } + if (/permanent ungültig|hosternotavailable|file.?not.?found|file.?unavailable|link.?is.?dead/i.test(lastError) || MEGA_DEBRID_NO_SERVER_RE.test(lastError)) { + break; + } + if (attempt < REQUEST_RETRIES) { + await sleepWithSignal(retryDelay(attempt), signal); + } + } + throw new Error(String(lastError || "Mega-Web Unrestrict fehlgeschlagen").replace(/^Error:\s*/i, "")); + } + + public async unrestrictLink(link: string, signal?: AbortSignal): Promise<UnrestrictedLink> { + if (this.mode === "api" && this.login.trim() && this.password.trim()) { + try { + const apiResult = await this.unrestrictViaApi(link, signal); + if (apiResult) { + logger.info(`Mega-Debrid (API) unrestrict OK: ${apiResult.fileName}`); + return apiResult; + } + throw new Error("Mega-Debrid API: Linkgenerierung lieferte kein Ergebnis"); + } catch (error) { + const errorText = compactErrorText(error); + if (signal?.aborted || (/aborted/i.test(errorText) && !/timeout/i.test(errorText))) { + throw error; + } + if (!this.allowApiFallback) { + throw error; + } + logger.warn(`Mega-Debrid API fehlgeschlagen, versuche Web-Fallback: ${errorText}`); + } + return this.unrestrictViaWeb(link, signal); + } + + return this.unrestrictViaWeb(link, signal); + } + + public static async unrestrictWithAccounts( + settings: AppSettings, + mode: "api" | "web", + allowApiFallback: boolean, + link: string, + megaWebUnrestrict: MegaWebUnrestrictor | undefined, + signal?: AbortSignal + ): Promise<UnrestrictedLink> { + const accounts = getMegaDebridAccountList(settings); + if (accounts.length === 0) { + throw new Error("Mega-Debrid: Kein Account konfiguriert"); + } + + if (getAvailableMegaDebridAccounts(settings).length === 0) { + throw new Error("Mega-Debrid: Kein aktiver Account verfuegbar (deaktiviert oder am Tageslimit)"); + } + + const failures: string[] = []; + let usableAccountSeen = false; + const cooldownFailures: string[] = []; + let earliestCooldownUntil = 0; + let parkedUntilRestartSeen = false; + const totalAccounts = accounts.length; + const providerName = `Mega-Debrid ${mode === "api" ? "API" : "Web"}`; + const linkShort = String(link || "").slice(0, 80); + + // Klebrige Rotation: gestartet wird beim Cursor (zuletzt erfolgreich genutzter, + // also warmer Account), danach der Reihe nach als Failover. Alle Skip-/Cooldown- + // Checks bleiben. Der Cursor wird erst im Erfolgszweig weitergesetzt — nach einem + // Schwung erfolgreicher Umwandlungen (MEGA_DEBRID_STICKY_LINKS) — sodass aufeinander + // folgende Links auf demselben warmen Account laufen statt jeweils neu einzuloggen. + const startOffset = ((megaDebridRotationCursor % accounts.length) + accounts.length) % accounts.length; + const cursorOrder: { account: MegaDebridAccountEntry; idx: number }[] = []; + for (let step = 0; step < accounts.length; step += 1) { + const idx = (startOffset + step) % accounts.length; + cursorOrder.push({ account: accounts[idx], idx }); + } + // Parallel ueber mehrere Accounts: nach AKTUELLER Auslastung (in-flight-Tiefe) sortieren — + // am wenigsten belegter Account zuerst, cursorOrder als stabiler Gleichstand-Tiebreak. So + // verteilen sich auch MEHR gleichzeitige Aufloesungen als Accounts gleichmaessig (statt sich + // alle hinter dem Cursor-Account zu stauen). Nichts in-flight (sequenziell) => alle Tiefe 0 => + // Reihenfolge == cursorOrder => bleibt klebrig beim warmen Account. + const inFlightDepth = (entry: { account: MegaDebridAccountEntry }): number => + megaDebridInFlight.get(`${entry.account.id}:${mode}`) ?? 0; + const orderedEntries = cursorOrder + .map((entry, position) => ({ entry, position })) + .sort((a, b) => (inFlightDepth(a.entry) - inFlightDepth(b.entry)) || (a.position - b.position)) + .map((wrapped) => wrapped.entry); + + for (let orderPos = 0; orderPos < orderedEntries.length; orderPos += 1) { + const entry = orderedEntries[orderPos]; + const account = entry.account; + const idx = entry.idx; + const accountLabel = ` (${account.label}/${totalAccounts}, ${account.maskedLogin})`; + const rotationLabel = `${account.label}/${totalAccounts} (${account.maskedLogin})`; + + if (isMegaDebridAccountDisabled(settings, account.id)) { + logger.info(`Mega-Debrid${accountLabel}: uebersprungen (manuell deaktiviert), pruefe naechsten Account`); + logAccountRotation("INFO", providerName, rotationLabel, "SKIP_DISABLED", { reason: "manually disabled" }); + continue; + } + if (isMegaDebridAccountDailyLimitReached(settings, account.id)) { + logger.info(`Mega-Debrid${accountLabel}: uebersprungen (lokales Tageslimit erreicht), pruefe naechsten Account`); + logAccountRotation("INFO", providerName, rotationLabel, "SKIP_DAILY_LIMIT", { reason: "local daily limit reached" }); + continue; + } + const cooldownKey = `${account.id}:${mode}`; + const accountCooldownState = getMegaDebridAccountCooldownState(cooldownKey); + if (accountCooldownState) { + const untilStr = accountCooldownState.untilRestart + ? "Neustart" + : new Date(accountCooldownState.until).toLocaleTimeString(); + const reasonText = accountCooldownState.untilRestart + ? "Tageslimit erreicht — bis zum Tagesreset gesperrt" + : `Cooldown bis ${untilStr}`; + logger.info(`Mega-Debrid${accountLabel}: uebersprungen (${reasonText}), pruefe naechsten Account`); + logAccountRotation("INFO", providerName, rotationLabel, "SKIP_COOLDOWN", { + reason: accountCooldownState.message, + category: accountCooldownState.category, + until: untilStr + }); + cooldownFailures.push(`Mega-Debrid${accountLabel}: ${accountCooldownState.message}`); + if (accountCooldownState.untilRestart) { + parkedUntilRestartSeen = true; + } else if (!earliestCooldownUntil || accountCooldownState.until < earliestCooldownUntil) { + earliestCooldownUntil = accountCooldownState.until; + } + continue; + } + + logger.info(`Mega-Debrid${accountLabel}: TESTE Account fuer Link-Generierung...`); + logAccountRotation("INFO", providerName, rotationLabel, "TEST", { + link: linkShort + }); + const testStartedAt = Date.now(); + + usableAccountSeen = true; + megaDebridInFlight.set(cooldownKey, (megaDebridInFlight.get(cooldownKey) ?? 0) + 1); + try { + const client = new MegaDebridClient(account.login, account.password, mode, allowApiFallback, megaWebUnrestrict); + const result = await client.unrestrictLink(link, signal); + clearMegaDebridAccountCooldownState(cooldownKey); + clearMegaDebridEmptyResponseStreak(cooldownKey); + const elapsedMs = Date.now() - testStartedAt; + traceConversionPhase({ phase: "mega-account", provider: providerName.includes("API") ? "megadebrid-api" : "megadebrid-web", account: rotationLabel, workMs: elapsedMs, outcome: "ok" }); + megaDebridStickyCount += 1; + if (megaDebridStickyCount >= MEGA_DEBRID_STICKY_LINKS) { + megaDebridRotationCursor = idx + 1; + megaDebridStickyCount = 0; + } else { + megaDebridRotationCursor = idx; + } + logger.info(`Mega-Debrid${accountLabel}: Unrestrict OK nach ${elapsedMs}ms -> ${result.fileName || "?"}`); + logAccountRotation("INFO", providerName, rotationLabel, "OK", { + elapsedMs, + fileName: result.fileName || "", + link: linkShort + }); + return { + ...result, + sourceLabel: `${result.sourceLabel ? `${result.sourceLabel} ` : ""}${account.label}`, + sourceAccountId: account.id, + sourceAccountLabel: account.label + }; + } catch (error) { + const elapsedMs = Date.now() - testStartedAt; + const abortText = compactErrorText(error).replace(/^Error:\s*/i, ""); + // Timeout/abort on THIS account (the shared unrestrict timeout fired). The + // account-wide cooldown exists ONLY to make the retry rotate to another + // account — so it is set only when another usable account actually exists. + // With no rotation target (single account / all others busy), cooling the + // sole account would freeze EVERY queued item while the account is healthy; + // a >60s timeout is a slow-LINK signal, not an unhealthy-account signal, so + // we park just this link (mega_debrid_slow_link) and leave the account free + // for other items. A quick user-cancel (below the min run) parks nothing. + if (/aborted/i.test(abortText) && !/timeout/i.test(abortText)) { + const ranLongEnough = elapsedMs >= getMegaDebridAbortMinRunMs(); + const otherUsableAccounts = orderedEntries.reduce((count, candidate) => { + if (candidate.account.id === account.id) { + return count; + } + if (isMegaDebridAccountDisabled(settings, candidate.account.id)) { + return count; + } + if (isMegaDebridAccountDailyLimitReached(settings, candidate.account.id)) { + return count; + } + if (getMegaDebridAccountCooldownState(`${candidate.account.id}:${mode}`)) { + return count; + } + return count + 1; + }, 0); + const rotateToAnotherAccount = ranLongEnough && otherUsableAccounts > 0; + if (rotateToAnotherAccount) { + setMegaDebridAccountCooldownState(cooldownKey, MEGA_DEBRID_ACCOUNT_COOLDOWN_MS, `Abbruch/Timeout nach ${Math.ceil(elapsedMs / 1000)}s`, "temporary"); + } + traceConversionPhase({ + phase: "mega-account", + provider: providerName.includes("API") ? "megadebrid-api" : "megadebrid-web", + account: rotationLabel, + workMs: elapsedMs, + outcome: "aborted", + detail: `${abortText}${rotateToAnotherAccount ? ` cd=${Math.ceil(MEGA_DEBRID_ACCOUNT_COOLDOWN_MS / 1000)}s` : ranLongEnough ? ` slowlink=${Math.ceil(MEGA_DEBRID_SLOW_LINK_RETRY_MS / 1000)}s` : ""}` + }); + failures.push(`Mega-Debrid${accountLabel}: ${abortText}`); + logAccountRotation("WARN", providerName, rotationLabel, "TIMEOUT_COOLDOWN", { + elapsedMs, + reason: abortText, + cooldownSec: rotateToAnotherAccount ? Math.ceil(MEGA_DEBRID_ACCOUNT_COOLDOWN_MS / 1000) : 0, + next: rotateToAnotherAccount ? "naechster Account beim Retry" : "Einzel-Retry (Account bleibt fuer andere Items frei)" + }); + if (ranLongEnough && !rotateToAnotherAccount) { + throw new Error(`mega_debrid_slow_link:${MEGA_DEBRID_SLOW_LINK_RETRY_MS}:Mega-Debrid${accountLabel}: ${abortText}`); + } + throw new Error(`Mega-Debrid${accountLabel}: ${abortText}`); + } + const failure = MegaDebridClient.classifyAccountFailure(error); + traceConversionPhase({ + phase: "mega-account", + provider: providerName.includes("API") ? "megadebrid-api" : "megadebrid-web", + account: rotationLabel, + workMs: Date.now() - testStartedAt, + outcome: failure.fatal ? "fatal" : "failed", + detail: `${failure.message}${failure.cooldownMs > 0 ? ` cd=${Math.ceil(failure.cooldownMs / 1000)}s` : ""}` + }); + failures.push(`Mega-Debrid${accountLabel}: ${failure.message}`); + + let parkUntilRestart = false; + let parkMessage = failure.message; + if (failure.limitSignal) { + const streak = recordMegaDebridEmptyResponseStreak(cooldownKey); + if (streak >= MEGA_DEBRID_EMPTY_STREAK_UNTIL_RESTART) { + parkUntilRestart = true; + parkMessage = `Tageslimit erreicht (${streak}x leere Antwort in Folge) — bis zum Tagesreset gesperrt`; + } + } else { + clearMegaDebridEmptyResponseStreak(cooldownKey); + } + + if (parkUntilRestart) { + setMegaDebridAccountCooldownState(cooldownKey, 0, parkMessage, "quota", true); + } else if (failure.cooldownMs > 0) { + setMegaDebridAccountCooldownState(cooldownKey, failure.cooldownMs, failure.message, failure.category); + } else { + clearMegaDebridAccountCooldownState(cooldownKey); + } + if (failure.fatal) { + logAccountRotation("ERROR", providerName, rotationLabel, "FATAL", { + elapsedMs, + reason: failure.message, + category: failure.category, + link: linkShort + }); + throw new Error(`Mega-Debrid${accountLabel}: ${failure.message}`); + } + const cooldownInfo = parkUntilRestart + ? ", bis zum Tagesreset gesperrt" + : failure.cooldownMs > 0 + ? `, Cooldown ${Math.ceil(failure.cooldownMs / 1000)}s` + : ""; + let nextLabel = "ENDE"; + for (let nextPos = orderPos + 1; nextPos < orderedEntries.length; nextPos += 1) { + const nextAcc = orderedEntries[nextPos].account; + if (!isMegaDebridAccountDisabled(settings, nextAcc.id) && !isMegaDebridAccountDailyLimitReached(settings, nextAcc.id) && !getMegaDebridAccountCooldownState(`${nextAcc.id}:${mode}`)) { + nextLabel = `${nextAcc.label}/${totalAccounts} (${nextAcc.maskedLogin})`; + break; + } + } + logger.warn(`Mega-Debrid${accountLabel}: ${parkUntilRestart ? parkMessage : failure.message}${cooldownInfo}, pruefe naechsten Account (${nextLabel})`); + logAccountRotation("WARN", providerName, rotationLabel, "FAILED", { + reason: parkUntilRestart ? parkMessage : failure.message, + elapsedMs, + category: parkUntilRestart ? "quota" : failure.category, + cooldownSec: parkUntilRestart || failure.cooldownMs <= 0 ? 0 : Math.ceil(failure.cooldownMs / 1000), + next: nextLabel, + link: linkShort + }); + } finally { + const remainingInFlight = (megaDebridInFlight.get(cooldownKey) ?? 1) - 1; + if (remainingInFlight <= 0) { + megaDebridInFlight.delete(cooldownKey); + } else { + megaDebridInFlight.set(cooldownKey, remainingInFlight); + } + } + } + + if (!usableAccountSeen) { + if (cooldownFailures.length > 0 && earliestCooldownUntil > Date.now()) { + const retryMs = Math.max(1000, earliestCooldownUntil - Date.now() + 1000); + throw new Error(`mega_debrid_cooldown:${retryMs}:${cooldownFailures.join(" | ")}`); + } + if (parkedUntilRestartSeen) { + const resetParkMs = Math.max(1000, megaDebridDailyParkExpiry(Date.now()) - Date.now() + 1000); + throw new Error(`mega_debrid_reset_park:${resetParkMs}:Mega-Debrid: Alle Accounts am Tageslimit (bis zum Tagesreset gesperrt)${cooldownFailures.length > 0 ? ` | ${cooldownFailures.join(" | ")}` : ""}`); + } + throw new Error("Mega-Debrid: Kein aktiver Account verfuegbar"); + } + throw new Error(failures.join(" | ") || "Mega-Debrid: Kein aktiver Account verfuegbar"); + } + + static classifyAccountFailure( + error: unknown + ): { fatal: boolean; cooldownMs: number; message: string; category: MegaDebridCooldownCategory; limitSignal?: boolean } { + const errorText = compactErrorText(error).replace(/^Error:\s*/i, ""); + + if (/aborted/i.test(errorText) && !/timeout/i.test(errorText)) { + return { fatal: true, cooldownMs: 0, message: errorText, category: "temporary" }; + } + + if (/token.?error|please log.?in/i.test(errorText)) { + return { fatal: false, cooldownMs: 15_000, message: errorText, category: "temporary" }; + } + + if (/bad.?login|incorrect.?(login|password)|invalid.?(login|password|credentials)|wrong.?password|unauthorized|forbidden|connectUser/i.test(errorText)) { + return { + fatal: false, + cooldownMs: MEGA_DEBRID_INVALID_ACCOUNT_COOLDOWN_MS, + message: `ungueltiger Account (${errorText})`, + category: "invalid" + }; + } + + if (isMegaDebridResolveFailure(errorText)) { + return { fatal: false, cooldownMs: 0, message: germanMegaDebridResolveReason(errorText), category: "temporary" }; + } + + if (/lieferte kein ergebnis|linkgenerierung[^.]*kein ergebnis/i.test(errorText)) { + return { fatal: false, cooldownMs: 0, message: germanMegaDebridResolveReason(errorText), category: "temporary" }; + } + + if (/permanent ungültig|hosternotavailable|file.?not.?found|file.?unavailable|link.?is.?dead/i.test(errorText)) { + return { fatal: true, cooldownMs: 0, message: errorText, category: "skip" }; + } + + if (/rate.?limit|too.?many|429/i.test(errorText)) { + return { + fatal: false, + cooldownMs: MEGA_DEBRID_ACCOUNT_COOLDOWN_MS, + message: `Rate-Limit (${errorText})`, + category: "rate_limit" + }; + } + + if (/quota|limit|exceeded|bandwidth/i.test(errorText)) { + return { + fatal: false, + cooldownMs: MEGA_DEBRID_ACCOUNT_COOLDOWN_MS, + message: `Quota/Limit erreicht (${errorText})`, + category: "quota" + }; + } + + if (MEGA_DEBRID_NO_SERVER_RE.test(errorText)) { + return { + fatal: false, + cooldownMs: MEGA_DEBRID_ACCOUNT_COOLDOWN_MS, + message: "Kein Server fuer diesen Hoster (Tageslimit/Hoster nicht verfuegbar)", + category: "quota" + }; + } + + if (/antwort\s+leer|empty\s+response|leere\s+antwort/i.test(errorText)) { + return { + fatal: false, + cooldownMs: 20_000, + message: errorText || "Mega-Web transient empty response", + category: "temporary", + limitSignal: true + }; + } + + if (/queue.?timeout/i.test(errorText)) { + // Lokaler Stau (die Queue dieses Accounts war zu lange belegt) — KEIN Signal fuer einen + // ungesunden Account. Kein Cooldown, sonst wuerde der warme Account fuer Eigen-Stau bestraft; + // der Link wird einfach erneut versucht und rotiert dann natuerlich weiter. + return { fatal: false, cooldownMs: 0, message: errorText, category: "temporary" }; + } + + if (isRetryableErrorText(errorText) || /timeout|network|fetch|socket/i.test(errorText)) { + return { + fatal: false, + cooldownMs: 30_000, + message: errorText || "temporaerer Fehler", + category: "temporary" + }; + } + + return { + fatal: false, + cooldownMs: 30_000, + message: errorText || "unbekannter Fehler", + category: "temporary" + }; + } +} + +class BestDebridClient { + private token: string; + + public constructor(token: string) { + this.token = token; + } + + public async unrestrictLink(link: string, signal?: AbortSignal): Promise<UnrestrictedLink> { + const requests = buildBestDebridRequests(link, this.token); + let lastError = ""; + + for (const request of requests) { + try { + return await this.tryRequest(request, link, signal); + } catch (error) { + const errorText = compactErrorText(error); + if (signal?.aborted || (/aborted/i.test(errorText) && !/timeout/i.test(errorText))) { + throw error; + } + lastError = errorText; + } + } + + throw new Error(lastError || "BestDebrid Unrestrict fehlgeschlagen"); + } + + private async tryRequest(request: BestDebridRequest, originalLink: string, signal?: AbortSignal): Promise<UnrestrictedLink> { + let lastError = ""; + for (let attempt = 1; attempt <= REQUEST_RETRIES; attempt += 1) { + try { + const headers: Record<string, string> = { + "User-Agent": DEBRID_USER_AGENT + }; + if (request.useAuthHeader) { + headers.Authorization = `Bearer ${this.token}`; + } + + const response = await fetch(request.url, { + method: "GET", + headers, + signal: withTimeoutSignal(signal, API_TIMEOUT_MS) + }); + const text = await response.text(); + const parsed = parseJson(text); + const payload = Array.isArray(parsed) ? asRecord(parsed[0]) : asRecord(parsed); + + if (!response.ok) { + const reason = parseError(response.status, text, payload); + if (shouldRetryStatus(response.status) && attempt < REQUEST_RETRIES) { + await sleepWithSignal(retryDelayForResponse(response, attempt), signal); + continue; + } + throw new Error(reason); + } + + const directUrl = pickString(payload, ["download", "debridLink", "link"]); + if (directUrl) { + let parsedDirect: URL; + try { + parsedDirect = new URL(directUrl); + } catch { + throw new Error("BestDebrid Antwort enthält keine gültige Download-URL"); + } + if (parsedDirect.protocol !== "https:" && parsedDirect.protocol !== "http:") { + throw new Error(`BestDebrid Antwort enthält ungültiges Download-URL-Protokoll (${parsedDirect.protocol})`); + } + const fileName = pickString(payload, ["filename", "fileName"]) || filenameFromUrl(originalLink); + const fileSize = pickNumber(payload, ["filesize", "size", "bytes"]); + return { + fileName, + directUrl, + fileSize, + retriesUsed: attempt - 1 + }; + } + + const message = pickString(payload, ["response_text", "message", "error"]); + if (message) { + throw new Error(message); + } + + throw new Error("BestDebrid Antwort ohne Download-Link"); + } catch (error) { + lastError = compactErrorText(error); + if (signal?.aborted || (/aborted/i.test(lastError) && !/timeout/i.test(lastError))) { + break; + } + if (attempt >= REQUEST_RETRIES || !isRetryableErrorText(lastError)) { + break; + } + await sleepWithSignal(retryDelay(attempt), signal); + } + } + throw new Error(String(lastError || "BestDebrid Request fehlgeschlagen").replace(/^Error:\s*/i, "")); + } +} + +class AllDebridClient { + private token: string; + + public constructor(token: string) { + this.token = token; + } + + public async getLinkInfos(links: string[], signal?: AbortSignal): Promise<Map<string, string>> { + const result = new Map<string, string>(); + const canonicalToInput = new Map<string, string>(); + const uniqueLinks: string[] = []; + + for (const link of links) { + const trimmed = link.trim(); + if (!trimmed) { + continue; + } + const canonical = canonicalLink(trimmed); + if (canonicalToInput.has(canonical)) { + continue; + } + canonicalToInput.set(canonical, trimmed); + uniqueLinks.push(trimmed); + } + + for (let index = 0; index < uniqueLinks.length; index += 32) { + if (signal?.aborted) { + throw new Error("aborted:debrid"); + } + const chunk = uniqueLinks.slice(index, index + 32); + const body = new URLSearchParams(); + for (const link of chunk) { + body.append("link[]", link); + } + + let payload: Record<string, unknown> | null = null; + let chunkResolved = false; + for (let attempt = 1; attempt <= REQUEST_RETRIES; attempt += 1) { + let response: Response; + let text = ""; + try { + response = await fetch(`${ALL_DEBRID_API_BASE}/link/infos`, { + method: "POST", + headers: { + Authorization: `Bearer ${this.token}`, + "Content-Type": "application/x-www-form-urlencoded", + "User-Agent": DEBRID_USER_AGENT + }, + body, + signal: withTimeoutSignal(signal, API_TIMEOUT_MS) + }); + + text = await response.text(); + payload = asRecord(parseJson(text)); + if (!response.ok) { + const reason = parseError(response.status, text, payload); + if (shouldRetryStatus(response.status) && attempt < REQUEST_RETRIES) { + await sleepWithSignal(retryDelayForResponse(response, attempt), signal); + continue; + } + throw new Error(reason); + } + + const contentType = String(response.headers.get("content-type") || "").toLowerCase(); + const looksHtml = contentType.includes("text/html") || /^\s*<(!doctype\s+html|html\b)/i.test(text); + if (looksHtml) { + throw new Error("AllDebrid lieferte HTML statt JSON"); + } + if (!payload) { + throw new Error("AllDebrid Antwort ist kein JSON-Objekt"); + } + + const status = pickString(payload, ["status"]); + if (status && status.toLowerCase() === "error") { + throw new Error(parseAllDebridError(payload)); + } + + chunkResolved = true; + break; + } catch (error) { + const errorText = compactErrorText(error); + if (signal?.aborted || (/aborted/i.test(errorText) && !/timeout/i.test(errorText))) { + throw error; + } + if (attempt >= REQUEST_RETRIES || !isRetryableErrorText(errorText)) { + throw error; + } + await sleepWithSignal(retryDelay(attempt), signal); + } + } + + if (!chunkResolved || !payload) { + throw new Error("AllDebrid Link-Infos konnten nicht geladen werden"); + } + + const data = asRecord(payload?.data); + const infos = Array.isArray(data?.infos) ? data.infos : []; + const hasAnyLinkedInfo = infos.some((entry) => { + const info = asRecord(entry); + return Boolean(pickString(info, ["link"])); + }); + const allowPositionalFallback = infos.length === chunk.length && !hasAnyLinkedInfo; + for (let i = 0; i < infos.length; i += 1) { + const info = asRecord(infos[i]); + if (!info) { + continue; + } + const fileName = pickString(info, ["filename", "fileName"]); + if (!fileName) { + continue; + } + + const responseLink = pickString(info, ["link"]); + const byResponse = canonicalToInput.get(canonicalLink(responseLink)); + const byIndex = chunk.length === 1 + ? chunk[0] + : allowPositionalFallback + ? chunk[i] + : ""; + const original = byResponse || byIndex; + if (!original) { + continue; + } + result.set(original, fileName); + } + } + + return result; + } + + public async getHostInfo(host: string, signal?: AbortSignal): Promise<AllDebridHostInfo> { + const wanted = normalizeAllDebridHostKey(host); + let lastError = ""; + + for (let attempt = 1; attempt <= REQUEST_RETRIES; attempt += 1) { + try { + const response = await fetch(`${ALL_DEBRID_API_BASE_V41}/user/hosts`, { + method: "GET", + headers: { + Authorization: `Bearer ${this.token}`, + "User-Agent": DEBRID_USER_AGENT + }, + signal: withTimeoutSignal(signal, API_TIMEOUT_MS) + }); + const text = await response.text(); + const payload = asRecord(parseJson(text)); + + if (!response.ok) { + const reason = parseError(response.status, text, payload); + if (shouldRetryStatus(response.status) && attempt < REQUEST_RETRIES) { + await sleepWithSignal(retryDelayForResponse(response, attempt), signal); + continue; + } + throw new Error(reason); + } + + const contentType = String(response.headers.get("content-type") || "").toLowerCase(); + const looksHtml = contentType.includes("text/html") || /^\s*<(!doctype\s+html|html\b)/i.test(text); + if (looksHtml) { + throw new Error("AllDebrid lieferte HTML statt JSON"); + } + if (!payload) { + throw new Error("AllDebrid Antwort ist kein JSON-Objekt"); + } + + const status = pickString(payload, ["status"]); + if (status && status.toLowerCase() === "error") { + throw new Error(parseAllDebridError(payload)); + } + + const data = asRecord(payload.data); + const hosts = asRecord(data?.hosts); + if (!hosts) { + throw new Error("AllDebrid Antwort ohne Host-Liste"); + } + + let hostEntry = asRecord(hosts[host]) || asRecord(hosts[wanted]); + if (!hostEntry) { + for (const entry of Object.values(hosts)) { + const candidate = asRecord(entry); + const candidateName = normalizeAllDebridHostKey(pickString(candidate, ["name"])); + if (candidateName === wanted) { + hostEntry = candidate; + break; + } + } + } + + if (!hostEntry) { + throw new Error(`AllDebrid Host ${host} nicht gefunden`); + } + + const state = toAllDebridHostState(hostEntry.status); + const quota = pickNumber(hostEntry, ["quota"]); + const quotaMax = pickNumber(hostEntry, ["quotaMax"]); + const limitSimuDl = pickNumber(hostEntry, ["limitSimuDl"]); + const quotaType = pickString(hostEntry, ["quotaType"]); + const note = quota === null && quotaMax === null && limitSimuDl === null + ? "AllDebrid liefert für diesen Host aktuell keine Quota- oder Slot-Daten." + : ""; + + return { + host: pickString(hostEntry, ["name"]) || host, + source: "api", + state, + statusLabel: toAllDebridHostStatusLabel(state), + fetchedAt: Date.now(), + lastCheckedAt: null, + quota, + quotaMax, + quotaType, + limitSimuDl, + note + }; + } catch (error) { + lastError = compactErrorText(error); + if (signal?.aborted || (/aborted/i.test(lastError) && !/timeout/i.test(lastError))) { + break; + } + if (attempt >= REQUEST_RETRIES || !isRetryableErrorText(lastError)) { + break; + } + await sleepWithSignal(retryDelay(attempt), signal); + } + } + + throw new Error(String(lastError || "AllDebrid Host-Info fehlgeschlagen").replace(/^Error:\s*/i, "")); + } + + public async unrestrictLink(link: string, signal?: AbortSignal): Promise<UnrestrictedLink> { + let lastError = ""; + for (let attempt = 1; attempt <= REQUEST_RETRIES; attempt += 1) { + try { + const response = await fetch(`${ALL_DEBRID_API_BASE}/link/unlock`, { + method: "POST", + headers: { + Authorization: `Bearer ${this.token}`, + "Content-Type": "application/x-www-form-urlencoded", + "User-Agent": DEBRID_USER_AGENT + }, + body: new URLSearchParams({ link }), + signal: withTimeoutSignal(signal, API_TIMEOUT_MS) + }); + const text = await response.text(); + const payload = asRecord(parseJson(text)); + + if (!response.ok) { + const reason = parseError(response.status, text, payload); + if (shouldRetryStatus(response.status) && attempt < REQUEST_RETRIES) { + await sleepWithSignal(retryDelayForResponse(response, attempt), signal); + continue; + } + throw new Error(reason); + } + + const contentType = String(response.headers.get("content-type") || "").toLowerCase(); + const looksHtml = contentType.includes("text/html") || /^\s*<(!doctype\s+html|html\b)/i.test(text); + if (looksHtml) { + throw new Error("AllDebrid lieferte HTML statt JSON"); + } + if (!payload) { + throw new Error("AllDebrid Antwort ist kein JSON-Objekt"); + } + + const status = pickString(payload, ["status"]); + if (status && status.toLowerCase() === "error") { + throw new Error(parseAllDebridError(payload)); + } + + const data = asRecord(payload?.data); + const directUrl = pickString(data, ["link"]); + if (!directUrl) { + throw new Error("AllDebrid Antwort ohne Download-Link"); + } + let parsedDirect: URL; + try { + parsedDirect = new URL(directUrl); + } catch { + throw new Error("AllDebrid Antwort enthält keine gültige Download-URL"); + } + if (parsedDirect.protocol !== "https:" && parsedDirect.protocol !== "http:") { + throw new Error(`AllDebrid Antwort enthält ungültiges Download-URL-Protokoll (${parsedDirect.protocol})`); + } + + return { + fileName: pickString(data, ["filename"]) || filenameFromUrl(link), + directUrl, + fileSize: pickNumber(data, ["filesize"]), + retriesUsed: attempt - 1 + }; + } catch (error) { + lastError = compactErrorText(error); + if (signal?.aborted || (/aborted/i.test(lastError) && !/timeout/i.test(lastError))) { + break; + } + if (attempt >= REQUEST_RETRIES || !isRetryableErrorText(lastError)) { + break; + } + await sleepWithSignal(retryDelay(attempt), signal); + } + } + + throw new Error(String(lastError || "AllDebrid Unrestrict fehlgeschlagen").replace(/^Error:\s*/i, "")); + } +} + +export async function fetchAllDebridHostInfo(token: string, host = "rapidgator", signal?: AbortSignal): Promise<AllDebridHostInfo> { + return new AllDebridClient(token).getHostInfo(host, signal); +} + +export async function fetchDebridLinkHostLimits(apiKeysRaw: string, host = "rapidgator", signal?: AbortSignal): Promise<DebridLinkHostLimitInfo[]> { + const apiKeys = parseDebridLinkApiKeys(apiKeysRaw); + if (apiKeys.length === 0) { + throw new Error("Debrid-Link ist nicht konfiguriert"); + } + + const publicHostInfo = await fetchDebridLinkPublicHostInfo(host, signal); + const results: DebridLinkHostLimitInfo[] = []; + for (const apiKey of apiKeys) { + results.push(await fetchDebridLinkHostLimitForKeyDetailed(apiKey, host, publicHostInfo, signal)); + } + return results; +} + +class DebridLinkClient { + private apiKeys: ReturnType<typeof parseDebridLinkApiKeys>; + + public constructor(apiKeysRaw: string) { + this.apiKeys = parseDebridLinkApiKeys(apiKeysRaw); + } + + public async unrestrictLink(link: string, settings: AppSettings, signal?: AbortSignal): Promise<UnrestrictedLink> { + if (this.apiKeys.length === 0) { + throw new Error("Debrid-Link: Kein API-Key konfiguriert"); + } + + if (getAvailableDebridLinkApiKeys(settings).length === 0) { + throw new Error("Debrid-Link: Kein aktiver API-Key verfuegbar (deaktiviert oder am Tageslimit)"); + } + + const failures: string[] = []; + let usableKeySeen = false; + const cooldownFailures: string[] = []; + let earliestCooldownUntil = 0; + const attemptedKeyFailures: Array<{ message: string; cooldownMs: number; category?: DebridLinkCooldownCategory }> = []; + let consecutiveTransportFailures = 0; + const totalKeys = this.apiKeys.length; + const providerName = "Debrid-Link"; + const linkShort = String(link || "").slice(0, 80); + const linkHoster = extractHosterFromUrl(link); + + for (let keyIdx = 0; keyIdx < this.apiKeys.length; keyIdx += 1) { + const apiKey = this.apiKeys[keyIdx]; + const keyLabel = ` (${apiKey.label}/${totalKeys}, ${apiKey.masked})`; + const rotationLabel = `${apiKey.label}/${totalKeys} (${apiKey.masked})`; + if (isDebridLinkApiKeyDisabled(settings, apiKey.id)) { + logger.info(`Debrid-Link${keyLabel}: uebersprungen (manuell deaktiviert), pruefe naechsten Key`); + logAccountRotation("INFO", providerName, rotationLabel, "SKIP_DISABLED", { reason: "manually disabled" }); + continue; + } + if (isDebridLinkApiKeyDailyLimitReached(settings, apiKey.id)) { + logger.info(`Debrid-Link${keyLabel}: uebersprungen (lokales Tageslimit erreicht), pruefe naechsten Key`); + logAccountRotation("INFO", providerName, rotationLabel, "SKIP_DAILY_LIMIT", { reason: "local daily limit reached" }); + continue; + } + const keyCooldownState = getDebridLinkKeyCooldownState(apiKey.id); + if (keyCooldownState) { + const untilStr = new Date(keyCooldownState.until).toLocaleTimeString(); + logger.info(`Debrid-Link${keyLabel}: uebersprungen (Cooldown bis ${untilStr}), pruefe naechsten Key`); + logAccountRotation("INFO", providerName, rotationLabel, "SKIP_COOLDOWN", { + reason: keyCooldownState.message, + category: keyCooldownState.category, + until: untilStr + }); + cooldownFailures.push(`Debrid-Link${keyLabel}: ${keyCooldownState.message}`); + if (!earliestCooldownUntil || keyCooldownState.until < earliestCooldownUntil) { + earliestCooldownUntil = keyCooldownState.until; + } + continue; + } + const hostCooldownState = linkHoster ? getDebridLinkKeyHostCooldownState(apiKey.id, linkHoster) : null; + if (hostCooldownState) { + const untilStr = new Date(hostCooldownState.until).toLocaleTimeString(); + logger.info(`Debrid-Link${keyLabel}: uebersprungen (Host-Cooldown ${linkHoster} bis ${untilStr}), pruefe naechsten Key`); + logAccountRotation("INFO", providerName, rotationLabel, "SKIP_HOST_COOLDOWN", { + reason: hostCooldownState.message, + category: hostCooldownState.category, + host: linkHoster, + until: untilStr + }); + cooldownFailures.push(`Debrid-Link${keyLabel}: ${hostCooldownState.message}`); + if (!earliestCooldownUntil || hostCooldownState.until < earliestCooldownUntil) { + earliestCooldownUntil = hostCooldownState.until; + } + continue; + } + + logger.info(`Debrid-Link${keyLabel}: TESTE Key fuer Link-Generierung...`); + logAccountRotation("INFO", providerName, rotationLabel, "TEST", { link: linkShort }); + const testStartedAt = Date.now(); + + usableKeySeen = true; + try { + const result = await this.unrestrictWithKey(apiKey, link, signal); + clearDebridLinkKeyCooldownState(apiKey.id); + setDebridLinkKeyRuntimeStatus(apiKey.id, "ready", "Unrestrict erfolgreich"); + const elapsedMs = Date.now() - testStartedAt; + logger.info(`Debrid-Link${keyLabel}: Unrestrict OK nach ${elapsedMs}ms -> ${result.fileName || "?"}`); + logAccountRotation("INFO", providerName, rotationLabel, "OK", { + elapsedMs, + fileName: result.fileName || "", + link: linkShort + }); + return { + ...result, + sourceLabel: apiKey.label, + sourceAccountId: apiKey.id, + sourceAccountLabel: apiKey.label + }; + } catch (error) { + const failure = await this.classifyKeyFailure(error, apiKey, link, signal); + const elapsedMs = Date.now() - testStartedAt; + const abortText = compactErrorText(error).replace(/^Error:\s*/i, ""); + if (/aborted/i.test(abortText) && !/timeout/i.test(abortText)) { + const ranLongEnough = elapsedMs >= getMegaDebridAbortMinRunMs(); + if (ranLongEnough) { + setDebridLinkKeyCooldownState(apiKey.id, DEBRID_LINK_KEY_COOLDOWN_MS, `Abbruch/Timeout nach ${Math.ceil(elapsedMs / 1000)}s`, "temporary"); + } else { + clearDebridLinkKeyCooldownState(apiKey.id); + } + failures.push(`Debrid-Link${keyLabel}: ${abortText}`); + logAccountRotation("WARN", providerName, rotationLabel, "TIMEOUT_COOLDOWN", { + elapsedMs, + reason: abortText, + cooldownSec: ranLongEnough ? Math.ceil(DEBRID_LINK_KEY_COOLDOWN_MS / 1000) : 0, + next: "naechster Key beim Retry" + }); + throw new Error(`Debrid-Link${keyLabel}: ${abortText}`); + } + attemptedKeyFailures.push({ + message: `Debrid-Link${keyLabel}: ${failure.message}`, + cooldownMs: failure.cooldownMs, + category: failure.category + }); + failures.push(`Debrid-Link${keyLabel}: ${failure.message}`); + if (failure.cooldownMs > 0) { + if (failure.hostOnly) { + setDebridLinkKeyHostCooldownState( + apiKey.id, + failure.hoster || "", + failure.cooldownMs, + failure.message, + failure.category || "quota" + ); + } else { + setDebridLinkKeyCooldownState(apiKey.id, failure.cooldownMs, failure.message, failure.category || "temporary"); + } + } else { + clearDebridLinkKeyCooldownState(apiKey.id); + if (failure.category === "invalid") { + setDebridLinkKeyRuntimeStatus(apiKey.id, "invalid", failure.message); + } else if (failure.category !== "skip") { + setDebridLinkKeyRuntimeStatus(apiKey.id, "error", failure.message); + } + } + if (failure.fatal) { + logAccountRotation("ERROR", providerName, rotationLabel, "FATAL", { + elapsedMs, + reason: failure.message, + category: failure.category, + link: linkShort + }); + throw new Error(`Debrid-Link${keyLabel}: ${failure.message}`); + } + if (failure.providerWide) { + const providerWideCooldownMs = 5 * 60 * 1000; + logger.warn(`Debrid-Link${keyLabel}: ${failure.message} (provider-wide, ueberspringe verbleibende Keys, Cooldown ${providerWideCooldownMs / 1000}s)`); + logAccountRotation("ERROR", providerName, rotationLabel, "PROVIDER_WIDE", { + elapsedMs, + reason: failure.message, + category: failure.category, + cooldownSec: Math.ceil(providerWideCooldownMs / 1000), + link: linkShort + }); + throw new Error(`debrid_link_cooldown:${providerWideCooldownMs}:Debrid-Link${keyLabel}: ${failure.message}`); + } + const isTransport = isRetryableErrorText(failure.message) && !(error instanceof DebridLinkApiError); + consecutiveTransportFailures = isTransport ? consecutiveTransportFailures + 1 : 0; + if (consecutiveTransportFailures >= 2) { + const cascadeCooldownMs = 3 * 60 * 1000; + logger.warn(`Debrid-Link: ${consecutiveTransportFailures} Transport-Fehler in Folge, ueberspringe verbleibende Keys, Cooldown ${cascadeCooldownMs / 1000}s`); + logAccountRotation("ERROR", providerName, rotationLabel, "TRANSPORT_CASCADE", { + elapsedMs, + consecutive: consecutiveTransportFailures, + cooldownSec: Math.ceil(cascadeCooldownMs / 1000), + link: linkShort + }); + throw new Error(`debrid_link_cooldown:${cascadeCooldownMs}:Debrid-Link: Transport-Kaskade (${consecutiveTransportFailures}x)`); + } + let nextLabel = "ENDE"; + for (let nextIdx = keyIdx + 1; nextIdx < this.apiKeys.length; nextIdx += 1) { + const nextKey = this.apiKeys[nextIdx]; + if (!isDebridLinkApiKeyDisabled(settings, nextKey.id) && !isDebridLinkApiKeyDailyLimitReached(settings, nextKey.id) && !getDebridLinkKeyCooldownState(nextKey.id)) { + nextLabel = `${nextKey.label}/${totalKeys} (${nextKey.masked})`; + break; + } + } + const cooldownInfo = failure.cooldownMs > 0 + ? `, Cooldown ${Math.ceil(failure.cooldownMs / 1000)}s` + : ""; + logger.warn(`Debrid-Link${keyLabel}: ${failure.message}${cooldownInfo}, pruefe naechsten Key (${nextLabel})`); + logAccountRotation("WARN", providerName, rotationLabel, "FAILED", { + elapsedMs, + reason: failure.message, + category: failure.category, + cooldownSec: failure.cooldownMs > 0 ? Math.ceil(failure.cooldownMs / 1000) : 0, + next: nextLabel, + link: linkShort + }); + } + } + + if (!usableKeySeen) { + if (cooldownFailures.length > 0 && earliestCooldownUntil > Date.now()) { + const retryMs = Math.max(1000, earliestCooldownUntil - Date.now() + 1000); + throw new Error(`debrid_link_cooldown:${retryMs}:${cooldownFailures.join(" | ")}`); + } + throw new Error("debrid_link_no_active_key:Debrid-Link: Kein aktiver API-Key verfuegbar"); + } + + if (attemptedKeyFailures.length > 0 && attemptedKeyFailures.every((entry) => entry.category === "invalid")) { + throw new Error(`debrid_link_invalid_all:${attemptedKeyFailures.map((entry) => entry.message).join(" | ")}`); + } + + const cooldownOnlyFailures = attemptedKeyFailures.filter((entry) => entry.cooldownMs > 0); + if (attemptedKeyFailures.length > 0 && cooldownOnlyFailures.length === attemptedKeyFailures.length) { + const retryMs = Math.max(1000, Math.min(...cooldownOnlyFailures.map((entry) => Math.max(1000, entry.cooldownMs))) + 1000); + throw new Error(`debrid_link_cooldown:${retryMs}:${cooldownOnlyFailures.map((entry) => entry.message).join(" | ")}`); + } + throw new Error(failures.join(" | ") || "Debrid-Link: Kein aktiver API-Key verfuegbar"); + } + + private async unrestrictWithKey( + apiKey: ReturnType<typeof parseDebridLinkApiKeys>[number], + link: string, + signal?: AbortSignal + ): Promise<UnrestrictedLink> { + const payload = await this.requestPayload(apiKey, "POST", "/downloader/add", { url: link }, signal); + const entry = await this.resolveDownloaderEntry(apiKey, payload.value, link, signal); + const directUrl = pickString(entry, ["downloadUrl"]); + const expired = Boolean(entry.expired === true); + if (!directUrl || expired) { + throw new Error("Debrid-Link: Keine gueltige Download-URL in Antwort"); + } + return { + fileName: pickString(entry, ["name"]) || filenameFromUrl(directUrl) || filenameFromUrl(link), + directUrl, + fileSize: pickNumber(entry, ["size"]), + retriesUsed: 0 + }; + } + + private async requestPayload( + apiKey: ReturnType<typeof parseDebridLinkApiKeys>[number], + method: "GET" | "POST" | "DELETE", + apiPath: string, + body: Record<string, unknown> | undefined, + signal?: AbortSignal, + maxAttempts = REQUEST_RETRIES + ): Promise<Record<string, unknown>> { + return requestDebridLinkPayloadWithKey(apiKey, method, apiPath, body, signal, maxAttempts); + } + + private shouldRetryApiError(error: DebridLinkApiError, attempt: number, maxAttempts: number): boolean { + if (attempt >= maxAttempts) { + return false; + } + if (error.status === 429 || error.status >= 500) { + return true; + } + return DEBRID_LINK_RETRYABLE_ERRORS.has(error.code); + } + + private retryDelayForApiError(error: DebridLinkApiError, attempt: number): number { + if (error.retryAfterMs > 0) { + return error.retryAfterMs; + } + return retryDelay(attempt); + } + + private async resolveDownloaderEntry( + apiKey: ReturnType<typeof parseDebridLinkApiKeys>[number], + rawValue: unknown, + originalLink: string, + signal?: AbortSignal + ): Promise<Record<string, unknown>> { + const entries = parseDebridLinkLinkEntries(rawValue); + if (entries.length === 0) { + throw new Error("Debrid-Link: Keine Daten in Antwort"); + } + + const matchingEntries = entries.filter((entry) => { + const url = pickString(entry, ["url"]); + return url ? canonicalLink(url) === canonicalLink(originalLink) : false; + }); + const chosen = matchingEntries.length === 1 + ? matchingEntries[0] + : entries.length === 1 + ? entries[0] + : null; + if (!chosen) { + throw new Error(`Debrid-Link: Link lieferte ${entries.length} Dateien statt einer Einzeldatei`); + } + + const needsRefresh = !pickString(chosen, ["downloadUrl"]) || chosen.expired === true; + if (!needsRefresh) { + return chosen; + } + + const id = pickString(chosen, ["id"]); + if (!id) { + return chosen; + } + + const maxPolls = 5; + for (let poll = 0; poll < maxPolls; poll++) { + if (signal?.aborted) { + throw new Error("aborted"); + } + if (poll > 0) { + await sleepWithSignal(2000, signal); + } + const refreshed = await this.fetchDownloaderEntry(apiKey, id, signal); + if (refreshed) { + const url = pickString(refreshed, ["downloadUrl"]); + const expired = refreshed.expired === true; + if (url && !expired) { + return refreshed; + } + } + } + return (await this.fetchDownloaderEntry(apiKey, id, signal)) || chosen; + } + + private async fetchDownloaderEntry( + apiKey: ReturnType<typeof parseDebridLinkApiKeys>[number], + id: string, + signal?: AbortSignal + ): Promise<Record<string, unknown> | null> { + const query = new URLSearchParams({ ids: id }); + const payload = await this.requestPayload(apiKey, "GET", `/downloader/list?${query.toString()}`, undefined, signal); + const entries = parseDebridLinkLinkEntries(payload.value); + if (entries.length === 0) { + return null; + } + return entries.find((entry) => pickString(entry, ["id"]) === id) || entries[0] || null; + } + + private async fetchQuotaCooldownMs( + apiKey: ReturnType<typeof parseDebridLinkApiKeys>[number], + signal?: AbortSignal + ): Promise<number> { + try { + const payload = await this.requestPayload(apiKey, "GET", "/downloader/limits", undefined, signal, 1); + return parseDebridLinkNextResetMs(payload) || DEBRID_LINK_KEY_COOLDOWN_MS; + } catch { + return DEBRID_LINK_KEY_COOLDOWN_MS; + } + } + + private async classifyKeyFailure( + error: unknown, + apiKey: ReturnType<typeof parseDebridLinkApiKeys>[number], + link: string, + signal?: AbortSignal + ): Promise<{ fatal: boolean; cooldownMs: number; message: string; category?: DebridLinkCooldownCategory; providerWide?: boolean; hostOnly?: boolean; hoster?: string }> { + const errorText = compactErrorText(error).replace(/^Error:\s*/i, ""); + if (error instanceof DebridLinkApiError) { + const code = String(error.code || "").trim() || `HTTP ${error.status}`; + const description = error.message || code; + + if (DEBRID_LINK_INVALID_TOKEN_ERRORS.has(code)) { + return { + fatal: false, + cooldownMs: DEBRID_LINK_INVALID_KEY_COOLDOWN_MS, + message: `ungueltiger oder deaktivierter API-Key (${code}: ${description})`, + category: "invalid" + }; + } + if (DEBRID_LINK_RATE_LIMIT_ERRORS.has(code) || error.status === 429) { + return { + fatal: false, + cooldownMs: error.retryAfterMs || DEBRID_LINK_RATE_LIMIT_COOLDOWN_MS, + message: `API-Rate-Limit erreicht (${code}: ${description})`, + category: "rate_limit" + }; + } + if (DEBRID_LINK_HOST_QUOTA_ERRORS.has(code)) { + const cooldownMs = await this.fetchQuotaCooldownMs(apiKey, signal); + const hosterRaw = extractHosterFromUrl(link); + const hosterLabel = hosterRaw || "host"; + return { + fatal: false, + cooldownMs, + message: `Quota erreicht fuer ${hosterLabel} (${code}: ${description})`, + category: "quota", + hostOnly: true, + hoster: hosterRaw + }; + } + if (DEBRID_LINK_KEY_QUOTA_ERRORS.has(code)) { + const cooldownMs = await this.fetchQuotaCooldownMs(apiKey, signal); + return { + fatal: false, + cooldownMs, + message: `Quota erreicht (${code}: ${description})`, + category: "quota" + }; + } + if (DEBRID_LINK_PROVIDER_WIDE_ERRORS.has(code)) { + return { + fatal: false, + cooldownMs: DEBRID_LINK_KEY_COOLDOWN_MS, + message: `Link kann aktuell nicht generiert werden (${code}: ${description})`, + category: "temporary", + providerWide: true + }; + } + if (DEBRID_LINK_SKIP_KEY_ERRORS.has(code)) { + return { + fatal: false, + cooldownMs: 0, + message: `Key kann Link aktuell nicht verarbeiten (${code}: ${description})`, + category: "skip" + }; + } + if (DEBRID_LINK_FATAL_LINK_ERRORS.has(code)) { + return { + fatal: true, + cooldownMs: 0, + message: description, + category: "temporary" + }; + } + if (DEBRID_LINK_RETRYABLE_ERRORS.has(code) || error.status >= 500) { + return { + fatal: false, + cooldownMs: DEBRID_LINK_KEY_COOLDOWN_MS, + message: `temporärer API-Fehler (${code}: ${description})` + }; + } + return { + fatal: true, + cooldownMs: 0, + message: description + }; + } + + if (/keine gueltige download-url/i.test(errorText)) { + return { + fatal: false, + cooldownMs: DEBRID_LINK_KEY_COOLDOWN_MS, + message: errorText || "Download-URL nicht verfuegbar", + category: "temporary" + }; + } + + if (isRetryableErrorText(errorText) || /debrid-link.*(json|html)/i.test(errorText)) { + const isTransport = /timeout|network|fetch failed|aborted|econnreset|enotfound|etimedout|socket/i.test(errorText) + && !(error instanceof DebridLinkApiError); + return { + fatal: false, + cooldownMs: isTransport ? 15_000 : DEBRID_LINK_KEY_COOLDOWN_MS, + message: errorText || "temporärer Transportfehler" + }; + } + + if (errorText && /success.*false|kein.*json|empty.*response/i.test(errorText)) { + return { + fatal: false, + cooldownMs: 30_000, + message: errorText, + category: "temporary" + }; + } + + return { + fatal: true, + cooldownMs: 0, + message: errorText || "Unbekannter Debrid-Link-Fehler" + }; + } +} + +class LinkSnappyClient { + private username: string; + private password: string; + private sessionCookies: string | null = null; + + public constructor(username: string, password: string) { + this.username = username; + this.password = password; + } + + private async authenticate(signal?: AbortSignal): Promise<void> { + const params = new URLSearchParams({ username: this.username, password: this.password }); + const res = await fetch(`${LINKSNAPPY_API_BASE}/AUTHENTICATE?${params.toString()}`, { + signal: withTimeoutSignal(signal, API_TIMEOUT_MS), + redirect: "manual" + }); + + const cookies: string[] = []; + const setCookie = res.headers.getSetCookie?.() ?? []; + for (const sc of setCookie) { + const nameValue = sc.split(";")[0]; + if (nameValue) cookies.push(nameValue); + } + + const json = await res.json() as Record<string, unknown>; + if (json.status !== "OK") { + throw new Error(`LinkSnappy: Login fehlgeschlagen – ${String(json.error || "Unbekannter Fehler")}`); + } + + if (cookies.length > 0) { + this.sessionCookies = cookies.join("; "); + } else { + this.sessionCookies = `username=${encodeURIComponent(this.username)}; Auth=manual`; + } + + logger.info("LinkSnappy: Authentifizierung erfolgreich"); + } + + public async unrestrictLink(link: string, signal?: AbortSignal): Promise<UnrestrictedLink> { + if (!this.username || !this.password) { + throw new Error("LinkSnappy: Kein Login konfiguriert"); + } + + let lastError = ""; + for (let attempt = 1; attempt <= REQUEST_RETRIES; attempt += 1) { + if (signal?.aborted) throw new Error("aborted:debrid"); + try { + if (!this.sessionCookies) { + await this.authenticate(signal); + } + + const genLinks = `{"link":"${encodeURIComponent(link)}","type":"","linkpass":""}`; + const url = `${LINKSNAPPY_API_BASE}/linkgen?genLinks=${genLinks}`; + + const res = await fetch(url, { + headers: { Cookie: this.sessionCookies! }, + signal: withTimeoutSignal(signal, API_TIMEOUT_MS) + }); + + const json = await res.json() as Record<string, unknown>; + + if (json.status === "ERROR" && json.error) { + const errorMsg = String(json.error); + if (/not logged in|session expired|unauthorized/i.test(errorMsg)) { + this.sessionCookies = null; + if (attempt < REQUEST_RETRIES) { + continue; + } + throw new Error(`LinkSnappy: ${errorMsg}`); + } + throw new Error(`LinkSnappy: ${errorMsg}`); + } + + const links = json.links as Array<Record<string, unknown>> | undefined; + if (!links || links.length === 0) { + throw new Error("LinkSnappy: Keine Antwort-Daten"); + } + + const entry = links[0]; + if (entry.status === "ERROR" || (entry.error && entry.status !== "OK")) { + const errText = String(entry.error); + if (/quota|limit/i.test(errText)) { + throw new Error(`LinkSnappy: Quota erreicht – ${errText}`); + } + throw new Error(`LinkSnappy: ${errText}`); + } + + let directUrl = String(entry.generated || ""); + if (!directUrl) { + throw new Error("LinkSnappy: Keine Download-URL in Antwort"); + } + if (directUrl.startsWith("http://")) { + directUrl = directUrl.replace("http://", "https://"); + } + + const fileName = String(entry.filename || "") || filenameFromUrl(directUrl) || filenameFromUrl(link); + const rawSize = entry.filesize; + let fileSize: number | null = null; + if (typeof rawSize === "number" && rawSize > 0) { + fileSize = rawSize; + } else if (typeof rawSize === "string") { + const parsed = parseFileSizeString(rawSize); + if (parsed > 0) fileSize = parsed; + } + + logger.info(`LinkSnappy: Unrestrict OK → ${fileName || "?"}`); + + return { + fileName, + directUrl, + fileSize, + retriesUsed: attempt - 1, + sourceLabel: "API" + }; + } catch (error) { + lastError = compactErrorText(error); + if (signal?.aborted || (/aborted/i.test(lastError) && !/timeout/i.test(lastError))) { + throw error; + } + if (/fehlgeschlagen/i.test(lastError) && /Login/i.test(lastError)) { + throw error; + } + if (attempt < REQUEST_RETRIES) { + await sleepWithSignal(retryDelay(attempt), signal); + } + } + } + + throw new Error(String(lastError || "LinkSnappy Unrestrict fehlgeschlagen").replace(/^Error:\s*/i, "")); + } +} + +function parseFileSizeString(s: string): number { + const match = s.trim().match(/^([\d.]+)\s*([KMGT]?)B?$/i); + if (!match) return 0; + const num = parseFloat(match[1]); + const unit = (match[2] || "").toUpperCase(); + const multipliers: Record<string, number> = { "": 1, K: 1024, M: 1024 ** 2, G: 1024 ** 3, T: 1024 ** 4 }; + return Math.floor(num * (multipliers[unit] || 1)); +} + +class OneFichierClient { + private apiKey: string; + + public constructor(apiKey: string) { + this.apiKey = apiKey; + } + + public async unrestrictLink(link: string, signal?: AbortSignal): Promise<UnrestrictedLink> { + if (!ONEFICHIER_URL_RE.test(link)) { + throw new Error("Kein 1Fichier-Link"); + } + + let lastError = ""; + for (let attempt = 1; attempt <= REQUEST_RETRIES; attempt += 1) { + if (signal?.aborted) throw new Error("aborted:debrid"); + try { + const res = await fetch(`${ONEFICHIER_API_BASE}/download/get_token.cgi`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${this.apiKey}` + }, + body: JSON.stringify({ url: link, pretty: 1, cdn: 0 }), + signal: withTimeoutSignal(signal, API_TIMEOUT_MS) + }); + + const json = await res.json() as Record<string, unknown>; + + if (json.status === "KO" || json.error) { + const msg = String(json.message || json.error || "Unbekannter 1Fichier-Fehler"); + throw new Error(msg); + } + + const directUrl = String(json.url || ""); + if (!directUrl) { + throw new Error("1Fichier: Keine Download-URL in Antwort"); + } + + return { + fileName: filenameFromUrl(directUrl) || filenameFromUrl(link), + directUrl, + fileSize: null, + retriesUsed: attempt - 1 + }; + } catch (error) { + lastError = compactErrorText(error); + if (signal?.aborted || (/aborted/i.test(lastError) && !/timeout/i.test(lastError))) { + throw error; + } + if (attempt < REQUEST_RETRIES) { + await sleepWithSignal(retryDelay(attempt), signal); + } + } + } + throw new Error(`1Fichier-Unrestrict fehlgeschlagen: ${lastError}`); + } +} + +const DDOWNLOAD_URL_RE = /^https?:\/\/(?:www\.)?(?:ddownload\.com|ddl\.to)\/([a-z0-9]+)/i; +const DDOWNLOAD_WEB_BASE = "https://ddownload.com"; +const DDOWNLOAD_WEB_UA = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/133.0.0.0 Safari/537.36"; + +class DdownloadClient { + private login: string; + private password: string; + private cookies: string = ""; + + public constructor(login: string, password: string) { + this.login = login; + this.password = password; + } + + private async webLogin(signal?: AbortSignal): Promise<void> { + const loginPageRes = await fetch(`${DDOWNLOAD_WEB_BASE}/login.html`, { + headers: { "User-Agent": DDOWNLOAD_WEB_UA }, + redirect: "manual", + signal: withTimeoutSignal(signal, API_TIMEOUT_MS) + }); + const loginPageHtml = await loginPageRes.text(); + const tokenMatch = loginPageHtml.match(/name="token" value="([^"]+)"/); + const pageCookies = (loginPageRes.headers.getSetCookie?.() || []).map((c: string) => c.split(";")[0]).join("; "); + + const body = new URLSearchParams({ + op: "login", + token: tokenMatch?.[1] || "", + rand: "", + redirect: "", + login: this.login, + password: this.password + }); + const loginRes = await fetch(`${DDOWNLOAD_WEB_BASE}/`, { + method: "POST", + headers: { + "User-Agent": DDOWNLOAD_WEB_UA, + "Content-Type": "application/x-www-form-urlencoded", + ...(pageCookies ? { Cookie: pageCookies } : {}) + }, + body: body.toString(), + redirect: "manual", + signal: withTimeoutSignal(signal, API_TIMEOUT_MS) + }); + + try { await loginRes.text(); } catch { } + + const setCookies = loginRes.headers.getSetCookie?.() || []; + const xfss = setCookies.find((c: string) => c.startsWith("xfss=")); + const loginCookie = setCookies.find((c: string) => c.startsWith("login=")); + if (!xfss) { + throw new Error("DDownload Login fehlgeschlagen (kein Session-Cookie)"); + } + this.cookies = [loginCookie, xfss].filter((c): c is string => Boolean(c)).map((c) => c.split(";")[0]).join("; "); + } + + public async unrestrictLink(link: string, signal?: AbortSignal): Promise<UnrestrictedLink> { + const match = link.match(DDOWNLOAD_URL_RE); + if (!match) { + throw new Error("Kein DDownload-Link"); + } + const fileCode = match[1]; + let lastError = ""; + + for (let attempt = 1; attempt <= REQUEST_RETRIES; attempt += 1) { + try { + if (signal?.aborted) throw new Error("aborted:debrid"); + + if (!this.cookies) { + await this.webLogin(signal); + } + + const filePageRes = await fetch(`${DDOWNLOAD_WEB_BASE}/${fileCode}`, { + headers: { + "User-Agent": DDOWNLOAD_WEB_UA, + Cookie: this.cookies + }, + redirect: "manual", + signal: withTimeoutSignal(signal, API_TIMEOUT_MS) + }); + + if (filePageRes.status >= 300 && filePageRes.status < 400) { + const directUrl = filePageRes.headers.get("location") || ""; + try { await filePageRes.text(); } catch { } + if (directUrl) { + return { + fileName: filenameFromUrl(directUrl) || filenameFromUrl(link), + directUrl, + fileSize: null, + retriesUsed: attempt - 1, + skipTlsVerify: true + }; + } + } + + const html = await filePageRes.text(); + + if (/File Not Found|file was removed|file was banned/i.test(html)) { + throw new Error("DDownload: Datei nicht gefunden"); + } + + const idVal = html.match(/name="id" value="([^"]+)"/)?.[1] || fileCode; + const randVal = html.match(/name="rand" value="([^"]+)"/)?.[1] || ""; + const fileNameMatch = html.match(/class="file-info-name"[^>]*>([^<]+)</); + const fileName = fileNameMatch?.[1]?.trim() || filenameFromUrl(link); + + const dlBody = new URLSearchParams({ + op: "download2", + id: idVal, + rand: randVal, + referer: "", + method_premium: "1", + adblock_detected: "0" + }); + + const dlRes = await fetch(`${DDOWNLOAD_WEB_BASE}/${fileCode}`, { + method: "POST", + headers: { + "User-Agent": DDOWNLOAD_WEB_UA, + "Content-Type": "application/x-www-form-urlencoded", + Cookie: this.cookies, + Referer: `${DDOWNLOAD_WEB_BASE}/${fileCode}` + }, + body: dlBody.toString(), + redirect: "manual", + signal: withTimeoutSignal(signal, API_TIMEOUT_MS) + }); + + if (dlRes.status >= 300 && dlRes.status < 400) { + const directUrl = dlRes.headers.get("location") || ""; + try { await dlRes.text(); } catch { } + if (directUrl) { + return { + fileName: fileName || filenameFromUrl(directUrl), + directUrl, + fileSize: null, + retriesUsed: attempt - 1, + skipTlsVerify: true + }; + } + } + + const dlHtml = await dlRes.text(); + const directMatch = dlHtml.match(/https?:\/\/[a-z0-9]+\.(?:dstorage\.org|ddownload\.com|ddl\.to|ucdn\.to)[^\s"'<>]+/i); + if (directMatch) { + return { + fileName, + directUrl: directMatch[0], + fileSize: null, + retriesUsed: attempt - 1, + skipTlsVerify: true + }; + } + + const errMatch = dlHtml.match(/class="err"[^>]*>([^<]+)</i); + if (errMatch) { + throw new Error(`DDownload: ${errMatch[1].trim()}`); + } + + throw new Error("DDownload: Kein Download-Link erhalten"); + } catch (error) { + lastError = compactErrorText(error); + if (signal?.aborted || (/aborted/i.test(lastError) && !/timeout/i.test(lastError))) { + break; + } + if (/login|session|cookie/i.test(lastError)) { + this.cookies = ""; + } + if (attempt >= REQUEST_RETRIES || !isRetryableErrorText(lastError)) { + break; + } + await sleepWithSignal(retryDelay(attempt), signal); + } + } + + throw new Error(String(lastError || "DDownload Unrestrict fehlgeschlagen").replace(/^Error:\s*/i, "")); + } +} + +export class DebridService { + private settings: AppSettings; + + private options: DebridServiceOptions; + + private cachedDdownloadClient: DdownloadClient | null = null; + private cachedDdownloadKey = ""; + private cachedDebridLinkClient: DebridLinkClient | null = null; + private cachedDebridLinkKey = ""; + private cachedLinkSnappyClient: LinkSnappyClient | null = null; + private cachedLinkSnappyKey = ""; + + public constructor(settings: AppSettings, options: DebridServiceOptions = {}) { + this.settings = cloneSettings(settings); + this.options = options; + } + + public setSettings(next: AppSettings): void { + const prev = this.settings; + this.settings = cloneSettings(next); + + if (prev.debridLinkApiKeys !== next.debridLinkApiKeys) { + this.cachedDebridLinkClient = null; + this.cachedDebridLinkKey = ""; + } + if (prev.linkSnappyLogin !== next.linkSnappyLogin || prev.linkSnappyPassword !== next.linkSnappyPassword) { + this.cachedLinkSnappyClient = null; + this.cachedLinkSnappyKey = ""; + } + if (prev.ddownloadLogin !== next.ddownloadLogin || prev.ddownloadPassword !== next.ddownloadPassword) { + this.cachedDdownloadClient = null; + this.cachedDdownloadKey = ""; + } + + const prevAccounts = parseMegaDebridAccounts(prev.megaCredentials || "", prev.megaPassword || ""); + const nextAccounts = parseMegaDebridAccounts(next.megaCredentials || "", next.megaPassword || ""); + const nextLogins = new Set<string>(); + const nextPasswordByLogin = new Map<string, string>(); + for (const acc of nextAccounts) { + nextLogins.add(acc.login.toLowerCase()); + nextPasswordByLogin.set(acc.login.toLowerCase(), acc.password); + } + MegaDebridClient.pruneCachedTokensNotIn(nextLogins); + for (const prevAcc of prevAccounts) { + const loginKey = prevAcc.login.toLowerCase(); + if (nextLogins.has(loginKey) && nextPasswordByLogin.get(loginKey) !== prevAcc.password) { + MegaDebridClient.clearCachedApiToken(prevAcc.login); + } + } + const nextDebridLinkKeyIds = new Set<string>(parseDebridLinkApiKeys(next.debridLinkApiKeys || "").map((entry) => entry.id)); + pruneDebridLinkRuntimeStateForKeys(nextDebridLinkKeyIds); + } + + private getDebridLinkClient(apiKeysRaw: string): DebridLinkClient { + if (this.cachedDebridLinkClient && this.cachedDebridLinkKey === apiKeysRaw) { + return this.cachedDebridLinkClient; + } + this.cachedDebridLinkClient = new DebridLinkClient(apiKeysRaw); + this.cachedDebridLinkKey = apiKeysRaw; + return this.cachedDebridLinkClient; + } + + private getLinkSnappyClient(login: string, password: string): LinkSnappyClient { + const key = `${login}\0${password}`; + if (this.cachedLinkSnappyClient && this.cachedLinkSnappyKey === key) { + return this.cachedLinkSnappyClient; + } + this.cachedLinkSnappyClient = new LinkSnappyClient(login, password); + this.cachedLinkSnappyKey = key; + return this.cachedLinkSnappyClient; + } + + private getDdownloadClient(login: string, password: string): DdownloadClient { + const key = `${login}\0${password}`; + if (this.cachedDdownloadClient && this.cachedDdownloadKey === key) { + return this.cachedDdownloadClient; + } + this.cachedDdownloadClient = new DdownloadClient(login, password); + this.cachedDdownloadKey = key; + return this.cachedDdownloadClient; + } + + public async resolveFilenames( + links: string[], + onResolved?: (link: string, fileName: string) => void, + signal?: AbortSignal + ): Promise<Map<string, string>> { + const settings = cloneSettings(this.settings); + const allDebridClient = new AllDebridClient(settings.allDebridToken); + const unresolved = links.filter((link) => looksLikeOpaqueFilename(filenameFromUrl(link))); + if (unresolved.length === 0) { + return new Map<string, string>(); + } + + const clean = new Map<string, string>(); + const reportResolved = (link: string, fileName: string): void => { + const normalized = fileName.trim(); + if (!normalized || looksLikeOpaqueFilename(normalized) || normalized.toLowerCase() === "download.bin") { + return; + } + if (clean.get(link) === normalized) { + return; + } + clean.set(link, normalized); + onResolved?.(link, normalized); + }; + + const token = settings.allDebridToken.trim(); + if (token) { + try { + const infos = await allDebridClient.getLinkInfos(unresolved, signal); + for (const [link, fileName] of infos.entries()) { + reportResolved(link, fileName); + } + } catch (error) { + const errorText = compactErrorText(error); + if (signal?.aborted || (/aborted/i.test(errorText) && !/timeout/i.test(errorText))) { + throw error; + } + } + } + + const megaLinks = unresolved.filter((link) => !clean.has(link) && isMegaFileUrl(link)); + if (megaLinks.length > 0) { + await runWithConcurrency(megaLinks, 4, async (link) => { + try { + const info = await resolveMegaFilename(link, signal); + if (info?.name) { + reportResolved(link, info.name); + } + } catch (error) { + const errorText = compactErrorText(error); + if (signal?.aborted || (/aborted/i.test(errorText) && !/timeout/i.test(errorText))) { + throw error; + } + } + }); + } + + const remaining = unresolved.filter((link) => !clean.has(link) && isRapidgatorLink(link)); + await runWithConcurrency(remaining, 6, async (link) => { + const fromPage = await resolveRapidgatorFilename(link, signal); + reportResolved(link, fromPage); + }); + + return clean; + } + + private shouldUseRealDebridWeb(settings: AppSettings): boolean { + return Boolean(settings.realDebridUseWebLogin && this.options.realDebridWebUnrestrict); + } + + private shouldUseAllDebridWeb(settings: AppSettings): boolean { + return Boolean(settings.allDebridUseWebLogin && this.options.allDebridWebUnrestrict); + } + + private shouldUseBestDebridWeb(settings: AppSettings): boolean { + return Boolean(settings.bestDebridUseWebLogin && this.options.bestDebridWebUnrestrict); + } + + private isProviderDailyLimited(settings: AppSettings, provider: DebridProvider): boolean { + const effectiveProvider = resolveMegaDebridProvider(settings, provider); + if (effectiveProvider === "debridlink") { + const configuredKeys = parseDebridLinkApiKeys(settings.debridLinkApiKeys); + if (configuredKeys.length > 0 && getAvailableDebridLinkApiKeys(settings).length === 0) { + return true; + } + } + if (effectiveProvider === "megadebrid-api" || effectiveProvider === "megadebrid-web") { + const configuredAccounts = getMegaDebridAccountList(settings); + if (configuredAccounts.length > 0 && getAvailableMegaDebridAccounts(settings).length === 0) { + return true; + } + } + return isProviderDailyLimitReached(settings, effectiveProvider); + } + + private isProviderSelectableFor(settings: AppSettings, provider: DebridProvider): boolean { + return this.isProviderConfiguredFor(settings, provider) && !this.isProviderDailyLimited(settings, provider); + } + + private formatProviderLimitMessage(settings: AppSettings, provider: DebridProvider): string { + const effectiveProvider = resolveMegaDebridProvider(settings, provider); + if (effectiveProvider === "debridlink" && parseDebridLinkApiKeys(settings.debridLinkApiKeys).length > 0 && getAvailableDebridLinkApiKeys(settings).length === 0) { + return "Debrid-Link nicht verfuegbar (alle aktiven API-Keys deaktiviert oder ausgeschopft)"; + } + if ((effectiveProvider === "megadebrid-api" || effectiveProvider === "megadebrid-web") && getMegaDebridAccountList(settings).length > 0 && getAvailableMegaDebridAccounts(settings).length === 0) { + return "Mega-Debrid nicht verfuegbar (alle aktiven Accounts deaktiviert oder ausgeschopft)"; + } + return `${PROVIDER_LABELS[effectiveProvider]} Tageslimit erreicht`; + } + + public async unrestrictLink(link: string, signal?: AbortSignal, settingsSnapshot?: AppSettings, preferredLeadProvider?: DebridProvider | null): Promise<ProviderUnrestrictedLink> { + const settings = settingsSnapshot ? cloneSettings(settingsSnapshot) : cloneSettings(this.settings); + + const routing = settings.hosterRouting || {}; + const hosterKey = extractHosterFromUrl(link); + if (hosterKey && routing[hosterKey]) { + const routedProvider = routing[hosterKey]; + if (this.isProviderSelectableFor(settings, routedProvider)) { + logger.info(`Hoster-Zuordnung: ${hosterKey} → ${PROVIDER_LABELS[routedProvider]}`); + try { + const result = await this.unrestrictViaProvider(settings, routedProvider, link, signal); + let fileName = result.fileName; + if (isRapidgatorLink(link) && looksLikeOpaqueFilename(fileName || filenameFromUrl(link))) { + const fromPage = await resolveRapidgatorFilename(link, signal); + if (fromPage) fileName = fromPage; + } + return { + ...result, + fileName, + provider: routedProvider, + providerLabel: PROVIDER_LABELS[routedProvider] + (result.sourceLabel ? ` (${result.sourceLabel})` : "") + }; + } catch (error) { + const errorText = compactErrorText(error); + if (signal?.aborted || (/aborted/i.test(errorText) && !/timeout/i.test(errorText))) { + throw error; + } + if (!settings.autoProviderFallback) { + throw new Error(`Hoster-Zuordnung fehlgeschlagen (${hosterKey} → ${PROVIDER_LABELS[routedProvider]}): ${errorText}`); + } + logger.warn(`Hoster-Zuordnung ${hosterKey} → ${PROVIDER_LABELS[routedProvider]} fehlgeschlagen, Fallback auf Provider-Kette: ${errorText}`); + } + } else if (this.isProviderConfiguredFor(settings, routedProvider) && this.isProviderDailyLimited(settings, routedProvider)) { + logger.info(`Hoster-Zuordnung ${hosterKey} → ${PROVIDER_LABELS[routedProvider]} übersprungen (${this.formatProviderLimitMessage(settings, routedProvider)})`); + } else { + logger.warn(`Hoster-Zuordnung ${hosterKey} → ${PROVIDER_LABELS[routedProvider]} übersprungen (Provider nicht konfiguriert/deaktiviert)`); + } + } + + if (ONEFICHIER_URL_RE.test(link) && this.isProviderSelectableFor(settings, "onefichier")) { + try { + const result = await this.unrestrictViaProvider(settings, "onefichier", link, signal); + return { + ...result, + provider: "onefichier", + providerLabel: PROVIDER_LABELS["onefichier"] + (result.sourceLabel ? ` (${result.sourceLabel})` : "") + }; + } catch (error) { + const errorText = compactErrorText(error); + if (signal?.aborted || (/aborted/i.test(errorText) && !/timeout/i.test(errorText))) { + throw error; + } + if (!settings.autoProviderFallback) { + throw error; + } + } + } + + if (DDOWNLOAD_URL_RE.test(link) && this.isProviderSelectableFor(settings, "ddownload")) { + try { + const result = await this.unrestrictViaProvider(settings, "ddownload", link, signal); + return { + ...result, + provider: "ddownload", + providerLabel: PROVIDER_LABELS["ddownload"] + (result.sourceLabel ? ` (${result.sourceLabel})` : "") + }; + } catch (error) { + const errorText = compactErrorText(error); + if (signal?.aborted || (/aborted/i.test(errorText) && !/timeout/i.test(errorText))) { + throw error; + } + if (!settings.autoProviderFallback) { + throw error; + } + } + } + + const baseOrder: DebridProvider[] = (settings.providerOrder && settings.providerOrder.length > 0) + ? uniqueProviderOrder(settings.providerOrder) + : toProviderOrder(settings.providerPrimary, settings.providerSecondary, settings.providerTertiary); + const order = leadProviderChainWith(baseOrder, preferredLeadProvider); + + const primary = order[0]; + if (!settings.autoProviderFallback) { + if (!this.isProviderConfiguredFor(settings, primary)) { + throw new Error(`${PROVIDER_LABELS[primary]} nicht konfiguriert`); + } + const selectedProvider = this.isProviderDailyLimited(settings, primary) + ? order.find((provider) => provider !== primary && this.isProviderSelectableFor(settings, provider)) + : primary; + if (!selectedProvider) { + throw new Error(this.formatProviderLimitMessage(settings, primary)); + } + try { + const result = await this.unrestrictViaProvider(settings, selectedProvider, link, signal); + let fileName = result.fileName; + if (isRapidgatorLink(link) && looksLikeOpaqueFilename(fileName || filenameFromUrl(link))) { + const fromPage = await resolveRapidgatorFilename(link, signal); + if (fromPage) { + fileName = fromPage; + } + } + return { + ...result, + fileName, + provider: selectedProvider, + providerLabel: PROVIDER_LABELS[selectedProvider] + (result.sourceLabel ? ` (${result.sourceLabel})` : "") + }; + } catch (error) { + const errorText = compactErrorText(error); + if (signal?.aborted || (/aborted/i.test(errorText) && !/timeout/i.test(errorText))) { + throw error; + } + throw new Error(`Unrestrict fehlgeschlagen: ${PROVIDER_LABELS[selectedProvider]}: ${errorText}`); + } + } + + let configuredFound = false; + let limitReachedFound = false; + const attempts: string[] = []; + + for (const provider of order) { + if (!this.isProviderConfiguredFor(settings, provider)) { + continue; + } + configuredFound = true; + if (this.isProviderDailyLimited(settings, provider)) { + limitReachedFound = true; + logger.info(`Provider-Kette: ${PROVIDER_LABELS[provider]} uebersprungen (${this.formatProviderLimitMessage(settings, provider)})`); + attempts.push(this.formatProviderLimitMessage(settings, provider)); + continue; + } + + const providerStartedAt = Date.now(); + try { + logger.info(`Provider-Kette: versuche ${PROVIDER_LABELS[provider]}`); + traceConversionPhase({ phase: "chain-try", provider }); + const result = await this.unrestrictViaProvider(settings, provider, link, signal); + traceConversionPhase({ phase: "chain-ok", provider, workMs: Date.now() - providerStartedAt, outcome: "ok" }); + let fileName = result.fileName; + if (isRapidgatorLink(link) && looksLikeOpaqueFilename(fileName || filenameFromUrl(link))) { + const fromPage = await resolveRapidgatorFilename(link, signal); + if (fromPage) { + fileName = fromPage; + } + } + return { + ...result, + fileName, + provider, + providerLabel: PROVIDER_LABELS[provider] + (result.sourceLabel ? ` (${result.sourceLabel})` : "") + }; + } catch (error) { + const errorText = compactErrorText(error); + if (signal?.aborted || (/aborted/i.test(errorText) && !/timeout/i.test(errorText))) { + traceConversionPhase({ phase: "chain-aborted", provider, workMs: Date.now() - providerStartedAt, outcome: "aborted", detail: errorText.slice(0, 120) }); + throw error; + } + const nextProvider = order.slice(order.indexOf(provider) + 1).find((candidate) => this.isProviderSelectableFor(settings, candidate)); + traceConversionPhase({ + phase: "chain-failed", + provider, + workMs: Date.now() - providerStartedAt, + outcome: nextProvider ? "failover" : "exhausted", + detail: `${errorText.slice(0, 120)}${nextProvider ? ` → ${nextProvider}` : ""}` + }); + if (nextProvider) { + logger.warn(`Provider-Kette: ${PROVIDER_LABELS[provider]} fehlgeschlagen (${errorText}), Fallback auf ${PROVIDER_LABELS[nextProvider]}`); + } else { + logger.warn(`Provider-Kette: ${PROVIDER_LABELS[provider]} fehlgeschlagen (${errorText}), kein weiterer Provider verfuegbar`); + } + attempts.push(`${PROVIDER_LABELS[provider]}: ${compactErrorText(error)}`); + } + } + + if (!configuredFound) { + throw new Error("Kein Debrid-Provider konfiguriert"); + } + if (limitReachedFound && attempts.every((entry) => /Tageslimit erreicht$/i.test(entry))) { + throw new Error("Alle konfigurierten Provider haben ihr Tageslimit erreicht"); + } + + throw new Error(`Unrestrict fehlgeschlagen: ${attempts.join(" | ")}`); + } + + private isProviderConfiguredFor(settings: AppSettings, provider: DebridProvider): boolean { + const effectiveProvider = resolveMegaDebridProvider(settings, provider); + if ((settings.disabledProviders || []).includes(provider) || (settings.disabledProviders || []).includes(effectiveProvider)) return false; + if (effectiveProvider === "realdebrid") { + return Boolean(this.shouldUseRealDebridWeb(settings) || settings.token.trim()); + } + if (effectiveProvider === "megadebrid-api") { + return Boolean(hasMegaDebridCredentials(settings) && isMegaDebridModeEnabled(settings, "api")); + } + if (effectiveProvider === "megadebrid-web") { + return Boolean(hasMegaDebridCredentials(settings) && isMegaDebridModeEnabled(settings, "web") && this.options.megaWebUnrestrict); + } + if (effectiveProvider === "alldebrid") { + return Boolean(this.shouldUseAllDebridWeb(settings) || settings.allDebridToken.trim()); + } + if (effectiveProvider === "ddownload") { + return Boolean(settings.ddownloadLogin.trim() && settings.ddownloadPassword.trim()); + } + if (effectiveProvider === "onefichier") { + return Boolean(settings.oneFichierApiKey.trim()); + } + if (effectiveProvider === "debridlink") { + return Boolean(settings.debridLinkApiKeys.trim()); + } + if (effectiveProvider === "linksnappy") { + return Boolean(settings.linkSnappyLogin.trim() && settings.linkSnappyPassword.trim()); + } + return Boolean(this.shouldUseBestDebridWeb(settings) || settings.bestToken.trim()); + } + + private async unrestrictViaProvider(settings: AppSettings, provider: DebridProvider, link: string, signal?: AbortSignal): Promise<UnrestrictedLink> { + const effectiveProvider = resolveMegaDebridProvider(settings, provider); + if (effectiveProvider === "realdebrid") { + if (this.shouldUseRealDebridWeb(settings) && this.options.realDebridWebUnrestrict) { + const result = await this.options.realDebridWebUnrestrict(link, signal); + if (!result) { + throw new Error("Real-Debrid-Web-Fallback nicht verfügbar"); + } + result.sourceLabel = "Web"; + return result; + } + const result = await new RealDebridClient(settings.token).unrestrictLink(link, signal); + result.sourceLabel = "API"; + return result; + } + if (effectiveProvider === "megadebrid-api") { + return MegaDebridClient.unrestrictWithAccounts(settings, "api", provider === "megadebrid" && settings.megaDebridPreferApi, link, this.options.megaWebUnrestrict, signal); + } + if (effectiveProvider === "megadebrid-web") { + return MegaDebridClient.unrestrictWithAccounts(settings, "web", false, link, this.options.megaWebUnrestrict, signal); + } + if (effectiveProvider === "alldebrid") { + if (this.shouldUseAllDebridWeb(settings) && this.options.allDebridWebUnrestrict) { + const result = await this.options.allDebridWebUnrestrict(link, signal); + if (!result) { + throw new Error("AllDebrid-Web-Fallback nicht verfügbar"); + } + result.sourceLabel = "Web"; + return result; + } + const adResult = await new AllDebridClient(settings.allDebridToken).unrestrictLink(link, signal); + adResult.sourceLabel = "API"; + return adResult; + } + if (effectiveProvider === "ddownload") { + return this.getDdownloadClient(settings.ddownloadLogin, settings.ddownloadPassword).unrestrictLink(link, signal); + } + if (effectiveProvider === "onefichier") { + return new OneFichierClient(settings.oneFichierApiKey).unrestrictLink(link, signal); + } + if (effectiveProvider === "debridlink") { + const dlResult = await this.getDebridLinkClient(settings.debridLinkApiKeys).unrestrictLink(link, settings, signal); + dlResult.sourceLabel = dlResult.sourceLabel || "API"; + return dlResult; + } + if (effectiveProvider === "linksnappy") { + return this.getLinkSnappyClient(settings.linkSnappyLogin, settings.linkSnappyPassword).unrestrictLink(link, signal); + } + if (this.shouldUseBestDebridWeb(settings) && this.options.bestDebridWebUnrestrict) { + const bdResult = await this.options.bestDebridWebUnrestrict(link, signal); + if (!bdResult) { + throw new Error("BestDebrid-Web-Fallback nicht verfügbar"); + } + bdResult.sourceLabel = "Web"; + return bdResult; + } + const bdResult = await new BestDebridClient(settings.bestToken).unrestrictLink(link, signal); + bdResult.sourceLabel = "API"; + return bdResult; + } +} diff --git a/src/main/debug-server.ts b/src/main/debug-server.ts new file mode 100644 index 0000000..4140600 --- /dev/null +++ b/src/main/debug-server.ts @@ -0,0 +1,1181 @@ +import http from "node:http"; +import fs from "node:fs"; +import path from "node:path"; +import crypto from "node:crypto"; +import { APP_VERSION } from "./constants"; +import { getAuditLogPath } from "./audit-log"; +import { getDebugSetupCheck } from "./debug-setup"; +import { logger, getLogFilePath } from "./logger"; +import { getRecentErrors } from "./error-ring"; +import { getItemLogPath as getPersistedItemLogPath } from "./item-log"; +import { getSessionLogPath } from "./session-log"; +import { getPackageLogPath as getPersistedPackageLogPath } from "./package-log"; +import { getRenameLogPath } from "./rename-log"; +import { createStoragePaths, loadHistory, loadSettings } from "./storage"; +import { buildAccountSummary, buildRedactedSettingsPayload, buildStatsPayload, summarizeHistoryEntry } from "./support-data"; +import { buildSupportBundle, getSupportBundleDefaultFileName } from "./support-bundle"; +import { getTraceConfig, getTraceConfigPath, getTraceLogPath, logTraceEvent, setTraceEnabled, updateTraceConfig } from "./trace-log"; +import { getConversionLogPath } from "./conversion-trace"; +import { getProviderRuntimeSnapshot } from "./debrid"; +import { getWindowsHostDiagnostics } from "./windows-host-diagnostics"; +import type { DownloadManager } from "./download-manager"; +import type { DownloadItem, PackageEntry, UiSnapshot } from "../shared/types"; + +const DEFAULT_PORT = 9868; +const DEFAULT_HOST = "127.0.0.1"; +const MAX_LOG_LINES = 10000; +const SUPPORT_MANIFEST_FILE = "debug_support_manifest.json"; + +type DebugEndpointDescriptor = { + method: "GET"; + path: string; + queryExample?: string; + description: string; +}; + +const DEBUG_ENDPOINTS: DebugEndpointDescriptor[] = [ + { method: "GET", path: "/health", description: "Basic health, uptime, and memory information." }, + { method: "GET", path: "/meta", description: "Lists runtime metadata and all available endpoints." }, + { method: "GET", path: "/debug/setup", description: "Checks whether the local debug setup is configured for support." }, + { method: "GET", path: "/self-check", description: "Extended support self-check with disk space, log sizes, and support bundle estimate." }, + { method: "GET", path: "/host/diagnostics", description: "Returns Windows host crash and dump diagnostics." }, + { method: "GET", path: "/log", queryExample: "lines=100&grep=keyword", description: "Legacy alias for the main application log tail." }, + { method: "GET", path: "/logs/main", queryExample: "lines=100&grep=keyword", description: "Reads the main application log tail." }, + { method: "GET", path: "/logs/audit", queryExample: "lines=100&grep=keyword", description: "Reads the audit log for support-relevant UI and admin actions." }, + { method: "GET", path: "/logs/rename", queryExample: "lines=100&grep=keyword", description: "Reads the dedicated rename and MKV move log." }, + { method: "GET", path: "/logs/trace", queryExample: "lines=100&grep=keyword", description: "Reads the optional support trace log." }, + { method: "GET", path: "/logs/session", queryExample: "lines=100&grep=keyword", description: "Reads the session log tail." }, + { method: "GET", path: "/logs/conversion", queryExample: "lines=100&grep=keyword", description: "Reads the per-item link conversion/unrestrict lifecycle log (token, API getLink, web, account rotation, aborts with timings)." }, + { method: "GET", path: "/logs/package", queryExample: "package=Release&lines=100&grep=keyword", description: "Reads the package log for a specific package name or id." }, + { method: "GET", path: "/logs/item", queryExample: "item=episode.part2.rar&lines=100&grep=keyword", description: "Reads the item log for a specific file name or item id." }, + { method: "GET", path: "/errors", queryExample: "level=ERROR&limit=100", description: "Returns the in-memory ring of the most recent WARN/ERROR log lines." }, + { method: "GET", path: "/trace/config", queryExample: "enable=1¬e=support&durationMinutes=120", description: "Reads or updates the support trace configuration." }, + { method: "GET", path: "/settings", description: "Returns a redacted settings snapshot without raw secrets." }, + { method: "GET", path: "/accounts", description: "Returns a redacted account/provider configuration summary." }, + { method: "GET", path: "/providers", description: "Live provider runtime state: per-account/key cooldowns (until/remaining/reason/category), in-flight depth, Mega rotation cursor, empty-response streaks. The 'why is it cooling down right now' view." }, + { method: "GET", path: "/stats", description: "Returns live session stats plus persisted all-time totals." }, + { method: "GET", path: "/history", queryExample: "limit=50&status=completed", description: "Returns history entries with optional filters." }, + { method: "GET", path: "/status", description: "Returns a live high-level status overview." }, + { method: "GET", path: "/packages", queryExample: "package=Release&includeItems=1", description: "Lists packages and optional per-item detail." }, + { method: "GET", path: "/items", queryExample: "status=downloading&package=Release", description: "Lists items and supports status/package filters." }, + { method: "GET", path: "/session", queryExample: "package=Release", description: "Returns session-wide or package-scoped item state." }, + { method: "GET", path: "/support/bundle", description: "Downloads a ZIP support bundle with logs, diagnostics, and redacted state." }, + { method: "GET", path: "/diagnostics", queryExample: "package=Release&lines=150", description: "Returns a combined support snapshot with logs, status, settings, accounts, stats, history, and host diagnostics." } +]; + +let server: http.Server | null = null; +let manager: DownloadManager | null = null; +let authToken = ""; +let bindHost = DEFAULT_HOST; +let bindPort = DEFAULT_PORT; +let runtimeBaseDir = ""; +let allowlist: string[] = []; + +export interface DebugServerRuntimeStatus { + running: boolean; + host: string; + port: number; + hasToken: boolean; + localOnly: boolean; + allowlistCount: number; +} + +function getStoragePaths() { + return createStoragePaths(runtimeBaseDir); +} + +function readSupportSettings() { + return loadSettings(getStoragePaths()); +} + +function readSupportHistory() { + return loadHistory(getStoragePaths()); +} + +function extractDebugClientIp(req: http.IncomingMessage): string { + const forwarded = req.headers["x-forwarded-for"]; + const forwardedValue = Array.isArray(forwarded) ? forwarded[0] : forwarded; + const forwardedIp = String(forwardedValue || "").split(",")[0]?.trim(); + if (forwardedIp) { + return forwardedIp; + } + const realIp = String(req.headers["x-real-ip"] || "").trim(); + if (realIp) { + return realIp; + } + return getPeerIp(req); +} + +function getSupportManifestPath(baseDir: string = runtimeBaseDir): string { + return path.join(baseDir, SUPPORT_MANIFEST_FILE); +} + +function getDebugTokenPath(baseDir: string = runtimeBaseDir): string { + return path.join(baseDir, "debug_token.txt"); +} + +function loadToken(baseDir: string): string { + const tokenPath = path.join(baseDir, "debug_token.txt"); + try { + return fs.readFileSync(tokenPath, "utf8").trim(); + } catch { + return ""; + } +} + +function getPort(baseDir: string): number { + const portPath = path.join(baseDir, "debug_port.txt"); + try { + const n = Number(fs.readFileSync(portPath, "utf8").trim()); + if (Number.isFinite(n) && n >= 1024 && n <= 65535) { + return n; + } + } catch { + } + return DEFAULT_PORT; +} + +function getHost(baseDir: string): string { + const hostPath = path.join(baseDir, "debug_host.txt"); + try { + const raw = fs.readFileSync(hostPath, "utf8").trim(); + if (!raw) { + return DEFAULT_HOST; + } + if (/^(localhost|0\.0\.0\.0|127\.0\.0\.1|::1)$/i.test(raw)) { + return raw; + } + if (/^[a-z0-9.-]+$/i.test(raw)) { + return raw; + } + } catch { + } + return DEFAULT_HOST; +} + +function getAllowlistPath(baseDir: string = runtimeBaseDir): string { + return path.join(baseDir, "debug_allowlist.txt"); +} + +function loadAllowlist(baseDir: string): string[] { + try { + return fs.readFileSync(getAllowlistPath(baseDir), "utf8") + .split(/\r?\n/) + .map((line) => line.trim()) + .filter((line) => line.length > 0 && !line.startsWith("#")); + } catch { + return []; + } +} + +function normalizeIp(ip: string): string { + return String(ip || "").trim().replace(/^::ffff:/i, "").toLowerCase(); +} + +function isLoopbackIp(ip: string): boolean { + const x = normalizeIp(ip); + return x === "::1" || x === "localhost" || x.startsWith("127."); +} + +function ipv4ToInt(ip: string): number | null { + const parts = ip.split("."); + if (parts.length !== 4) { + return null; + } + let result = 0; + for (const part of parts) { + const value = Number(part); + if (!Number.isInteger(value) || value < 0 || value > 255) { + return null; + } + result = (result * 256) + value; + } + return result >>> 0; +} + +function matchIpRule(clientIp: string, rule: string): boolean { + const client = normalizeIp(clientIp); + const r = rule.trim().toLowerCase(); + if (!r) { + return false; + } + if (r === "*" || r === "0.0.0.0/0") { + return true; + } + if (r === client) { + return true; + } + const slash = r.indexOf("/"); + if (slash > 0) { + const baseInt = ipv4ToInt(r.slice(0, slash)); + const clientInt = ipv4ToInt(client); + const bits = Number(r.slice(slash + 1)); + if (baseInt === null || clientInt === null || !Number.isInteger(bits) || bits < 0 || bits > 32) { + return false; + } + if (bits === 0) { + return true; + } + const mask = bits === 32 ? 0xffffffff : (~((1 << (32 - bits)) - 1)) >>> 0; + return (clientInt & mask) === (baseInt & mask); + } + return false; +} + +export function evaluateClientAllowed(clientIp: string, rules: string[]): boolean { + const client = normalizeIp(clientIp); + if (isLoopbackIp(client) || client === "") { + return true; + } + if (rules.length === 0) { + return false; + } + return rules.some((rule) => matchIpRule(client, rule)); +} + +export function getPeerIp(req: http.IncomingMessage): string { + return normalizeIp(req.socket?.remoteAddress || ""); +} + +function isClientAllowed(clientIp: string): boolean { + return evaluateClientAllowed(clientIp, allowlist); +} + +function checkAuth(req: http.IncomingMessage): boolean { + if (!authToken) { + return false; + } + const header = req.headers.authorization || ""; + if (header === `Bearer ${authToken}`) { + return true; + } + const url = new URL(req.url || "/", "http://localhost"); + return url.searchParams.get("token") === authToken; +} + +function jsonResponse(res: http.ServerResponse, status: number, data: unknown): void { + const body = JSON.stringify(data, null, 2); + res.writeHead(status, { + "Content-Type": "application/json; charset=utf-8", + "Access-Control-Allow-Origin": "*", + "Cache-Control": "no-cache" + }); + res.end(body); +} + +function binaryResponse( + res: http.ServerResponse, + status: number, + body: Buffer, + contentType: string, + fileName?: string +): void { + res.writeHead(status, { + "Content-Type": contentType, + "Content-Length": String(body.length), + "Access-Control-Allow-Origin": "*", + "Cache-Control": "no-cache", + ...(fileName ? { "Content-Disposition": `attachment; filename="${fileName}"` } : {}) + }); + res.end(body); +} + +function normalizeLinesParam(rawValue: string | null, fallback: number): number { + const parsed = Number(rawValue || String(fallback)); + if (!Number.isFinite(parsed) || parsed <= 0) { + return fallback; + } + return Math.max(1, Math.min(Math.floor(parsed), MAX_LOG_LINES)); +} + +function readLogTailFromFile(filePath: string | null, lines: number): string[] { + if (!filePath) { + return ["(Log-Datei nicht gefunden)"]; + } + try { + const content = fs.readFileSync(filePath, "utf8"); + const allLines = content.split("\n").filter((l) => l.trim().length > 0); + return allLines.slice(-Math.min(lines, MAX_LOG_LINES)); + } catch { + return ["(Log-Datei nicht lesbar)"]; + } +} + +function filterLines(lines: string[], grep: string): string[] { + const pattern = String(grep || "").trim().toLowerCase(); + if (!pattern) { + return lines; + } + return lines.filter((line) => line.toLowerCase().includes(pattern)); +} + +function toBooleanQuery(value: string | null): boolean | null { + if (value === null) { + return null; + } + if (/^(1|true|yes|on)$/i.test(value)) { + return true; + } + if (/^(0|false|no|off)$/i.test(value)) { + return false; + } + return null; +} + +function sanitizeRequestUrlForTrace(rawUrl: string): string { + try { + const url = new URL(rawUrl || "/", "http://localhost"); + if (url.searchParams.has("token")) { + url.searchParams.set("token", "***"); + } + return `${url.pathname}${url.search}`; + } catch { + return String(rawUrl || "/"); + } +} + +function formatEndpointSummary(endpoint: DebugEndpointDescriptor): string { + return `${endpoint.method} ${endpoint.path}${endpoint.queryExample ? `?${endpoint.queryExample}` : ""}`; +} + +function getEndpointSummaries(): string[] { + return DEBUG_ENDPOINTS.map((endpoint) => formatEndpointSummary(endpoint)); +} + +function buildSupportManifest(baseDir: string): Record<string, unknown> { + return { + schemaVersion: 1, + generatedAt: new Date().toISOString(), + appVersion: APP_VERSION, + runtimeBaseDir: baseDir, + purpose: "Machine-readable manifest for support tooling and remote troubleshooting.", + auth: { + required: true, + methods: [ + "Authorization: Bearer <token>", + "?token=<token>" + ], + tokenFile: path.join(baseDir, "debug_token.txt") + }, + runtimeFiles: { + hostFile: path.join(baseDir, "debug_host.txt"), + portFile: path.join(baseDir, "debug_port.txt"), + tokenFile: path.join(baseDir, "debug_token.txt"), + mainLogFile: getLogFilePath(), + auditLogFile: getAuditLogPath(), + renameLogFile: getRenameLogPath(), + traceLogFile: getTraceLogPath(), + traceConfigFile: getTraceConfigPath(), + sessionLogFile: getSessionLogPath(), + packageLogDir: path.join(baseDir, "package-logs"), + itemLogDir: path.join(baseDir, "item-logs"), + settingsFile: path.join(baseDir, "rd_downloader_config.json"), + sessionFile: path.join(baseDir, "rd_session_state.json"), + historyFile: path.join(baseDir, "rd_history.json") + }, + debugServer: { + enabled: Boolean(authToken), + host: bindHost, + port: bindPort, + localBaseUrl: `http://127.0.0.1:${bindPort}`, + remoteBaseUrlTemplate: `http://<SERVER_IP_OR_DNS>:${bindPort}` + }, + setupCheckEndpoint: "/debug/setup", + selfCheckEndpoint: "/self-check", + remoteAccessRequirements: [ + "A reachable server IP or DNS name.", + "The configured diagnostics port.", + "The token stored in debug_token.txt.", + "A network route and firewall rule that permit access to the configured port." + ], + endpoints: DEBUG_ENDPOINTS.map((endpoint) => ({ + ...endpoint, + summary: formatEndpointSummary(endpoint) + })) + }; +} + +function writeSupportManifest(baseDir: string): void { + try { + fs.writeFileSync(getSupportManifestPath(baseDir), JSON.stringify(buildSupportManifest(baseDir), null, 2), "utf8"); + } catch (error) { + logger.warn(`Debug-Server: Support-Manifest konnte nicht geschrieben werden: ${String(error)}`); + } +} + +export function rotateDebugToken(baseDir: string = runtimeBaseDir): { path: string; token: string } { + const token = crypto.randomBytes(24).toString("hex"); + const tokenPath = getDebugTokenPath(baseDir); + fs.writeFileSync(tokenPath, `${token}\n`, "utf8"); + if (baseDir === runtimeBaseDir) { + authToken = token; + writeSupportManifest(baseDir); + } + logger.info(`Debug-Server Token rotiert: ${tokenPath}`); + logTraceEvent("INFO", "support", "Debug-Token rotiert", { tokenPath }); + return { path: tokenPath, token }; +} + +function summarizeItem(item: DownloadItem): Record<string, unknown> { + return { + id: item.id, + packageId: item.packageId, + fileName: item.fileName, + status: item.status, + fullStatus: item.fullStatus, + provider: item.provider, + providerLabel: item.providerLabel || "", + progress: item.progressPercent, + speedMBs: +(item.speedBps / 1024 / 1024).toFixed(2), + downloadedMB: +(item.downloadedBytes / 1024 / 1024).toFixed(1), + totalMB: item.totalBytes ? +(item.totalBytes / 1024 / 1024).toFixed(1) : null, + retries: item.retries, + lastError: item.lastError, + targetPath: item.targetPath, + updatedAt: item.updatedAt + }; +} + +function summarizePackage(snapshot: UiSnapshot, pkg: PackageEntry, includeItems: boolean): Record<string, unknown> { + const ids = new Set(pkg.itemIds); + const packageItems = Object.values(snapshot.session.items).filter((item) => ids.has(item.id)); + const byStatus: Record<string, number> = {}; + for (const item of packageItems) { + byStatus[item.status] = (byStatus[item.status] || 0) + 1; + } + return { + id: pkg.id, + name: pkg.name, + status: pkg.status, + enabled: pkg.enabled, + cancelled: pkg.cancelled, + outputDir: pkg.outputDir, + extractDir: pkg.extractDir, + postProcessLabel: pkg.postProcessLabel || "", + itemCount: pkg.itemIds.length, + itemCounts: byStatus, + updatedAt: pkg.updatedAt, + items: includeItems ? packageItems.map((item) => summarizeItem(item)) : undefined + }; +} + +function findPackage(snapshot: UiSnapshot, query: string): PackageEntry | null { + const needle = String(query || "").trim().toLowerCase(); + if (!needle) { + return null; + } + return Object.values(snapshot.session.packages).find((pkg) => + pkg.id.toLowerCase() === needle || pkg.name.toLowerCase().includes(needle) + ) || null; +} + +function findItem(snapshot: UiSnapshot, query: string): DownloadItem | null { + const needle = String(query || "").trim().toLowerCase(); + if (!needle) { + return null; + } + return Object.values(snapshot.session.items).find((item) => + item.id.toLowerCase() === needle || item.fileName.toLowerCase().includes(needle) + ) || null; +} + +function getPackageLogPathForQuery(snapshot: UiSnapshot, query: string): { pkg: PackageEntry | null; logPath: string | null } { + const pkg = findPackage(snapshot, query); + if (pkg) { + const livePath = manager?.getPackageLogPath(pkg.id) || null; + return { pkg, logPath: livePath || getPersistedPackageLogPath(pkg.id) }; + } + const directPath = getPersistedPackageLogPath(String(query || "").trim()); + return { pkg: null, logPath: directPath }; +} + +function getItemLogPathForQuery(snapshot: UiSnapshot, query: string): { item: DownloadItem | null; logPath: string | null } { + const item = findItem(snapshot, query); + if (item) { + const livePath = manager?.getItemLogPath(item.id) || null; + return { item, logPath: livePath || getPersistedItemLogPath(item.id) }; + } + const directPath = getPersistedItemLogPath(String(query || "").trim()); + return { item: null, logPath: directPath }; +} + +function buildStatusPayload(snapshot: UiSnapshot): Record<string, unknown> { + const items = Object.values(snapshot.session.items); + const packages = Object.values(snapshot.session.packages); + + const byStatus: Record<string, number> = {}; + for (const item of items) { + byStatus[item.status] = (byStatus[item.status] || 0) + 1; + } + + const activeItems = items + .filter((item) => item.status === "downloading" || item.status === "validating") + .map((item) => summarizeItem(item)); + + const failedItems = items + .filter((item) => item.status === "failed") + .map((item) => summarizeItem(item)); + + return { + running: snapshot.session.running, + paused: snapshot.session.paused, + speed: snapshot.speedText, + eta: snapshot.etaText, + itemCounts: byStatus, + totalItems: items.length, + totalPackages: packages.length, + packages: packages.map((pkg) => summarizePackage(snapshot, pkg, false)), + activeItems, + failedItems: failedItems.length > 0 ? failedItems : undefined + }; +} + +function handleRequest(req: http.IncomingMessage, res: http.ServerResponse): void { + const url = new URL(req.url || "/", "http://localhost"); + const pathname = url.pathname; + const traceConfig = getTraceConfig(); + if (traceConfig.enabled && traceConfig.logDebugRequests) { + logTraceEvent("INFO", "debug-http", "Request", { + method: req.method || "GET", + url: sanitizeRequestUrlForTrace(req.url || "/"), + clientIp: extractDebugClientIp(req) + }); + } + + if (req.method === "OPTIONS") { + res.writeHead(204, { + "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Headers": "Authorization", + "Access-Control-Allow-Methods": "GET,OPTIONS" + }); + res.end(); + return; + } + + const peerIp = getPeerIp(req); + if (!isClientAllowed(peerIp)) { + if (traceConfig.enabled && traceConfig.logDebugRequests) { + logTraceEvent("WARN", "debug-http", "Durch Allowlist blockiert", { + peerIp, + forwardedFor: extractDebugClientIp(req), + url: sanitizeRequestUrlForTrace(req.url || "/") + }); + } + jsonResponse(res, 403, { error: "Forbidden", reason: "Client-IP nicht in Allowlist", clientIp: peerIp }); + return; + } + + if (!checkAuth(req)) { + if (traceConfig.enabled && traceConfig.logDebugRequests) { + logTraceEvent("WARN", "debug-http", "Unauthorized request", { + method: req.method || "GET", + url: sanitizeRequestUrlForTrace(req.url || "/"), + clientIp: extractDebugClientIp(req) + }); + } + jsonResponse(res, 401, { error: "Unauthorized" }); + return; + } + + if (pathname === "/health") { + jsonResponse(res, 200, { + status: "ok", + appVersion: APP_VERSION, + uptime: Math.floor(process.uptime()), + memoryMB: Math.round(process.memoryUsage().rss / 1024 / 1024) + }); + return; + } + + if (pathname === "/meta") { + jsonResponse(res, 200, { + appVersion: APP_VERSION, + runtimeBaseDir, + debugServer: { + host: bindHost, + port: bindPort + }, + supportFiles: { + supportManifest: getSupportManifestPath(), + traceConfig: getTraceConfigPath(), + traceLog: getTraceLogPath() + }, + supportChecks: { + setup: "/debug/setup", + selfCheck: "/self-check" + }, + logPaths: { + main: getLogFilePath(), + audit: getAuditLogPath(), + rename: getRenameLogPath(), + session: getSessionLogPath(), + trace: getTraceLogPath() + }, + endpoints: getEndpointSummaries() + }); + return; + } + + if (pathname === "/debug/setup" || pathname === "/self-check") { + jsonResponse(res, 200, getDebugSetupCheck(runtimeBaseDir)); + return; + } + + if (pathname === "/host/diagnostics") { + jsonResponse(res, 200, getWindowsHostDiagnostics()); + return; + } + + if (pathname === "/log" || pathname === "/logs/main") { + const count = normalizeLinesParam(url.searchParams.get("lines"), 100); + const grep = url.searchParams.get("grep") || ""; + const lines = filterLines(readLogTailFromFile(getLogFilePath(), count), grep); + jsonResponse(res, 200, { lines, count: lines.length }); + return; + } + + if (pathname === "/errors") { + const levelFilter = (url.searchParams.get("level") || "").toUpperCase(); + const limit = normalizeLinesParam(url.searchParams.get("limit"), 100); + let entries = getRecentErrors(); + if (levelFilter === "ERROR" || levelFilter === "WARN") { + entries = entries.filter((entry) => entry.level === levelFilter); + } + const limited = entries.slice(-limit); + jsonResponse(res, 200, { count: limited.length, total: entries.length, entries: limited }); + return; + } + + if (pathname === "/logs/audit") { + const count = normalizeLinesParam(url.searchParams.get("lines"), 100); + const grep = url.searchParams.get("grep") || ""; + const logPath = getAuditLogPath(); + const lines = filterLines(readLogTailFromFile(logPath, count), grep); + jsonResponse(res, 200, { + path: logPath, + lines, + count: lines.length + }); + return; + } + + if (pathname === "/logs/rename") { + const count = normalizeLinesParam(url.searchParams.get("lines"), 100); + const grep = url.searchParams.get("grep") || ""; + const logPath = getRenameLogPath(); + const lines = filterLines(readLogTailFromFile(logPath, count), grep); + jsonResponse(res, 200, { + path: logPath, + lines, + count: lines.length + }); + return; + } + + if (pathname === "/logs/trace") { + const count = normalizeLinesParam(url.searchParams.get("lines"), 100); + const grep = url.searchParams.get("grep") || ""; + const logPath = getTraceLogPath(); + const lines = filterLines(readLogTailFromFile(logPath, count), grep); + jsonResponse(res, 200, { + path: logPath, + configPath: getTraceConfigPath(), + config: getTraceConfig(), + lines, + count: lines.length + }); + return; + } + + if (pathname === "/logs/session") { + const count = normalizeLinesParam(url.searchParams.get("lines"), 100); + const grep = url.searchParams.get("grep") || ""; + const logPath = getSessionLogPath(); + const lines = filterLines(readLogTailFromFile(logPath, count), grep); + jsonResponse(res, 200, { + path: logPath, + lines, + count: lines.length + }); + return; + } + + if (pathname === "/logs/conversion") { + const count = normalizeLinesParam(url.searchParams.get("lines"), 100); + const grep = url.searchParams.get("grep") || ""; + const logPath = getConversionLogPath(); + const lines = logPath ? filterLines(readLogTailFromFile(logPath, count), grep) : []; + jsonResponse(res, 200, { + path: logPath, + available: Boolean(logPath), + lines, + count: lines.length + }); + return; + } + + if (pathname === "/providers") { + jsonResponse(res, 200, getProviderRuntimeSnapshot()); + return; + } + + if (pathname === "/trace/config") { + const patch: Record<string, unknown> = {}; + const enabled = toBooleanQuery(url.searchParams.get("enable")); + const includeMainLog = toBooleanQuery(url.searchParams.get("includeMainLog")); + const includeAudit = toBooleanQuery(url.searchParams.get("includeAudit")); + const logDebugRequests = toBooleanQuery(url.searchParams.get("logDebugRequests")); + if (enabled !== null) { + patch.enabled = enabled; + } + if (includeMainLog !== null) { + patch.includeMainLog = includeMainLog; + } + if (includeAudit !== null) { + patch.includeAudit = includeAudit; + } + if (logDebugRequests !== null) { + patch.logDebugRequests = logDebugRequests; + } + const note = String(url.searchParams.get("note") || "").trim(); + const durationMinutesRaw = Number(url.searchParams.get("durationMinutes") || "120"); + const durationMinutes = Number.isFinite(durationMinutesRaw) && durationMinutesRaw > 0 + ? Math.min(Math.floor(durationMinutesRaw), 24 * 60) + : 120; + let config = getTraceConfig(); + if (enabled !== null) { + config = setTraceEnabled(enabled, note, durationMinutes * 60 * 1000); + } + const configPatch = { ...patch }; + delete configPatch.enabled; + if (Object.keys(configPatch).length > 0) { + config = updateTraceConfig(configPatch); + } + if (Object.keys(patch).length > 0) { + logTraceEvent("INFO", "support", "Trace-Konfiguration über Debug-Server geändert", { ...patch, note, durationMinutes }); + } + jsonResponse(res, 200, { + path: getTraceConfigPath(), + logPath: getTraceLogPath(), + config + }); + return; + } + + if (pathname === "/logs/package") { + if (!manager) { + jsonResponse(res, 503, { error: "Manager not initialized" }); + return; + } + const snapshot = manager.getSnapshot(); + const packageQuery = url.searchParams.get("package") || url.searchParams.get("packageId") || ""; + const count = normalizeLinesParam(url.searchParams.get("lines"), 100); + const grep = url.searchParams.get("grep") || ""; + const resolved = getPackageLogPathForQuery(snapshot, packageQuery); + if (!resolved.logPath) { + jsonResponse(res, 404, { error: "Package log not found", package: packageQuery }); + return; + } + const lines = filterLines(readLogTailFromFile(resolved.logPath, count), grep); + jsonResponse(res, 200, { + package: resolved.pkg ? summarizePackage(snapshot, resolved.pkg, false) : undefined, + path: resolved.logPath, + lines, + count: lines.length + }); + return; + } + + if (pathname === "/logs/item") { + if (!manager) { + jsonResponse(res, 503, { error: "Manager not initialized" }); + return; + } + const snapshot = manager.getSnapshot(); + const itemQuery = url.searchParams.get("item") || url.searchParams.get("itemId") || ""; + const count = normalizeLinesParam(url.searchParams.get("lines"), 100); + const grep = url.searchParams.get("grep") || ""; + const resolved = getItemLogPathForQuery(snapshot, itemQuery); + if (!resolved.logPath) { + jsonResponse(res, 404, { error: "Item log not found", item: itemQuery }); + return; + } + const lines = filterLines(readLogTailFromFile(resolved.logPath, count), grep); + jsonResponse(res, 200, { + item: resolved.item ? summarizeItem(resolved.item) : undefined, + path: resolved.logPath, + lines, + count: lines.length + }); + return; + } + + if (pathname === "/settings") { + const settings = readSupportSettings(); + jsonResponse(res, 200, buildRedactedSettingsPayload(settings)); + return; + } + + if (pathname === "/accounts") { + const settings = readSupportSettings(); + jsonResponse(res, 200, buildAccountSummary(settings)); + return; + } + + if (pathname === "/stats") { + if (!manager) { + jsonResponse(res, 503, { error: "Manager not initialized" }); + return; + } + const snapshot = manager.getSnapshot(); + const settings = readSupportSettings(); + jsonResponse(res, 200, { + ...buildStatsPayload(snapshot), + allTime: { + totalDownloadedAllTime: settings.totalDownloadedAllTime, + totalCompletedFilesAllTime: settings.totalCompletedFilesAllTime, + totalRuntimeAllTimeMs: settings.totalRuntimeAllTimeMs + } + }); + return; + } + + if (pathname === "/history") { + const entries = readSupportHistory(); + const limit = normalizeLinesParam(url.searchParams.get("limit"), 50); + const statusFilter = String(url.searchParams.get("status") || "").trim().toLowerCase(); + const grep = String(url.searchParams.get("grep") || "").trim().toLowerCase(); + let filtered = entries; + if (statusFilter) { + filtered = filtered.filter((entry) => String(entry.status || "").toLowerCase() === statusFilter); + } + if (grep) { + filtered = filtered.filter((entry) => JSON.stringify(summarizeHistoryEntry(entry)).toLowerCase().includes(grep)); + } + const sliced = filtered + .sort((a, b) => Number(b.completedAt || 0) - Number(a.completedAt || 0)) + .slice(0, limit); + jsonResponse(res, 200, { + count: sliced.length, + total: filtered.length, + entries: sliced.map((entry) => summarizeHistoryEntry(entry)) + }); + return; + } + + if (pathname === "/status") { + if (!manager) { + jsonResponse(res, 503, { error: "Manager not initialized" }); + return; + } + const snapshot = manager.getSnapshot(); + jsonResponse(res, 200, buildStatusPayload(snapshot)); + return; + } + + if (pathname === "/packages") { + if (!manager) { + jsonResponse(res, 503, { error: "Manager not initialized" }); + return; + } + const snapshot = manager.getSnapshot(); + const packageQuery = url.searchParams.get("package") || ""; + const includeItems = /^(1|true|yes)$/i.test(String(url.searchParams.get("includeItems") || "")); + let packages = Object.values(snapshot.session.packages); + if (packageQuery) { + const needle = packageQuery.toLowerCase(); + packages = packages.filter((pkg) => pkg.id.toLowerCase() === needle || pkg.name.toLowerCase().includes(needle)); + } + jsonResponse(res, 200, { + count: packages.length, + packages: packages.map((pkg) => summarizePackage(snapshot, pkg, includeItems)) + }); + return; + } + + if (pathname === "/items") { + if (!manager) { + jsonResponse(res, 503, { error: "Manager not initialized" }); + return; + } + const snapshot = manager.getSnapshot(); + const filter = url.searchParams.get("status"); + const pkg = url.searchParams.get("package"); + let items = Object.values(snapshot.session.items); + if (filter) { + items = items.filter((i) => i.status === filter); + } + if (pkg) { + const matchedPkg = findPackage(snapshot, pkg); + if (matchedPkg) { + const ids = new Set(matchedPkg.itemIds); + items = items.filter((i) => ids.has(i.id)); + } + } + jsonResponse(res, 200, { + count: items.length, + items: items.map((i) => summarizeItem(i)) + }); + return; + } + + if (pathname === "/session") { + if (!manager) { + jsonResponse(res, 503, { error: "Manager not initialized" }); + return; + } + const snapshot = manager.getSnapshot(); + const pkg = url.searchParams.get("package"); + if (pkg) { + const matchedPkg = findPackage(snapshot, pkg); + if (matchedPkg) { + const ids = new Set(matchedPkg.itemIds); + const pkgItems = Object.values(snapshot.session.items) + .filter((i) => ids.has(i.id)); + jsonResponse(res, 200, { + package: summarizePackage(snapshot, matchedPkg, false), + items: pkgItems.map((item) => summarizeItem(item)) + }); + return; + } + } + jsonResponse(res, 200, { + running: snapshot.session.running, + paused: snapshot.session.paused, + packageCount: Object.keys(snapshot.session.packages).length, + itemCount: Object.keys(snapshot.session.items).length, + packages: Object.values(snapshot.session.packages).map((p) => ({ + id: p.id, + name: p.name, + status: p.status, + items: p.itemIds.length + })) + }); + return; + } + + if (pathname === "/support/bundle") { + if (!manager) { + jsonResponse(res, 503, { error: "Manager not initialized" }); + return; + } + const fileName = getSupportBundleDefaultFileName(); + buildSupportBundle(manager, runtimeBaseDir) + .then((body) => { + logTraceEvent("INFO", "support", "Support-Bundle über Debug-Server heruntergeladen", { + fileName, + sizeBytes: body.length + }); + binaryResponse(res, 200, body, "application/zip", fileName); + }) + .catch((error) => { + jsonResponse(res, 500, { error: String((error as { message?: string })?.message || error) }); + }); + return; + } + + if (pathname === "/diagnostics") { + if (!manager) { + jsonResponse(res, 503, { error: "Manager not initialized" }); + return; + } + const snapshot = manager.getSnapshot(); + const lineCount = normalizeLinesParam(url.searchParams.get("lines"), 150); + const grep = url.searchParams.get("grep") || ""; + const packageQuery = url.searchParams.get("package") || ""; + const mainLogPath = getLogFilePath(); + const sessionLogPath = getSessionLogPath(); + const selectedPackage = packageQuery ? findPackage(snapshot, packageQuery) : null; + const packageLogPath = selectedPackage + ? manager.getPackageLogPath(selectedPackage.id) || getPersistedPackageLogPath(selectedPackage.id) + : null; + jsonResponse(res, 200, { + meta: { + appVersion: APP_VERSION, + serverTime: new Date().toISOString(), + runtimeBaseDir, + debugServer: { + host: bindHost, + port: bindPort + }, + setup: getDebugSetupCheck(runtimeBaseDir) + }, + status: buildStatusPayload(snapshot), + settings: buildRedactedSettingsPayload(readSupportSettings()), + stats: buildStatsPayload(snapshot), + accounts: buildAccountSummary(readSupportSettings()), + providers: getProviderRuntimeSnapshot(), + history: { + total: readSupportHistory().length, + recent: readSupportHistory() + .sort((a, b) => Number(b.completedAt || 0) - Number(a.completedAt || 0)) + .slice(0, 10) + .map((entry) => summarizeHistoryEntry(entry)) + }, + host: getWindowsHostDiagnostics(), + selectedPackage: selectedPackage ? summarizePackage(snapshot, selectedPackage, true) : undefined, + logs: { + main: { + path: mainLogPath, + lines: filterLines(readLogTailFromFile(mainLogPath, lineCount), grep) + }, + audit: { + path: getAuditLogPath(), + lines: filterLines(readLogTailFromFile(getAuditLogPath(), lineCount), grep) + }, + rename: { + path: getRenameLogPath(), + lines: filterLines(readLogTailFromFile(getRenameLogPath(), lineCount), grep) + }, + trace: { + path: getTraceLogPath(), + config: getTraceConfig(), + lines: filterLines(readLogTailFromFile(getTraceLogPath(), lineCount), grep) + }, + session: { + path: sessionLogPath, + lines: filterLines(readLogTailFromFile(sessionLogPath, lineCount), grep) + }, + conversion: { + path: getConversionLogPath(), + lines: getConversionLogPath() ? filterLines(readLogTailFromFile(getConversionLogPath() as string, lineCount), grep) : [] + }, + package: selectedPackage ? { + path: packageLogPath, + lines: filterLines(readLogTailFromFile(packageLogPath, lineCount), grep) + } : undefined + } + }); + return; + } + + jsonResponse(res, 404, { + error: "Not found", + endpoints: getEndpointSummaries() + }); +} + +function openServerSocket(): Promise<void> { + return new Promise((resolve) => { + authToken = loadToken(runtimeBaseDir); + bindPort = getPort(runtimeBaseDir); + bindHost = getHost(runtimeBaseDir); + allowlist = loadAllowlist(runtimeBaseDir); + writeSupportManifest(runtimeBaseDir); + if (!authToken) { + logger.info("Debug-Server: Kein Token in debug_token.txt, Server wird nicht gestartet"); + resolve(); + return; + } + if (bindHost === "0.0.0.0" && allowlist.length === 0) { + logger.warn("Debug-Server: Netzwerk-Bind ohne Allowlist - nur Loopback-Clients werden akzeptiert (fail-closed)"); + } + const srv = http.createServer(handleRequest); + let settled = false; + const settle = (): void => { + if (!settled) { + settled = true; + resolve(); + } + }; + srv.on("error", (err: NodeJS.ErrnoException) => { + if (err.code === "EADDRINUSE") { + logger.warn(`Debug-Server: Port ${bindPort} belegt (EADDRINUSE) - Server nicht gestartet`); + } else { + logger.warn(`Debug-Server Fehler: ${String(err)}`); + } + if (server === srv) { + server = null; + } + settle(); + }); + srv.listen(bindPort, bindHost, () => { + logger.info(`Debug-Server gestartet auf ${bindHost}:${bindPort} (Allowlist: ${allowlist.length})`); + settle(); + }); + server = srv; + }); +} + +export function startDebugServer(mgr: DownloadManager, baseDir: string): void { + runtimeBaseDir = baseDir; + manager = mgr; + void openServerSocket(); +} + +export async function restartDebugServer(): Promise<DebugServerRuntimeStatus> { + const old = server; + if (old) { + server = null; + await new Promise<void>((resolve) => { + let settled = false; + const done = (): void => { + if (!settled) { + settled = true; + resolve(); + } + }; + old.close(() => done()); + try { + old.closeAllConnections?.(); + } catch { + } + setTimeout(done, 1500); + }); + } + await openServerSocket(); + return getDebugServerRuntimeStatus(); +} + +export function getDebugServerRuntimeStatus(): DebugServerRuntimeStatus { + return { + running: Boolean(server && server.listening), + host: bindHost, + port: bindPort, + hasToken: Boolean(authToken), + localOnly: isLoopbackIp(bindHost) || bindHost === "127.0.0.1", + allowlistCount: allowlist.length + }; +} + +export function getActiveDebugToken(): string { + return authToken || loadToken(runtimeBaseDir); +} + +export function getDebugAllowlist(): string[] { + return [...allowlist]; +} + +export function writeDebugServerConfig(opts: { host?: string; port?: number; allowlist?: string[] }): void { + if (opts.host !== undefined) { + fs.writeFileSync(path.join(runtimeBaseDir, "debug_host.txt"), `${opts.host}\n`, "utf8"); + } + if (opts.port !== undefined) { + fs.writeFileSync(path.join(runtimeBaseDir, "debug_port.txt"), `${opts.port}\n`, "utf8"); + } + if (opts.allowlist !== undefined) { + const body = opts.allowlist.length > 0 ? opts.allowlist.join("\n") + "\n" : ""; + fs.writeFileSync(getAllowlistPath(), body, "utf8"); + } +} + +export function clearDebugToken(): void { + try { + fs.unlinkSync(getDebugTokenPath()); + } catch { + } + authToken = ""; + writeSupportManifest(runtimeBaseDir); +} + +export function stopDebugServer(): void { + if (server) { + server.close(); + try { + server.closeAllConnections?.(); + } catch { + } + server = null; + logger.info("Debug-Server gestoppt"); + } +} diff --git a/src/main/debug-setup.ts b/src/main/debug-setup.ts new file mode 100644 index 0000000..48005d2 --- /dev/null +++ b/src/main/debug-setup.ts @@ -0,0 +1,435 @@ +import fs from "node:fs"; +import path from "node:path"; +import { execFileSync } from "node:child_process"; +import { getSessionLogPath } from "./session-log"; +import { createStoragePaths, loadSettings } from "./storage"; +import type { + DebugSetupCheckResult, + SupportBundleEstimate, + SupportDirectorySizeInfo, + SupportDiskSpaceInfo, + SupportFileSizeInfo, + SupportTraceConfig +} from "../shared/types"; + +const DEFAULT_PORT = 9868; +const DEFAULT_HOST = "127.0.0.1"; +const SUPPORT_MANIFEST_FILE = "debug_support_manifest.json"; +const LOW_FREE_BYTES_THRESHOLD = Number(process.env.RD_SELF_CHECK_LOW_FREE_BYTES || 20 * 1024 * 1024 * 1024); +const LOW_FREE_PERCENT_THRESHOLD = Number(process.env.RD_SELF_CHECK_LOW_FREE_PERCENT || 5); +const LOW_FREE_PERCENT_BYTES_GUARD = Number(process.env.RD_SELF_CHECK_LOW_FREE_PERCENT_BYTES_GUARD || 50 * 1024 * 1024 * 1024); +const LARGE_LOG_BYTES_THRESHOLD = Number(process.env.RD_SELF_CHECK_LARGE_LOG_BYTES || 250 * 1024 * 1024); +const LARGE_BUNDLE_BYTES_THRESHOLD = Number(process.env.RD_SELF_CHECK_LARGE_BUNDLE_BYTES || 150 * 1024 * 1024); +const BUNDLE_OVERVIEW_SLACK_BYTES = 256 * 1024; + +function formatByteCount(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(2)} MB`; + } + return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`; +} + +function readToken(baseDir: string): string { + try { + return fs.readFileSync(path.join(baseDir, "debug_token.txt"), "utf8").trim(); + } catch { + return ""; + } +} + +function readPort(baseDir: string): number { + try { + const raw = Number(fs.readFileSync(path.join(baseDir, "debug_port.txt"), "utf8").trim()); + if (Number.isFinite(raw) && raw >= 1024 && raw <= 65535) { + return raw; + } + } catch { + } + return DEFAULT_PORT; +} + +function readHost(baseDir: string): string { + try { + const raw = fs.readFileSync(path.join(baseDir, "debug_host.txt"), "utf8").trim(); + if (!raw) { + return DEFAULT_HOST; + } + if (/^(localhost|0\.0\.0\.0|127\.0\.0\.1|::1)$/i.test(raw)) { + return raw; + } + if (/^[a-z0-9.-]+$/i.test(raw)) { + return raw; + } + } catch { + } + return DEFAULT_HOST; +} + +function readTraceConfig(baseDir: string): SupportTraceConfig { + const fallback: SupportTraceConfig = { + enabled: false, + includeMainLog: true, + includeAudit: true, + logDebugRequests: true, + autoDisableAt: null, + updatedAt: new Date(0).toISOString() + }; + try { + const filePath = path.join(baseDir, "trace_config.json"); + const parsed = JSON.parse(fs.readFileSync(filePath, "utf8")) as Partial<SupportTraceConfig>; + return { + enabled: Boolean(parsed.enabled), + includeMainLog: parsed.includeMainLog === undefined ? true : Boolean(parsed.includeMainLog), + includeAudit: parsed.includeAudit === undefined ? true : Boolean(parsed.includeAudit), + logDebugRequests: parsed.logDebugRequests === undefined ? true : Boolean(parsed.logDebugRequests), + autoDisableAt: typeof parsed.autoDisableAt === "string" && parsed.autoDisableAt.trim() ? parsed.autoDisableAt : null, + updatedAt: typeof parsed.updatedAt === "string" && parsed.updatedAt.trim() ? parsed.updatedAt : fallback.updatedAt + }; + } catch { + return fallback; + } +} + +function getFileSizeInfo(filePath: string | null): SupportFileSizeInfo { + if (!filePath) { + return { path: null, exists: false, bytes: 0 }; + } + try { + const stat = fs.statSync(filePath); + return { + path: filePath, + exists: true, + bytes: stat.size + }; + } catch { + return { + path: filePath, + exists: false, + bytes: 0 + }; + } +} + +function getDirectorySizeInfo(dirPath: string, skipPath?: string | null): SupportDirectorySizeInfo { + if (!fs.existsSync(dirPath)) { + return { + path: dirPath, + exists: false, + fileCount: 0, + bytes: 0 + }; + } + + let bytes = 0; + let fileCount = 0; + const queue = [dirPath]; + while (queue.length > 0) { + const current = queue.pop(); + if (!current) { + continue; + } + let entries: fs.Dirent[]; + try { + entries = fs.readdirSync(current, { withFileTypes: true }); + } catch { + continue; + } + for (const entry of entries) { + const fullPath = path.join(current, entry.name); + if (entry.isDirectory()) { + queue.push(fullPath); + continue; + } + if (skipPath && path.resolve(fullPath) === path.resolve(skipPath)) { + continue; + } + try { + bytes += fs.statSync(fullPath).size; + fileCount += 1; + } catch { + } + } + } + + return { + path: dirPath, + exists: true, + fileCount, + bytes + }; +} + +function resolveExistingPath(targetPath: string): string { + let current = path.resolve(targetPath); + while (!fs.existsSync(current)) { + const parent = path.dirname(current); + if (parent === current) { + break; + } + current = parent; + } + return current; +} + +function getWindowsDiskSpaceInfo(existingPath: string): SupportDiskSpaceInfo | null { + if (process.platform !== "win32") { + return null; + } + const root = path.parse(existingPath).root.replace(/[\\/]+$/g, ""); + const driveName = root.replace(":", ""); + if (!/^[A-Za-z]$/.test(driveName)) { + return null; + } + try { + const raw = execFileSync( + "powershell", + [ + "-NoProfile", + "-Command", + `$drive = Get-PSDrive -Name '${driveName}'; if ($drive) { [pscustomobject]@{ FreeSpace = [int64]$drive.Free; Size = [int64]($drive.Used + $drive.Free) } | ConvertTo-Json -Compress }` + ], + { + encoding: "utf8", + windowsHide: true, + stdio: ["ignore", "pipe", "ignore"], + timeout: 3000 + } + ).trim(); + if (!raw) { + return null; + } + const parsed = JSON.parse(raw) as { FreeSpace?: number | string; Size?: number | string }; + const totalBytes = Number(parsed.Size); + const freeBytes = Number(parsed.FreeSpace); + const freePercent = Number.isFinite(totalBytes) && totalBytes > 0 + ? Math.round((freeBytes / totalBytes) * 1000) / 10 + : null; + return { + path: existingPath, + totalBytes: Number.isFinite(totalBytes) ? totalBytes : null, + freeBytes: Number.isFinite(freeBytes) ? freeBytes : null, + freePercent + }; + } catch { + return null; + } +} + +function getDiskSpaceInfo(targetPath: string): SupportDiskSpaceInfo { + const existingPath = resolveExistingPath(targetPath); + try { + const stat = fs.statfsSync(existingPath); + const totalBytes = Number(stat.blocks) * Number(stat.bsize); + const freeBytes = Number(stat.bavail) * Number(stat.bsize); + const freePercent = totalBytes > 0 + ? Math.round((freeBytes / totalBytes) * 1000) / 10 + : null; + return { + path: existingPath, + totalBytes, + freeBytes, + freePercent + }; + } catch { + const windowsFallback = getWindowsDiskSpaceInfo(existingPath); + if (windowsFallback) { + return windowsFallback; + } + return { + path: existingPath, + totalBytes: null, + freeBytes: null, + freePercent: null + }; + } +} + +function getSupportBundleEstimate( + baseDir: string, + logSummary: DebugSetupCheckResult["logSummary"] +): SupportBundleEstimate { + const storagePaths = createStoragePaths(baseDir); + const staticFiles = [ + path.join(baseDir, SUPPORT_MANIFEST_FILE), + path.join(baseDir, "debug_host.txt"), + path.join(baseDir, "debug_port.txt"), + storagePaths.configFile, + storagePaths.sessionFile, + storagePaths.historyFile, + path.join(baseDir, "trace_config.json") + ].map((filePath) => getFileSizeInfo(filePath)); + + const staticBytes = staticFiles.reduce((sum, entry) => sum + entry.bytes, 0); + const duplicatedLiveLogBytes = logSummary.session.bytes + logSummary.packageLogs.bytes + logSummary.itemLogs.bytes; + const estimatedEntries = 10 + + staticFiles.filter((entry) => entry.exists).length + + Number(logSummary.main.exists) + + Number(logSummary.mainBackup.exists) + + Number(logSummary.audit.exists) + + Number(logSummary.auditBackup.exists) + + Number(logSummary.rename.exists) + + Number(logSummary.renameBackup.exists) + + Number(logSummary.session.exists) + + Number(logSummary.trace.exists) + + Number(logSummary.traceBackup.exists) + + logSummary.sessionLogs.fileCount + + logSummary.packageLogs.fileCount + + logSummary.itemLogs.fileCount + + logSummary.packageLogs.fileCount + + logSummary.itemLogs.fileCount; + + return { + estimatedBytes: staticBytes + logSummary.totalBytes + duplicatedLiveLogBytes + BUNDLE_OVERVIEW_SLACK_BYTES, + estimatedEntries, + duplicatedLiveLogBytes, + note: "Schätzwert vor ZIP-Komprimierung; aktueller Session-Log sowie Live-Paket-/Item-Logs werden im Bundle zusätzlich gespiegelt." + }; +} + +export function getDebugSetupCheck(baseDir: string): DebugSetupCheckResult { + const host = readHost(baseDir); + const port = readPort(baseDir); + const token = readToken(baseDir); + const storagePaths = createStoragePaths(baseDir); + const settings = loadSettings(storagePaths); + const tokenPath = path.join(baseDir, "debug_token.txt"); + const supportManifestPath = path.join(baseDir, SUPPORT_MANIFEST_FILE); + const traceConfigPath = path.join(baseDir, "trace_config.json"); + const traceLogPath = path.join(baseDir, "trace.log"); + const traceConfig = readTraceConfig(baseDir); + const sessionLogPath = getSessionLogPath(); + const localOnly = /^(127\.0\.0\.1|localhost|::1)$/i.test(host); + const warnings: string[] = []; + const notes: string[] = []; + + const logSummary: DebugSetupCheckResult["logSummary"] = { + main: getFileSizeInfo(path.join(baseDir, "rd_downloader.log")), + mainBackup: getFileSizeInfo(path.join(baseDir, "rd_downloader.log.old")), + audit: getFileSizeInfo(path.join(baseDir, "audit.log")), + auditBackup: getFileSizeInfo(path.join(baseDir, "audit.log.old")), + rename: getFileSizeInfo(path.join(baseDir, "rename.log")), + renameBackup: getFileSizeInfo(path.join(baseDir, "rename.log.old")), + session: getFileSizeInfo(sessionLogPath), + trace: getFileSizeInfo(traceLogPath), + traceBackup: getFileSizeInfo(path.join(baseDir, "trace.log.old")), + sessionLogs: getDirectorySizeInfo(path.join(baseDir, "session-logs"), sessionLogPath), + packageLogs: getDirectorySizeInfo(path.join(baseDir, "package-logs")), + itemLogs: getDirectorySizeInfo(path.join(baseDir, "item-logs")), + totalBytes: 0 + }; + logSummary.totalBytes = [ + logSummary.main.bytes, + logSummary.mainBackup.bytes, + logSummary.audit.bytes, + logSummary.auditBackup.bytes, + logSummary.rename.bytes, + logSummary.renameBackup.bytes, + logSummary.session.bytes, + logSummary.trace.bytes, + logSummary.traceBackup.bytes, + logSummary.sessionLogs.bytes, + logSummary.packageLogs.bytes, + logSummary.itemLogs.bytes + ].reduce((sum, value) => sum + value, 0); + + const diskSpace: DebugSetupCheckResult["diskSpace"] = { + runtime: getDiskSpaceInfo(baseDir), + output: getDiskSpaceInfo(settings.outputDir), + extract: getDiskSpaceInfo(settings.extractDir) + }; + const supportBundle = getSupportBundleEstimate(baseDir, logSummary); + + if (!token) { + warnings.push("debug_token.txt fehlt oder ist leer. Der Debug-Server startet dann nicht."); + } + if (localOnly) { + warnings.push("Der Debug-Server ist aktuell nur lokal erreichbar. Für Remote-Support debug_host.txt auf 0.0.0.0 setzen."); + } else { + notes.push("Der Debug-Server ist für Remote-Zugriff konfiguriert. Firewall oder Provider-Regeln müssen separat offen sein."); + } + if (!fs.existsSync(supportManifestPath)) { + warnings.push("debug_support_manifest.json fehlt. App einmal neu starten, damit das Support-Manifest neu geschrieben wird."); + } + if (!fs.existsSync(traceConfigPath)) { + warnings.push("trace_config.json fehlt. Trace-Funktionen sind lokal noch nicht initialisiert."); + } + if (traceConfig.enabled && !traceConfig.autoDisableAt) { + warnings.push("Support-Trace ist aktiv ohne automatische Abschaltzeit. Einmal neu aktivieren, damit die 2-Stunden-Begrenzung gesetzt wird."); + } + if (traceConfig.enabled && traceConfig.autoDisableAt) { + notes.push(`Support-Trace aktiv bis ${traceConfig.autoDisableAt}.`); + } + + for (const entry of [ + { label: "Runtime", info: diskSpace.runtime }, + { label: "Download-Ziel", info: diskSpace.output }, + { label: "Entpack-Ziel", info: diskSpace.extract } + ]) { + if (entry.info.freeBytes === null || entry.info.totalBytes === null) { + warnings.push(`${entry.label}: Freier Speicherplatz konnte nicht gelesen werden (${entry.info.path}).`); + continue; + } + const lowByAbsolute = entry.info.freeBytes < LOW_FREE_BYTES_THRESHOLD; + const lowByPercent = entry.info.freePercent !== null + && entry.info.freePercent < LOW_FREE_PERCENT_THRESHOLD + && entry.info.freeBytes < LOW_FREE_PERCENT_BYTES_GUARD; + if (lowByAbsolute || lowByPercent) { + warnings.push(`${entry.label}: wenig freier Speicherplatz (${formatByteCount(entry.info.freeBytes)} frei auf ${entry.info.path}).`); + } + } + + if (logSummary.totalBytes >= LARGE_LOG_BYTES_THRESHOLD) { + warnings.push(`Support-Logs sind bereits recht groß (${formatByteCount(logSummary.totalBytes)}). Rotation greift, aber ein Bundle wird entsprechend umfangreicher.`); + } else { + notes.push(`Aktuelle Support-Logmenge: ${formatByteCount(logSummary.totalBytes)}.`); + } + + if (supportBundle.estimatedBytes >= LARGE_BUNDLE_BYTES_THRESHOLD) { + warnings.push(`Support-Bundle wird voraussichtlich groß (${formatByteCount(supportBundle.estimatedBytes)} vor ZIP-Komprimierung).`); + } else { + notes.push(`Support-Bundle-Schätzung: etwa ${formatByteCount(supportBundle.estimatedBytes)}.`); + } + + notes.push("Die App kann Netzwerk-Firewalls oder Provider-Sicherheitsgruppen nicht direkt prüfen."); + + return { + status: warnings.length > 0 ? "warn" : "ok", + enabled: Boolean(token), + runtimeBaseDir: baseDir, + host, + port, + localOnly, + tokenConfigured: Boolean(token), + tokenPath, + supportManifestPath, + supportManifestPresent: fs.existsSync(supportManifestPath), + traceConfigPath: fs.existsSync(traceConfigPath) ? traceConfigPath : null, + traceLogPath: fs.existsSync(traceLogPath) ? traceLogPath : null, + traceEnabled: traceConfig.enabled, + traceAutoDisableAt: traceConfig.autoDisableAt, + diskSpace, + logSummary, + supportBundle, + warnings, + notes, + localUrls: { + health: `http://127.0.0.1:${port}/health?token=${token || "<TOKEN>"}`, + meta: `http://127.0.0.1:${port}/meta?token=${token || "<TOKEN>"}`, + diagnostics: `http://127.0.0.1:${port}/diagnostics?token=${token || "<TOKEN>"}` + }, + remoteUrlTemplates: { + health: `http://<SERVER_IP_OR_DNS>:${port}/health?token=${token || "<TOKEN>"}`, + meta: `http://<SERVER_IP_OR_DNS>:${port}/meta?token=${token || "<TOKEN>"}`, + diagnostics: `http://<SERVER_IP_OR_DNS>:${port}/diagnostics?token=${token || "<TOKEN>"}` + } + }; +} diff --git a/src/main/desktop-rename-log.ts b/src/main/desktop-rename-log.ts new file mode 100644 index 0000000..da9cea1 --- /dev/null +++ b/src/main/desktop-rename-log.ts @@ -0,0 +1,252 @@ +import fs from "node:fs"; +import path from "node:path"; +import { logTimestamp } from "./log-timestamp"; + +type DesktopRenameLevel = "INFO" | "WARN" | "ERROR"; + +const FOLDER_NAME = "Downloader-Log"; + +let logDir: string | null = null; +let logFilePath: string | null = null; +let sessionHeader = ""; + +function fileTimestamp(date: Date = new Date()): string { + const pad = (value: number): string => String(value).padStart(2, "0"); + return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}_` + + `${pad(date.getHours())}-${pad(date.getMinutes())}-${pad(date.getSeconds())}`; +} + +function sanitizeFieldValue(value: unknown): string { + if (value === undefined || value === null) { + return ""; + } + if (typeof value === "string") { + return value.replace(/\r?\n/g, "\\n"); + } + if (typeof value === "number" || typeof value === "boolean") { + return String(value); + } + try { + return JSON.stringify(value).replace(/\r?\n/g, "\\n"); + } catch { + return String(value); + } +} + +function formatFields(fields?: Record<string, unknown>): string { + if (!fields) { + return ""; + } + const parts = Object.entries(fields) + .filter(([, value]) => value !== undefined && value !== null && sanitizeFieldValue(value) !== "") + .map(([key, value]) => `${key}=${sanitizeFieldValue(value)}`); + return parts.length > 0 ? ` | ${parts.join(" | ")}` : ""; +} + +function ensureWritable(): boolean { + if (!logDir || !logFilePath) { + return false; + } + try { + fs.mkdirSync(logDir, { recursive: true }); + if (!fs.existsSync(logFilePath)) { + fs.writeFileSync(logFilePath, sessionHeader, "utf8"); + } + return true; + } catch { + return false; + } +} + +export function initDesktopRenameLog(desktopDir: string | null | undefined): void { + try { + const base = String(desktopDir || "").trim(); + if (!base) { + logDir = null; + logFilePath = null; + return; + } + logDir = path.join(base, FOLDER_NAME); + logFilePath = path.join(logDir, `rename-session_${fileTimestamp()}.txt`); + sessionHeader = `=== Rename-Session gestartet: ${logTimestamp()} ===\n` + + "Diese Datei protokolliert JEDEN Umbenenn-/Verschiebevorgang dieser Programm-Sitzung\n" + + "und verifiziert nach jedem Vorgang, ob die Datei wirklich unter dem Zielnamen auf der\n" + + "Platte liegt (und die Quelle verschwunden ist). [INFO]=ok, [ERROR]=Verifikation gescheitert.\n\n"; + fs.mkdirSync(logDir, { recursive: true }); + fs.writeFileSync(logFilePath, sessionHeader, "utf8"); + } catch { + logDir = null; + logFilePath = null; + } +} + +export function logDesktopRename(level: DesktopRenameLevel, message: string, fields?: Record<string, unknown>): void { + if (!ensureWritable() || !logFilePath) { + return; + } + try { + fs.appendFileSync(logFilePath, `${logTimestamp()} [${level}] ${message}${formatFields(fields)}\n`, "utf8"); + } catch { + } +} + +export function getDesktopRenameLogPath(): string | null { + if (!logFilePath) { + return null; + } + try { + return fs.existsSync(logFilePath) ? logFilePath : null; + } catch { + return null; + } +} + +export function shutdownDesktopRenameLog(): void { + if (ensureWritable() && logFilePath) { + try { + fs.appendFileSync(logFilePath, `=== Rename-Session beendet: ${logTimestamp()} ===\n`, "utf8"); + } catch { + } + } + logDir = null; + logFilePath = null; +} + +export interface RenameVerification { + ok: boolean; + level: "INFO" | "WARN" | "ERROR"; + targetExists: boolean; + onDiskName: string | null; + nameMatches: boolean; + sourceGone: boolean; + targetSize: number | null; + reason: string; +} + +function toLongPath(filePath: string): string { + const absolute = path.resolve(String(filePath || "")); + if (process.platform !== "win32") { + return absolute; + } + if (!absolute || absolute.startsWith("\\\\?\\")) { + return absolute; + } + if (absolute.length < 248) { + return absolute; + } + if (absolute.startsWith("\\\\")) { + return `\\\\?\\UNC\\${absolute.slice(2)}`; + } + return `\\\\?\\${absolute}`; +} + +function resolveOnDiskName(requested: string, entries: string[] | null): string | null { + if (entries === null) { + return null; + } + const requestedLower = requested.toLowerCase(); + return entries.find((entry) => entry === requested) + || entries.find((entry) => entry.toLowerCase() === requestedLower) + || requested; +} + +function buildVerification( + sourcePath: string, + targetPath: string, + facts: { targetExists: boolean; targetSize: number | null; dirEntries: string[] | null; sourceExists: boolean } +): RenameVerification { + const requested = path.basename(targetPath); + const dirReadFailed = facts.targetExists && facts.dirEntries === null; + const onDiskName = facts.targetExists ? resolveOnDiskName(requested, facts.dirEntries) : null; + + const samePath = path.resolve(sourcePath).toLowerCase() === path.resolve(targetPath).toLowerCase(); + const sourceGone = samePath ? true : !facts.sourceExists; + const nameMatches = facts.targetExists && !dirReadFailed && onDiskName === requested; + + const problems: string[] = []; + let level: "INFO" | "WARN" | "ERROR" = "INFO"; + if (!facts.targetExists) { + problems.push("Zieldatei nach Rename NICHT gefunden"); + level = "ERROR"; + } else if (!dirReadFailed && !nameMatches) { + problems.push(`On-Disk-Name weicht ab (ist "${onDiskName}", erwartet "${requested}")`); + level = "ERROR"; + } + if (!samePath && facts.targetExists && !sourceGone) { + problems.push("Quelldatei existiert noch (moeglicher halb-fertiger Verschiebevorgang)"); + level = "ERROR"; + } + if (level === "INFO" && dirReadFailed) { + problems.push("Zielverzeichnis nicht lesbar — Schreibweise nicht verifiziert"); + level = "WARN"; + } + + return { + ok: level === "INFO", + level, + targetExists: facts.targetExists, + onDiskName, + nameMatches, + sourceGone, + targetSize: facts.targetSize, + reason: problems.join("; ") + }; +} + +export function verifyRename(sourcePath: string, targetPath: string): RenameVerification { + const longTarget = toLongPath(targetPath); + let targetExists = false; + let targetSize: number | null = null; + try { + const stat = fs.statSync(longTarget); + targetExists = true; + targetSize = stat.size; + } catch { + targetExists = false; + } + let dirEntries: string[] | null = null; + if (targetExists) { + try { + dirEntries = fs.readdirSync(path.dirname(longTarget)); + } catch { + dirEntries = null; + } + } + let sourceExists = false; + try { + fs.statSync(toLongPath(sourcePath)); + sourceExists = true; + } catch { + sourceExists = false; + } + return buildVerification(sourcePath, targetPath, { targetExists, targetSize, dirEntries, sourceExists }); +} + +export async function verifyRenameAsync(sourcePath: string, targetPath: string): Promise<RenameVerification> { + const longTarget = toLongPath(targetPath); + let targetExists = false; + let targetSize: number | null = null; + try { + const stat = await fs.promises.stat(longTarget); + targetExists = true; + targetSize = stat.size; + } catch { + targetExists = false; + } + let dirEntries: string[] | null = null; + if (targetExists) { + try { + dirEntries = await fs.promises.readdir(path.dirname(longTarget)); + } catch { + dirEntries = null; + } + } + let sourceExists = false; + try { + await fs.promises.stat(toLongPath(sourcePath)); + sourceExists = true; + } catch { + sourceExists = false; + } + return buildVerification(sourcePath, targetPath, { targetExists, targetSize, dirEntries, sourceExists }); +} diff --git a/src/main/download-completion.ts b/src/main/download-completion.ts new file mode 100644 index 0000000..7d602c6 --- /dev/null +++ b/src/main/download-completion.ts @@ -0,0 +1,166 @@ +import { ALLOCATION_UNIT_SIZE } from "./constants"; + +export type DownloadCompletionSource = + | "content-range" + | "content-length" + | "provider-metadata" + | "stream-end"; + +export type DownloadCompletionPlan = { + expectedTotal: number | null; + source: DownloadCompletionSource; + canFinishEarly: boolean; +}; + +export function planDownloadCompletion(args: { + existingBytes: number; + responseStatus: number; + contentLength: number; + totalFromRange: number | null; + knownTotal: number | null; + correctedTotal: number | null; +}): DownloadCompletionPlan { + const existingBytes = Math.max(0, Math.floor(Number(args.existingBytes) || 0)); + const responseStatus = Math.floor(Number(args.responseStatus) || 0); + const contentLength = Math.max(0, Math.floor(Number(args.contentLength) || 0)); + const totalFromRange = Number.isFinite(args.totalFromRange || NaN) + ? Math.max(0, Math.floor(args.totalFromRange || 0)) + : 0; + const correctedTotal = Number.isFinite(args.correctedTotal || NaN) + ? Math.max(0, Math.floor(args.correctedTotal || 0)) + : 0; + const knownTotal = Number.isFinite(args.knownTotal || NaN) + ? Math.max(0, Math.floor(args.knownTotal || 0)) + : 0; + + if (correctedTotal > 0) { + return { + expectedTotal: correctedTotal, + source: totalFromRange > 0 ? "content-range" : "content-length", + canFinishEarly: true + }; + } + + if (totalFromRange > 0) { + return { + expectedTotal: totalFromRange, + source: "content-range", + canFinishEarly: true + }; + } + + if (contentLength > 0) { + return { + expectedTotal: responseStatus === 206 ? existingBytes + contentLength : contentLength, + source: "content-length", + canFinishEarly: true + }; + } + + if (knownTotal > 0) { + return { + expectedTotal: knownTotal, + source: "provider-metadata", + canFinishEarly: false + }; + } + + return { + expectedTotal: null, + source: "stream-end", + canFinishEarly: false + }; +} + +export function reconcileFinalizedSize( + streamedBytes: number, + statSize: number, + preAllocated: boolean +): number { + const streamed = Math.max(0, Math.floor(Number(streamedBytes) || 0)); + if (!Number.isFinite(statSize) || statSize < 0) { + return streamed; + } + const onDisk = Math.floor(statSize); + if (preAllocated && onDisk > streamed) { + return streamed; + } + return onDisk; +} + +export function validateDownloadedFileCompletion(args: { + actualBytes: number; + plan: DownloadCompletionPlan; + toleranceBytes?: number; +}): { + ok: boolean; + totalBytes: number; + acceptedMetadataMismatch: boolean; + error?: string; +} { + const actualBytes = Math.max(0, Math.floor(Number(args.actualBytes) || 0)); + const expectedTotal = Number.isFinite(args.plan.expectedTotal || NaN) + ? Math.max(0, Math.floor(args.plan.expectedTotal || 0)) + : 0; + const toleranceBytes = Math.max(0, Math.floor(Number(args.toleranceBytes ?? ALLOCATION_UNIT_SIZE) || 0)); + + if ( + expectedTotal > 0 && + (args.plan.source === "content-range" || args.plan.source === "content-length") && + actualBytes + toleranceBytes < expectedTotal + ) { + return { + ok: false, + totalBytes: expectedTotal, + acceptedMetadataMismatch: false, + error: `download_underflow:${actualBytes}/${expectedTotal}` + }; + } + + if (actualBytes <= 0 && expectedTotal > 0) { + return { + ok: false, + totalBytes: expectedTotal, + acceptedMetadataMismatch: false, + error: `download_underflow:${actualBytes}/${expectedTotal}` + }; + } + + if (args.plan.source === "provider-metadata") { + if (expectedTotal > 0 && actualBytes + toleranceBytes < expectedTotal) { + return { + ok: false, + totalBytes: expectedTotal, + acceptedMetadataMismatch: false, + error: `download_underflow:${actualBytes}/${expectedTotal}` + }; + } + return { + ok: true, + totalBytes: actualBytes, + acceptedMetadataMismatch: expectedTotal > 0 && Math.abs(actualBytes - expectedTotal) > toleranceBytes + }; + } + + if (args.plan.source === "stream-end") { + if (actualBytes <= 0) { + return { + ok: false, + totalBytes: 0, + acceptedMetadataMismatch: false, + error: "download_underflow:0/0" + }; + } + return { + ok: true, + totalBytes: actualBytes, + acceptedMetadataMismatch: false + }; + } + + return { + ok: true, + totalBytes: Math.max(actualBytes, expectedTotal), + acceptedMetadataMismatch: false + }; +} diff --git a/src/main/download-manager.ts b/src/main/download-manager.ts new file mode 100644 index 0000000..385e05b --- /dev/null +++ b/src/main/download-manager.ts @@ -0,0 +1,12696 @@ +import fs from "node:fs"; +import path from "node:path"; +import os from "node:os"; +import { EventEmitter } from "node:events"; +import { v4 as uuidv4 } from "uuid"; +import { + AllDebridHostInfo, + AppSettings, + DebridProvider, + AudioStripSummary, + DownloadItem, + DownloadStats, + DownloadSummary, + DownloadStatus, + DuplicatePolicy, + HistoryEntry, + PackageEntry, + PackagePriority, + ParsedPackageInput, + SessionState, + StartConflictEntry, + StartConflictResolutionResult, + UiSnapshot, DebridAccountStatus } from "../shared/types"; +import { parseDebridLinkApiKeys } from "../shared/debrid-link-keys"; +import { isMegaDebridTransientResolveFailure, germanMegaDebridResolveReason } from "../shared/mega-debrid-errors"; +import { + addDebridLinkApiKeyDailyUsageBytes, + addDebridLinkApiKeyTotalUsageBytes, + addMegaDebridAccountDailyUsageBytes, + addMegaDebridAccountTotalUsageBytes, + addProviderDailyUsageBytes, + addProviderTotalUsageBytes, + getProviderUsageDayKey, + isProviderDailyLimitReached +} from "../shared/provider-daily-limits"; +import { REQUEST_RETRIES, SAMPLE_VIDEO_EXTENSIONS, SPEED_WINDOW_SECONDS, WRITE_BUFFER_SIZE, WRITE_FLUSH_TIMEOUT_MS, ALLOCATION_UNIT_SIZE, STREAM_HIGH_WATER_MARK, DISK_BUSY_THRESHOLD_MS, DISK_BUSY_STATUS_THRESHOLD_MS } from "./constants"; +import { parseCollectorInput } from "./link-parser"; + +let tlsSkipRefCount = 0; +function acquireTlsSkip(): void { + tlsSkipRefCount += 1; + if (tlsSkipRefCount === 1) { + process.env.NODE_TLS_REJECT_UNAUTHORIZED = "0"; + } +} +function releaseTlsSkip(): void { + tlsSkipRefCount -= 1; + if (tlsSkipRefCount <= 0) { + tlsSkipRefCount = 0; + delete process.env.NODE_TLS_REJECT_UNAUTHORIZED; + } +} +import { cleanupCancelledPackageArtifactsAsync, removeDownloadLinkArtifacts, removeSampleArtifacts } from "./cleanup"; +import { planDownloadCompletion, reconcileFinalizedSize, validateDownloadedFileCompletion } from "./download-completion"; +import { AllDebridWebUnrestrictor, BestDebridWebUnrestrictor, DebridService, MegaWebUnrestrictor, RealDebridWebUnrestrictor, checkRapidgatorOnline, fetchAllDebridHostInfo, getAvailableDebridLinkApiKeys, getAvailableMegaDebridAccounts, getMegaDebridAccountCooldownState, getMegaDebridInFlightCountForMode, pruneExpiredDebridLinkRuntimeState, pruneExpiredMegaDebridRuntimeState } from "./debrid"; +import { cleanupArchives, clearExtractResumeState, collectArchiveCleanupTargets, detectArchiveSignature, extractPackageArchives, findArchiveCandidates, hasAnyFilesRecursive, removeEmptyDirectoryTree, resetExtractorCachesForPasswordChange, type ExtractArchiveFailureInfo } from "./extractor"; +import { validateFileAgainstManifest } from "./integrity"; +import { classifyDiskError } from "./fs-error"; +import { processVideoFile, resolveVideoTooling, stripDualLangMarker, hasDualLangMarker, isRemuxableVideoFile, type GermanAudioMode, type VideoProcessResult } from "./video-processor"; +import { sendNotification } from "./notify"; +import { logger } from "./logger"; +import { getRecentRotationEvents, runWithRotationItemSink, setRotationEventListener } from "./account-rotation-log"; +import { runWithConversionTrace, traceConversionPhase, traceConversionNote } from "./conversion-trace"; +import type { RotationEvent } from "../shared/types"; +import { ensureItemLog, getItemLogPath as getPersistedItemLogPath, logItemEvent as writeItemLogEvent } from "./item-log"; +import { ensurePackageLog, getPackageLogPath as getPersistedPackageLogPath, logPackageEvent as writePackageLogEvent } from "./package-log"; +import { logRenameEvent as writeRenameLogEvent } from "./rename-log"; +import { logDesktopRename, verifyRename, verifyRenameAsync, type RenameVerification } from "./desktop-rename-log"; +import { StoragePaths, saveSession, saveSessionAsync, saveSettings, saveSettingsAsync } from "./storage"; +import { compactErrorText, ensureDirPath, filenameFromUrl, formatEta, humanSize, looksLikeOpaqueFilename, nowMs, sanitizeFilename, sleep } from "./utils"; + +type ActiveTask = { + itemId: string; + packageId: string; + abortController: AbortController; + abortReason: "stop" | "cancel" | "reconnect" | "package_toggle" | "stall" | "shutdown" | "reset" | "none"; + resumable: boolean; + nonResumableCounted: boolean; + freshRetryUsed?: boolean; + resumeHardResetUsed?: boolean; + stallRetries?: number; + genericErrorRetries?: number; + unrestrictRetries?: number; + blockedOnDiskWrite?: boolean; + blockedOnDiskSince?: number; +}; + +type PackageItemDiskState = { + diskPath: string | null; + exists: boolean; + size: number; + minBytes: number; + fullOnDisk: boolean; + persistedBytesReady: boolean; + reason: "ok" | "missing_path" | "missing_file" | "too_small" | "persisted_shortfall"; +}; + +type HybridFailedArchiveState = { + marker: string; + lastError: string; + updatedAt: number; +}; + +const DEFAULT_DOWNLOAD_STALL_TIMEOUT_MS = 10000; + +const DEFAULT_DOWNLOAD_CONNECT_TIMEOUT_MS = 25000; + +const DEFAULT_GLOBAL_STALL_WATCHDOG_TIMEOUT_MS = 60000; + +const DEFAULT_POST_EXTRACT_TIMEOUT_MS = 4 * 60 * 60 * 1000; + +const EXTRACT_PROGRESS_EMIT_INTERVAL_MS = 260; + +const DEFAULT_UNRESTRICT_TIMEOUT_MS = 60000; + +const DEFAULT_LOW_THROUGHPUT_TIMEOUT_MS = 120000; + +const DEFAULT_LOW_THROUGHPUT_MIN_BYTES = 64 * 1024; + +const MINI_DOWNLOAD_RETRY_THRESHOLD_BYTES = 5 * 1024; + +const ALLDEBRID_HOST_INFO_TTL_MS = 60000; + +const ALLDEBRID_START_STAGGER_MS = 3000; + +const ARCHIVE_SETTLE_MIN_DELAY_MS = 1500; + +const ARCHIVE_SETTLE_POLL_MS = 250; + +const ARCHIVE_SETTLE_MAX_WAIT_MS = 5000; + +const MAX_SAME_DIRECT_URL_ATTEMPTS = 3; + +const MAX_HTTP416_FRESH_RESTARTS = 2; +const HTTP416_FRESH_RESTART_DELAY_MS = 8000; + +function getHttp416FreshRestartDelayMs(): number { + const fromEnv = Number(process.env.RD_HTTP416_FRESH_RESTART_DELAY_MS ?? NaN); + if (Number.isFinite(fromEnv) && fromEnv >= 0 && fromEnv <= 600000) { + return Math.floor(fromEnv); + } + return HTTP416_FRESH_RESTART_DELAY_MS; +} + +const RESUME_REWIND_BYTES = 256 * 1024; + +const REALDEBRID_TOTAL_MISMATCH_TOLERANCE_BYTES = 64 * 1024; + +const PREALLOC_RESUME_MISMATCH_THRESHOLD_BYTES = 1024 * 1024; + +const LARGE_BINARY_FILE_RE = /\.(?:part\d+\.rar|rar|r\d{2,3}|zip(?:\.\d+)?|7z(?:\.\d+)?|tar|gz|bz2|xz|iso|mkv|mp4|avi|mov|wmv|m4v|ts|m2ts|webm|mp3|flac|aac|wav)$/i; + +const KNOWN_SMALL_FILE_RE = /\.(?:sfv|nfo|nzb|md5|sha1|sha256|crc|txt|url|lnk|srr)$/i; + +const BONUS_DIR_NORMALIZED_PATTERNS = [ + "extras", "extra", "bonus", "featurettes", "featurette", + "specials", "specialfeatures", + "behindthescenes", "deletedscenes", "deletedscene", + "makingof", "outtakes", "trailers", "interviews", "documentaries", + "alternateending", "gagreel" +]; + +const BONUS_FILENAME_RE = /(?:^|[._\-\s])(?:making[._\-\s]?of|behind[._\-\s]?the[._\-\s]?scenes|deleted[._\-\s]?scene|alternate[._\-\s]?ending|gag[._\-\s]?reel|featurette|outtakes?|bloopers?|interview|extended[._\-\s]?scene|exclusive[._\-\s]?scene|inside[._\-\s]?e\d+|making[._\-\s]?of[._\-\s]?e\d+)(?:[._\-\s]|$)/i; + +function normalizeBonusSegment(segment: string): string { + return String(segment || "").toLowerCase().replace(/[._\-\s]+/g, ""); +} + +function isInsideBonusDir(filePath: string, packageDir: string): boolean { + if (!filePath || !packageDir) return false; + let current = path.dirname(filePath); + const root = path.resolve(packageDir); + let safety = 0; + while (current && safety++ < 32) { + const resolvedCurrent = path.resolve(current); + if (resolvedCurrent === root) return false; + if (!isPathInsideDir(current, packageDir)) return false; + const normalized = normalizeBonusSegment(path.basename(current)); + for (const pattern of BONUS_DIR_NORMALIZED_PATTERNS) { + if (normalized.includes(pattern)) return true; + } + const parent = path.dirname(current); + if (!parent || parent === current) break; + current = parent; + } + return false; +} + +export function isBonusContent(filePath: string, packageDir: string, nameWithoutExt: string): boolean { + if (extractEpisodeToken(nameWithoutExt)) { + return false; + } + return isInsideBonusDir(filePath, packageDir) || BONUS_FILENAME_RE.test(nameWithoutExt); +} + +function expectedMinBytes(totalBytes: number | null | undefined, strict: boolean): number { + if (!totalBytes || totalBytes <= 0) { + return 10240; + } + return strict ? totalBytes : Math.max(10240, totalBytes - ALLOCATION_UNIT_SIZE); +} + +function itemExpectedMinBytes(item: DownloadItem): number { + const strict = isLargeBinaryLikePath(item.targetPath || item.fileName || ""); + return expectedMinBytes(item.totalBytes, strict); +} + +function resolvePreallocResumeMismatchThreshold(pathHint: string): number { + return isLargeBinaryLikePath(pathHint) + ? 0 + : PREALLOC_RESUME_MISMATCH_THRESHOLD_BYTES; +} + +function resolvePackageItemDiskPath(pkg: PackageEntry, item: DownloadItem): string | null { + if (item.targetPath) { + return item.targetPath; + } + if (item.fileName && pkg.outputDir) { + return path.join(pkg.outputDir, item.fileName); + } + return null; +} + +function inspectPackageItemDiskState(pkg: PackageEntry, item: DownloadItem): PackageItemDiskState { + const minBytes = itemExpectedMinBytes(item); + const diskPath = resolvePackageItemDiskPath(pkg, item); + if (!diskPath) { + return { + diskPath: null, + exists: false, + size: 0, + minBytes, + fullOnDisk: false, + persistedBytesReady: false, + reason: "missing_path" + }; + } + + try { + const stat = fs.statSync(diskPath); + const fullOnDisk = stat.size >= minBytes; + const persistedBytesReady = item.downloadedBytes >= minBytes; + return { + diskPath, + exists: true, + size: stat.size, + minBytes, + fullOnDisk, + persistedBytesReady, + reason: !fullOnDisk + ? "too_small" + : !persistedBytesReady + ? "persisted_shortfall" + : "ok" + }; + } catch { + return { + diskPath, + exists: false, + size: 0, + minBytes, + fullOnDisk: false, + persistedBytesReady: false, + reason: "missing_file" + }; + } +} + +function stripArchiveSuffixForMatching(fileName: string): string { + const trimmed = path.basename(String(fileName || "").trim()); + if (!trimmed) { + return ""; + } + let next = trimmed.replace(/\.(?:part\d+\.rar|zip\.\d+|7z\.\d+|rar|r\d{2,3}|zip|7z|\d{3})$/i, ""); + next = next.replace(/\.part\d+$/i, "").replace(/\.vol\d+[+\d]*$/i, ""); + return next.toLowerCase(); +} + +function isPreferredArchiveEntryPointName(fileName: string): boolean { + const normalized = path.basename(String(fileName || "").trim()).toLowerCase(); + if (!normalized) { + return false; + } + return /\.part0*1\.rar$/.test(normalized) + || (/\.rar$/.test(normalized) && !/\.part\d+\.rar$/.test(normalized) && !/\.r\d{2,3}$/.test(normalized)) + || /\.zip\.001$/.test(normalized) + || /\.7z\.001$/.test(normalized) + || (/\.001$/.test(normalized) && !/\.(zip|7z)\.001$/.test(normalized)); +} + +function getDownloadStallTimeoutMs(): number { + const fromEnv = Number(process.env.RD_STALL_TIMEOUT_MS ?? NaN); + if (Number.isFinite(fromEnv) && fromEnv >= 2000 && fromEnv <= 600000) { + return Math.floor(fromEnv); + } + return DEFAULT_DOWNLOAD_STALL_TIMEOUT_MS; +} + +function getDownloadConnectTimeoutMs(): number { + const fromEnv = Number(process.env.RD_CONNECT_TIMEOUT_MS ?? NaN); + if (Number.isFinite(fromEnv) && fromEnv >= 250 && fromEnv <= 180000) { + return Math.floor(fromEnv); + } + return DEFAULT_DOWNLOAD_CONNECT_TIMEOUT_MS; +} + +function getGlobalStallWatchdogTimeoutMs(): number { + const fromEnv = Number(process.env.RD_GLOBAL_STALL_TIMEOUT_MS ?? NaN); + if (Number.isFinite(fromEnv)) { + if (fromEnv <= 0) { + return 0; + } + if (fromEnv >= 2000 && fromEnv <= 600000) { + return Math.floor(fromEnv); + } + } + return DEFAULT_GLOBAL_STALL_WATCHDOG_TIMEOUT_MS; +} + +function getPostExtractTimeoutMs(): number { + const fromEnv = Number(process.env.RD_POST_EXTRACT_TIMEOUT_MS ?? NaN); + if (Number.isFinite(fromEnv) && fromEnv >= 2000 && fromEnv <= 24 * 60 * 60 * 1000) { + return Math.floor(fromEnv); + } + return DEFAULT_POST_EXTRACT_TIMEOUT_MS; +} + +function getUnrestrictTimeoutMs(): number { + const fromEnv = Number(process.env.RD_UNRESTRICT_TIMEOUT_MS ?? NaN); + if (Number.isFinite(fromEnv) && fromEnv >= 5000 && fromEnv <= 15 * 60 * 1000) { + return Math.floor(fromEnv); + } + return DEFAULT_UNRESTRICT_TIMEOUT_MS; +} + +function getLowThroughputTimeoutMs(): number { + const fromEnv = Number(process.env.RD_LOW_THROUGHPUT_TIMEOUT_MS ?? NaN); + if (Number.isFinite(fromEnv) && fromEnv >= 30000 && fromEnv <= 20 * 60 * 1000) { + return Math.floor(fromEnv); + } + return DEFAULT_LOW_THROUGHPUT_TIMEOUT_MS; +} + +function getLowThroughputMinBytes(): number { + const fromEnv = Number(process.env.RD_LOW_THROUGHPUT_MIN_BYTES ?? NaN); + if (Number.isFinite(fromEnv) && fromEnv >= 1024 && fromEnv <= 32 * 1024 * 1024) { + return Math.floor(fromEnv); + } + return DEFAULT_LOW_THROUGHPUT_MIN_BYTES; +} + +function normalizeRetryLimit(value: unknown): number { + const num = Number(value); + if (!Number.isFinite(num)) { + return REQUEST_RETRIES; + } + return Math.max(0, Math.min(99, Math.floor(num))); +} + +function retryLimitLabel(retryLimit: number): string { + return retryLimit <= 0 ? "inf" : String(retryLimit); +} + +function retryLimitToMaxRetries(retryLimit: number): number { + return retryLimit <= 0 ? Number.MAX_SAFE_INTEGER : retryLimit; +} + +type HistoryEntryCallback = (entry: HistoryEntry) => void; + +type DownloadManagerOptions = { + megaWebUnrestrict?: MegaWebUnrestrictor; + allDebridWebUnrestrict?: AllDebridWebUnrestrictor; + realDebridWebUnrestrict?: RealDebridWebUnrestrictor; + bestDebridWebUnrestrict?: BestDebridWebUnrestrictor; + invalidateMegaSession?: () => void; + onHistoryEntry?: HistoryEntryCallback; + protectEmptyClobber?: boolean; +}; + +function generateHistoryId(): string { + return `hist-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`; +} + +const EMPTY_PACKAGE_SPEED_BPS: Readonly<Record<string, number>> = Object.freeze({}); + +function cloneSession(session: SessionState): SessionState { + return { + ...session, + packageOrder: [...session.packageOrder], + packages: { ...session.packages }, + items: { ...session.items } + }; +} + +function cloneSettings(settings: AppSettings): AppSettings { + return { + ...settings, + bandwidthSchedules: (settings.bandwidthSchedules || []).map((entry) => ({ ...entry })), + providerDailyLimitBytes: { ...(settings.providerDailyLimitBytes || {}) }, + providerDailyUsageBytes: { ...(settings.providerDailyUsageBytes || {}) }, + providerTotalUsageBytes: { ...(settings.providerTotalUsageBytes || {}) }, + debridLinkApiKeyDailyLimitBytes: { ...(settings.debridLinkApiKeyDailyLimitBytes || {}) }, + debridLinkApiKeyDailyUsageBytes: { ...(settings.debridLinkApiKeyDailyUsageBytes || {}) }, + debridLinkApiKeyTotalUsageBytes: { ...(settings.debridLinkApiKeyTotalUsageBytes || {}) } + }; +} + +type ParsedContentRange = { + start: number; + end: number; + total: number | null; +}; + +function parseContentRange(contentRange: string | null): ParsedContentRange | null { + if (!contentRange) { + return null; + } + const match = contentRange.match(/^bytes\s+(\d+)-(\d+)\/(\d+|\*)$/i); + if (!match) { + return null; + } + const start = Number(match[1]); + const end = Number(match[2]); + const total = match[3] === "*" ? null : Number(match[3]); + if (!Number.isFinite(start) || !Number.isFinite(end) || start < 0 || end < start) { + return null; + } + if (total !== null && (!Number.isFinite(total) || total <= 0 || end >= total)) { + return null; + } + return { start, end, total }; +} + +function parseContentRangeTotal(contentRange: string | null): number | null { + return parseContentRange(contentRange)?.total ?? null; +} + +function parseContentDispositionFilename(contentDisposition: string | null): string { + if (!contentDisposition) { + return ""; + } + + const encodedMatch = contentDisposition.match(/filename\*\s*=\s*([^;]+)/i); + if (encodedMatch?.[1]) { + let value = encodedMatch[1].trim(); + value = value.replace(/^[A-Za-z0-9._-]+(?:'[^']*)?'/, ""); + value = value.replace(/^['"]+|['"]+$/g, ""); + try { + const decoded = decodeURIComponent(value).trim(); + if (decoded) { + return decoded; + } + } catch { + if (value) { + return value; + } + } + } + + const plainMatch = contentDisposition.match(/filename\s*=\s*([^;]+)/i); + if (!plainMatch?.[1]) { + return ""; + } + return plainMatch[1].trim().replace(/^['"]+|['"]+$/g, ""); +} + +function isArchiveLikePath(filePath: string): boolean { + const lower = path.basename(filePath).toLowerCase(); + return /\.(?:part\d+\.rar|rar|r\d{2,3}|zip(?:\.\d+)?|z\d{1,3}|7z(?:\.\d+)?)$/i.test(lower); +} + +function extractHosterKey(link: string): string { + try { + const host = new URL(link).hostname.replace(/^www\./, "").toLowerCase(); + const parts = host.split("."); + return parts.length >= 2 ? parts[parts.length - 2] : host; + } catch { + return ""; + } +} + +function isLargeBinaryLikePath(filePath: string): boolean { + const lower = path.basename(String(filePath || "")).toLowerCase(); + return isArchiveLikePath(lower) || LARGE_BINARY_FILE_RE.test(lower); +} + +function shouldRejectSuspiciousSmallDownload( + filePath: string, + fileName: string, + fileSizeOnDisk: number, + expectedTotal: number | null +): boolean { + const size = Math.max(0, Math.floor(Number(fileSizeOnDisk) || 0)); + const expected = Number.isFinite(expectedTotal || NaN) ? Math.max(0, Math.floor(expectedTotal || 0)) : 0; + const binaryLike = isLargeBinaryLikePath(filePath || fileName); + const name = path.basename(String(filePath || fileName || "")); + + if (KNOWN_SMALL_FILE_RE.test(name) && (expected <= 0 || size >= expected)) { + return false; + } + + if (size <= 0) { + return expected > 0 || binaryLike; + } + if (size < 512) { + if (expected > 0 && size >= expected && binaryLike) { + return false; + } + return true; + } + if (size >= MINI_DOWNLOAD_RETRY_THRESHOLD_BYTES) { + return false; + } + if (expected >= MINI_DOWNLOAD_RETRY_THRESHOLD_BYTES) { + return true; + } + return binaryLike; +} + +function isFetchFailure(errorText: string): boolean { + const text = String(errorText || "").toLowerCase(); + return text.includes("fetch failed") || text.includes("socket hang up") || text.includes("econnreset") || text.includes("network error"); +} + +function shouldRewindResumeTail(errorText: string): boolean { + const text = String(errorText || "").toLowerCase(); + if (!text) { + return false; + } + return text.includes("terminated") + || text.includes("stall_timeout") + || text.includes("slow_throughput") + || text.includes("write_drain_timeout") + || text.includes("premature close") + || text.includes("unexpected eof") + || text.includes("download_underflow") + || isFetchFailure(text); +} + +function isHttp416Text(errorText: string): boolean { + return /(^|\D)416(\D|$)/.test(String(errorText || "")); +} + +function shouldPreflightFinalizeItemFromDisk(item: DownloadItem): boolean { + const text = `${item.fullStatus || ""} ${item.lastError || ""}`.toLowerCase(); + return text.includes("resume-link erneuern") + || text.includes("resume link erneuern") + || text.includes("direktlink erneuern") + || text.includes("direktlink erschöpft") + || text.includes("direct_link_retry_exhausted") + || text.includes("download_underflow") + || text.includes("resume_download_underflow") + || text.includes("range_ignored_on_resume") + || text.includes("server ignorierte range"); +} + +function isResumeHardResetReason(errorText: string): boolean { + const text = String(errorText || ""); + return text.startsWith("resume_download_underflow:"); +} + +function isRealDebridProvider(provider: string | null | undefined): boolean { + return String(provider || "").trim().toLowerCase() === "realdebrid"; +} + +export function getAuthoritativeRealDebridTotal( + provider: string | null | undefined, + knownTotal: number, + existingBytes: number, + responseStatus: number, + contentLength: number, + totalFromRange: number | null, + resumeHardResetUsed: boolean +): { totalBytes: number; source: "content-range" | "content-length"; mismatchBytes: number } | null { + if (!isRealDebridProvider(provider) || !knownTotal || knownTotal <= 0) { + return null; + } + + const evaluateCandidate = ( + candidateTotal: number, + source: "content-range" | "content-length" + ): { totalBytes: number; source: "content-range" | "content-length"; mismatchBytes: number } | null => { + if (!Number.isFinite(candidateTotal) || candidateTotal <= 0 || candidateTotal >= knownTotal) { + return null; + } + + const mismatchBytes = knownTotal - candidateTotal; + if (mismatchBytes > REALDEBRID_TOTAL_MISMATCH_TOLERANCE_BYTES) { + return null; + } + + if (candidateTotal + ALLOCATION_UNIT_SIZE < existingBytes) { + return null; + } + + if (responseStatus === 206) { + if (existingBytes <= 0) { + return null; + } + const maxReachableBytes = existingBytes + Math.max(0, contentLength); + if (candidateTotal > maxReachableBytes + ALLOCATION_UNIT_SIZE) { + return null; + } + } else if (responseStatus === 200) { + if (!resumeHardResetUsed || source !== "content-length") { + return null; + } + } else { + return null; + } + + return { + totalBytes: candidateTotal, + source, + mismatchBytes + }; + }; + + return evaluateCandidate(totalFromRange || 0, "content-range") + || evaluateCandidate(contentLength, "content-length"); +} + +function isPermanentLinkError(errorText: string): boolean { + const text = String(errorText || "").toLowerCase(); + return text.includes("permanent ungültig") + || /file.?not.?found/.test(text) + || /file.?unavailable/.test(text) + || /link.?is.?dead/.test(text) + || text.includes("file has been removed") + || text.includes("file has been deleted") + || text.includes("file is no longer available") + || text.includes("file was removed") + || text.includes("file was deleted"); +} + +function isUnrestrictFailure(errorText: string): boolean { + const text = String(errorText || "").toLowerCase(); + return text.includes("unrestrict") || text.includes("debrid-link") || text.includes("debrid_link_") + || text.includes("mega-web") || text.includes("mega-debrid") + || text.includes("bestdebrid") || text.includes("alldebrid") || text.includes("kein debrid") + || text.includes("session-cookie") || text.includes("session cookie") || text.includes("session blockiert") + || text.includes("session expired") || text.includes("invalid session") + || text.includes("login ungültig") || text.includes("login liefert") + || text.includes("login required") || text.includes("login failed"); +} + +function parseDebridLinkCooldownRetry(errorText: string): { delayMs: number; detail: string } | null { + const match = String(errorText || "").match(/debrid_link_cooldown:(\d+):(.*)$/i); + if (!match) { + return null; + } + const delayMs = Math.max(1000, Math.min(15 * 60 * 1000, Number(match[1]) || 0)); + const detail = String(match[2] || "").trim(); + if (!delayMs) { + return null; + } + return { delayMs, detail }; +} + +export function parseMegaDebridCooldownRetry(errorText: string): { delayMs: number; detail: string } | null { + const text = String(errorText || ""); + const matches = [...text.matchAll(/mega_debrid_cooldown:(\d+)/gi)]; + if (matches.length === 0) { + return null; + } + const delays = matches.map((m) => Number(m[1])).filter((n) => Number.isFinite(n) && n > 0); + if (delays.length === 0) { + return null; + } + const delayMs = Math.max(1000, Math.min(15 * 60 * 1000, Math.min(...delays))); + return { delayMs, detail: text.replace(/mega_debrid_cooldown:\d+:/i, "").trim() }; +} + +export function parseMegaDebridSlowLinkRetry(errorText: string): { delayMs: number; detail: string } | null { + const text = String(errorText || ""); + const match = text.match(/mega_debrid_slow_link:(\d+)/i); + if (!match) { + return null; + } + const raw = Number(match[1]); + if (!Number.isFinite(raw) || raw <= 0) { + return null; + } + const delayMs = Math.max(1000, Math.min(15 * 60 * 1000, raw)); + return { delayMs, detail: text.replace(/mega_debrid_slow_link:\d+:/i, "").trim() }; +} + +export function parseMegaDebridResetPark(errorText: string): { delayMs: number; detail: string } | null { + const match = String(errorText || "").match(/mega_debrid_reset_park:(\d+):(.*)$/is); + if (!match) { + return null; + } + const raw = Number(match[1]); + if (!Number.isFinite(raw) || raw <= 0) { + return null; + } + const delayMs = Math.max(1000, Math.min(26 * 60 * 60 * 1000, raw)); + return { delayMs, detail: String(match[2] || "").trim() }; +} + +function parseDebridLinkTerminalFailure(errorText: string): { kind: "invalid_all" | "no_active_key"; detail: string } | null { + const raw = String(errorText || ""); + const match = raw.match(/debrid_link_(invalid_all|no_active_key):(.*)$/i); + if (!match) { + if (/debrid-link.+(deaktiviert|ausgeschopft|kein aktiver api-key)/i.test(raw)) { + return { + kind: "no_active_key", + detail: raw.trim() + }; + } + return null; + } + const kind = String(match[1] || "").toLowerCase() === "invalid_all" ? "invalid_all" : "no_active_key"; + const detail = String(match[2] || "").trim(); + return { + kind, + detail: detail || "Debrid-Link ist aktuell nicht verfuegbar" + }; +} + +function isProviderBusyUnrestrictError(errorText: string): boolean { + const text = String(errorText || "").toLowerCase(); + return text.includes("too many active") + || text.includes("too many concurrent") + || text.includes("too many downloads") + || text.includes("active download") + || text.includes("concurrent limit") + || text.includes("slot limit") + || text.includes("limit reached") + || text.includes("zu viele aktive") + || text.includes("zu viele gleichzeitige") + || text.includes("zu viele downloads"); +} + +function isHosterUnavailableError(errorText: string): boolean { + return String(errorText || "").toLowerCase().includes("hosternotavailable"); +} + +function isTemporaryUnrestrictError(errorText: string): boolean { + const text = String(errorText || "").toLowerCase(); + return text.includes("server error") + || text.includes("internal server error") + || text.includes("notdebrid") + || text.includes("unable to generate link") + || text.includes("kann aktuell nicht generiert werden") + || text.includes("temporarily unavailable") + || text.includes("temporary unavailable") + || text.includes("temporarily disabled") + || text.includes("try again later") + || text.includes("service unavailable") + || text.includes("host is down") + || text.includes("maintenance") + || text.includes("bad gateway") + || text.includes("gateway timeout") + || text.includes("cloudflare") + || text.includes("worker error"); +} + +export function transientResolveRetryDelayMs(retryCount: number): number { + const steps = [3000, 6000, 10000]; + const n = Math.max(1, Math.floor(Number(retryCount) || 1)); + return steps[Math.min(n - 1, steps.length - 1)]; +} + +function isFinishedStatus(status: DownloadStatus): boolean { + return status === "completed" || status === "failed" || status === "cancelled"; +} + +function isExtractedLabel(statusText: string): boolean { + return /^entpackt\b/i.test(String(statusText || "").trim()); +} + +function isExtractErrorLabel(statusText: string): boolean { + const text = String(statusText || "").trim(); + return /^entpacken\b/i.test(text) && /\berror\b/i.test(text) + || /^entpack-fehler\b/i.test(text) + || /^entpacken\b.*\btimeout\b/i.test(text); +} + +function isTransientExtractStatus(statusText: string): boolean { + const text = String(statusText || "").trim(); + return /^entpacken\b/i.test(text) + || /^passwort\b/i.test(text) + || /^finalisieren\b/i.test(text); +} + +function shouldAutoRetryExtraction(statusText: string): boolean { + return !isExtractedLabel(statusText) && !isExtractErrorLabel(statusText); +} + +function shouldPreserveExtractionResumeLabel(statusText: string): boolean { + const text = String(statusText || "").trim(); + return isTransientExtractStatus(text) || /^entpacken abgebrochen\b/i.test(text); +} + +function formatExtractDone(elapsedMs: number): string { + if (elapsedMs < 1000) return "Entpackt - Done (<1s)"; + const secs = elapsedMs / 1000; + return secs < 100 + ? `Entpackt - Done (${secs.toFixed(1)}s)` + : `Entpackt - Done (${Math.round(secs)}s)`; +} + +function providerLabel(provider: DownloadItem["provider"]): string { + if (provider === "realdebrid") { + return "Real-Debrid"; + } + if (provider === "megadebrid") { + return "Mega-Debrid"; + } + if (provider === "megadebrid-api") { + return "Mega-Debrid API"; + } + if (provider === "megadebrid-web") { + return "Mega-Debrid Web"; + } + if (provider === "bestdebrid") { + return "BestDebrid"; + } + if (provider === "alldebrid") { + return "AllDebrid"; + } + if (provider === "ddownload") { + return "DDownload"; + } + if (provider === "onefichier") { + return "1Fichier"; + } + if (provider === "debridlink") { + return "Debrid-Link"; + } + if (provider === "linksnappy") { + return "LinkSnappy"; + } + return "Debrid"; +} + +function resolveMegaDebridProvider(settings: AppSettings, provider: DebridProvider | null): DebridProvider | null { + if (provider !== "megadebrid") { + return provider; + } + const apiEnabled = settings.megaDebridApiEnabled + || (settings.megaLogin.trim() && settings.megaPassword.trim() && !settings.megaDebridApiEnabled && !settings.megaDebridWebEnabled && settings.megaDebridPreferApi); + const webEnabled = settings.megaDebridWebEnabled + || (settings.megaLogin.trim() && settings.megaPassword.trim() && !settings.megaDebridApiEnabled && !settings.megaDebridWebEnabled && !settings.megaDebridPreferApi); + if (apiEnabled && !webEnabled) { + return "megadebrid-api"; + } + if (webEnabled && !apiEnabled) { + return "megadebrid-web"; + } + return settings.megaDebridPreferApi ? "megadebrid-api" : "megadebrid-web"; +} + +function pathKey(filePath: string): string { + const resolved = path.resolve(filePath); + return process.platform === "win32" ? resolved.toLowerCase() : resolved; +} + +function isPathInsideDir(filePath: string, dirPath: string): boolean { + const file = pathKey(filePath); + const dir = pathKey(dirPath); + if (file === dir) { + return true; + } + const withSep = dir.endsWith(path.sep) ? dir : `${dir}${path.sep}`; + return file.startsWith(withSep); +} + +const EMPTY_DIR_IGNORED_FILE_NAMES = new Set([ + "thumbs.db", + "desktop.ini", + ".ds_store" +]); +const EMPTY_DIR_IGNORED_FILE_RE = /^\.rd_extract_progress(?:_[^.\\/]+)?\.json$/i; + +function isIgnorableEmptyDirFileName(fileName: string): boolean { + const normalized = String(fileName || "").trim().toLowerCase(); + return EMPTY_DIR_IGNORED_FILE_NAMES.has(normalized) || EMPTY_DIR_IGNORED_FILE_RE.test(normalized); +} + +function verifyHeadline(v: RenameVerification): string { + if (v.ok) { + return "Rename verifiziert"; + } + if (v.level === "WARN") { + return "Rename vollzogen, aber Schreibweise nicht verifiziert"; + } + return "Rename meldet OK, aber Verifikation FEHLGESCHLAGEN"; +} + +function toWindowsLongPathIfNeeded(filePath: string): string { + const absolute = path.resolve(String(filePath || "")); + if (process.platform !== "win32") { + return absolute; + } + if (!absolute || absolute.startsWith("\\\\?\\")) { + return absolute; + } + if (absolute.length < 248) { + return absolute; + } + if (absolute.startsWith("\\\\")) { + return `\\\\?\\UNC\\${absolute.slice(2)}`; + } + return `\\\\?\\${absolute}`; +} + +const SCENE_RELEASE_FOLDER_RE = /-(?:4sf|4sj)$/i; +const SCENE_GROUP_SUFFIX_RE = /-(?=[A-Za-z0-9]{2,}$)(?=[A-Za-z0-9]*[A-Z])[A-Za-z0-9]+$/; +const SCENE_EPISODE_RE = /(?:^|[._\-\s])s(\d{1,2})e(\d{1,3})(?:e(\d{1,3}))?(?!\d)/i; +const SCENE_EPISODE_JOINED_RE = /s(\d{1,2})e(\d{1,3})(?:e(\d{1,3}))?(?!\d)/i; +const SCENE_EPISODE_TYPO_SS_RE = /(?:^|[._\-\s])s(\d{1,2})s(\d{1,3})(?!\d)/i; +const SCENE_SEASON_ONLY_RE = /(^|[._\-\s])s\d{1,2}(?=[._\-\s]|$)/i; + +export function hasMeaningfulSeriesPrefix(name: string): boolean { + const text = String(name || ""); + const seasonMatch = text.match(/(?:^|[._\-\s])s\d{1,2}/i); + if (!seasonMatch || seasonMatch.index === undefined) { + return false; + } + const prefix = text.slice(0, seasonMatch.index); + const alphaChars = (prefix.match(/[A-Za-z]/g) || []).length; + return alphaChars >= 3; +} + +export function looksLikeObfuscatedSceneFileName(name: string): boolean { + const text = String(name || "").toLowerCase(); + if (!text) { + return true; + } + let markers = 0; + if (/(?:^|[._\-\s])(?:480|540|576|720|1080|1440|2160|4320)p(?:[._\-\s]|$)/.test(text)) markers += 1; + if (/(?:^|[._\-\s])(?:german|english|french|italian|spanish|dutch|nordic|multi|ger|eng|ita|fre|spa)(?:[._\-\s]|$)/.test(text)) markers += 1; + if (/(?:^|[._\-\s])(?:bluray|brrip|bdrip|webrip|web-?dl|web|hdtv|dvdrip|amazonhd|amzn|nflx|nf|hulu|dsnp)(?:[._\-\s]|$)/.test(text)) markers += 1; + if (/(?:^|[._\-\s])(?:x264|x265|h264|h265|hevc|xvid|divx|avc)(?:[._\-\s]|$)/.test(text)) markers += 1; + if (/(?:^|[._\-\s])(?:ac3|aac|dd5\.?1|dd51|dts|eac3|atmos|truehd|flac)(?:[._\-\s]|$)/.test(text)) markers += 1; + if (markers >= 2) { + return false; + } + const dotCount = (text.match(/\./g) || []).length; + if (dotCount >= 5) { + return false; + } + return true; +} +const SCENE_SEASON_CAPTURE_RE = /(?:^|[._\-\s])s(\d{1,2})(?=[._\-\s]|$)/i; +const SCENE_EPISODE_ONLY_RE = /(?:^|[._\-\s])e(?:p(?:isode)?)?\s*0*(\d{1,3})(?:[._\-\s]|$)/i; +const SCENE_PART_TOKEN_RE = /(?:^|[._\-\s])(?:teil|part)\s*0*(\d{1,3})(?=[._\-\s]|$)/i; +const SCENE_COMPACT_EPISODE_CODE_RE = /(?:^|[._\-\s])(\d{3,4})([a-z])?(?=$|[._\-\s])/i; +const SCENE_RP_TOKEN_RE = /(?:^|[._\-\s])rp(?:[._\-\s]|$)/i; +const SCENE_REPACK_TOKEN_RE = /(?:^|[._\-\s])repack(?:[._\-\s]|$)/i; +const SCENE_QUALITY_TOKEN_RE = /([._\-\s])((?:4320|2160|1440|1080|720|576|540|480|360)p)(?=[._\-\s]|$)/i; +const SCENE_RESOLUTION_MARKER_RE = /\b(?:480|540|576|720|1080|1440|2160|4320)[pi]\b/i; +const SCENE_CODEC_MARKER_RE = /\b(?:x264|x265|x266|h\.?264|h\.?265|h\.?266|hevc|avc|vvc|av1|xvid|divx)\b/i; +const SCENE_GROUP_SUFFIX_FALLBACK_RE = /-([A-Za-z0-9]{2,})$/; +const SCENE_FLEXIBLE_GROUP_SUFFIX_RE = /-([A-Za-z0-9]+(?:_[A-Za-z0-9]+)*)$/; +const SCENE_MIXED_GROUP_SUFFIX_RE = /-[^-]*[\/\\|\u2044\u2215][^-]*$/; +const SCENE_NON_GROUP_SUFFIXES = new Set([ + "x264", + "x265", + "h264", + "h265", + "avc", + "hevc", + "web", + "webrip", + "webdl", + "bluray", + "bdrip", + "hdtv", + "dvdrip", + "remux" +]); + +function isValidSceneGroupSuffix(suffix: string): boolean { + const normalizedSuffix = String(suffix || "").trim(); + if (!normalizedSuffix) { + return false; + } + + const lower = normalizedSuffix.toLowerCase(); + if (SCENE_NON_GROUP_SUFFIXES.has(lower)) { + return false; + } + if (/^s\d{1,2}e\d{1,3}(?:e\d{1,3})?$/i.test(normalizedSuffix) || /^s\d{1,2}$/i.test(normalizedSuffix) || /^e\d{1,3}$/i.test(normalizedSuffix)) { + return false; + } + if (/^\d+p$/.test(lower) || /^\d+$/.test(lower)) { + return false; + } + if (/^\d/.test(normalizedSuffix)) { + return false; + } + if (/4s(?:f|j)/i.test(normalizedSuffix) && !/^(?:4sf|4sj)$/i.test(normalizedSuffix)) { + return false; + } + return /[a-z]/i.test(normalizedSuffix); +} + +function extractFlexibleSceneGroupSuffix(fileName: string): string | null { + const text = String(fileName || "").trim(); + if (!text) { + return null; + } + + const match = text.match(SCENE_FLEXIBLE_GROUP_SUFFIX_RE); + const suffix = String(match?.[1] || "").trim(); + if (!suffix || !/[a-z]/i.test(suffix)) { + return null; + } + + const suffixParts = suffix.split("_").filter(Boolean); + if (suffixParts.length === 0) { + return null; + } + if (!suffixParts.every((part) => isValidSceneGroupSuffix(part))) { + return null; + } + return suffix; +} + +function hasMixedSceneGroupSuffix(fileName: string): boolean { + const text = String(fileName || "").trim(); + if (!text) { + return false; + } + return SCENE_MIXED_GROUP_SUFFIX_RE.test(text); +} + +function applySourceSceneGroupSuffix(targetBaseName: string, sourceFileName: string): string { + const target = String(targetBaseName || "").trim(); + const suffix = extractFlexibleSceneGroupSuffix(sourceFileName); + if (!target || !suffix) { + return target; + } + + if (/-[^-]+$/.test(target)) { + return target.replace(/-[^-]+$/, `-${suffix}`); + } + return `${target}-${suffix}`; +} + +function hasSceneGroupSuffix(fileName: string): boolean { + const text = String(fileName || "").trim(); + if (!text) { + return false; + } + + if (SCENE_GROUP_SUFFIX_RE.test(text)) { + const directMatch = text.match(SCENE_GROUP_SUFFIX_FALLBACK_RE); + return isValidSceneGroupSuffix(String(directMatch?.[1] || "")); + } + + const fallbackMatch = text.match(SCENE_GROUP_SUFFIX_FALLBACK_RE); + const suffix = String(fallbackMatch?.[1] || "").trim(); + if (isValidSceneGroupSuffix(suffix)) { + return true; + } + return extractFlexibleSceneGroupSuffix(text) !== null; +} + +const SCENE_EPISODE_X_RE = /(?:^|[._\-\s])(\d{1,2})x(\d{1,2})(?:x(\d{1,2}))?(?![\dx])/i; + +export function extractEpisodeToken(fileName: string): string | null { + const text = String(fileName || ""); + const match = text.match(SCENE_EPISODE_RE) + || text.match(SCENE_EPISODE_JOINED_RE) + || text.match(SCENE_EPISODE_TYPO_SS_RE) + || text.match(SCENE_EPISODE_X_RE); + if (!match) { + return null; + } + + const season = Number(match[1]); + const episode = Number(match[2]); + if (!Number.isFinite(season) || !Number.isFinite(episode) || season < 0 || episode < 0) { + return null; + } + + let token = `S${String(season).padStart(2, "0")}E${String(episode).padStart(2, "0")}`; + if (match[3]) { + const episode2 = Number(match[3]); + if (Number.isFinite(episode2) && episode2 > 0) { + token += `E${String(episode2).padStart(2, "0")}`; + } + } + return token; +} + +function extractSeasonToken(fileName: string): string | null { + const text = String(fileName || ""); + const episodeMatch = text.match(SCENE_EPISODE_RE); + if (episodeMatch?.[1]) { + const season = Number(episodeMatch[1]); + if (Number.isFinite(season) && season >= 0) { + return `S${String(season).padStart(2, "0")}`; + } + } + const seasonMatch = text.match(SCENE_SEASON_CAPTURE_RE); + if (!seasonMatch?.[1]) { + return null; + } + const season = Number(seasonMatch[1]); + if (!Number.isFinite(season) || season < 0) { + return null; + } + return `S${String(season).padStart(2, "0")}`; +} + +function extractPartEpisodeNumber(fileName: string): number | null { + const match = String(fileName || "").match(SCENE_PART_TOKEN_RE); + if (!match?.[1]) { + return null; + } + const episode = Number(match[1]); + if (!Number.isFinite(episode) || episode <= 0) { + return null; + } + return episode; +} + +function extractEpisodeOnlyNumber(fileName: string): number | null { + const match = String(fileName || "").match(SCENE_EPISODE_ONLY_RE); + if (!match?.[1]) { + return null; + } + const episode = Number(match[1]); + if (!Number.isFinite(episode) || episode <= 0 || episode > 999) { + return null; + } + return episode; +} + +function extractCompactEpisodeToken(fileName: string, seasonHint: number | null): string | null { + const trimmed = String(fileName || "").trim(); + if (!trimmed) { + return null; + } + const match = trimmed.match(SCENE_COMPACT_EPISODE_CODE_RE); + if (!match?.[1]) { + return null; + } + + const code = match[1]; + const episodeSuffix = String(match[2] || "").toLowerCase(); + if (code === "4320" || code === "2160" || code === "1440" || code === "1080" + || code === "0720" || code === "720" || code === "0576" || code === "576" + || code === "0540" || code === "540" || code === "0480" || code === "480" + || code === "0360" || code === "360") { + return null; + } + + const letterOffset = episodeSuffix + ? episodeSuffix.charCodeAt(0) - "a".charCodeAt(0) + : 0; + const toToken = (season: number, episode: number): string | null => { + const effectiveEpisode = episode + Math.max(0, letterOffset); + if (episodeSuffix && (letterOffset < 0 || letterOffset > 25)) { + return null; + } + if (!Number.isFinite(season) || !Number.isFinite(effectiveEpisode) || season < 0 || season > 99 || effectiveEpisode <= 0 || effectiveEpisode > 999) { + return null; + } + return `S${String(season).padStart(2, "0")}E${String(effectiveEpisode).padStart(2, "0")}`; + }; + + if (seasonHint !== null && Number.isFinite(seasonHint) && seasonHint >= 0 && seasonHint <= 99) { + const seasonRaw = String(Math.trunc(seasonHint)); + const seasonPadded = String(Math.trunc(seasonHint)).padStart(2, "0"); + const seasonPrefixes = [seasonPadded, seasonRaw].filter((value, index, array) => value.length > 0 && array.indexOf(value) === index) + .sort((a, b) => b.length - a.length); + for (const prefix of seasonPrefixes) { + if (!code.startsWith(prefix) || code.length <= prefix.length) { + continue; + } + const episode = Number(code.slice(prefix.length)); + const token = toToken(Number(seasonRaw), episode); + if (token) { + return token; + } + } + } + + if (code.length === 3) { + return toToken(Number(code[0]), Number(code.slice(1))); + } + if (code.length === 4) { + return toToken(Number(code.slice(0, 2)), Number(code.slice(2))); + } + return null; +} + +function resolveEpisodeTokenForAutoRename(sourceFileName: string, folderNames: string[]): { token: string; fromPart: boolean } | null { + const directFromSource = extractEpisodeToken(sourceFileName); + if (directFromSource) { + return { token: directFromSource, fromPart: false }; + } + + for (const folderName of folderNames) { + const directFromFolder = extractEpisodeToken(folderName); + if (directFromFolder) { + return { token: directFromFolder, fromPart: false }; + } + } + + const seasonTokenHint = extractSeasonToken(sourceFileName) + ?? folderNames.map((folderName) => extractSeasonToken(folderName)).find(Boolean) + ?? null; + const episodeOnly = extractEpisodeOnlyNumber(sourceFileName) + ?? folderNames.map((folderName) => extractEpisodeOnlyNumber(folderName)).find((value) => Number.isFinite(value) && (value as number) > 0) + ?? null; + if (seasonTokenHint && episodeOnly) { + return { + token: `${seasonTokenHint}E${String(episodeOnly).padStart(2, "0")}`, + fromPart: false + }; + } + const seasonHint = seasonTokenHint ? Number(seasonTokenHint.slice(1)) : null; + const compactEpisode = extractCompactEpisodeToken(sourceFileName, seasonHint); + if (compactEpisode) { + return { token: compactEpisode, fromPart: false }; + } + + const partEpisode = extractPartEpisodeNumber(sourceFileName) + ?? folderNames.map((folderName) => extractPartEpisodeNumber(folderName)).find((value) => Number.isFinite(value) && (value as number) > 0) + ?? null; + if (!partEpisode) { + return null; + } + + const seasonToken = seasonTokenHint || "S01"; + + return { + token: `${seasonToken}E${String(partEpisode).padStart(2, "0")}`, + fromPart: true + }; +} + +export function applyEpisodeTokenToFolderName(folderName: string, episodeToken: string): string { + const trimmed = String(folderName || "").trim(); + if (!trimmed) { + return episodeToken; + } + + const episodeRe = /(^|[._\-\s])s\d{1,2}e\d{1,3}(?:e\d{1,3})?(?:[-]e?\d{1,3})?(?=[._\-\s]|$)/i; + if (episodeRe.test(trimmed)) { + return trimmed.replace(episodeRe, `$1${episodeToken}`); + } + + const withSeason = trimmed.replace(SCENE_SEASON_ONLY_RE, `$1${episodeToken}`); + if (withSeason !== trimmed) { + return withSeason; + } + + const withSuffixInsert = trimmed.replace(/-(4sf|4sj)$/i, `.${episodeToken}-$1`); + if (withSuffixInsert !== trimmed) { + return withSuffixInsert; + } + + return `${trimmed}.${episodeToken}`; +} + +export function sourceHasRpToken(fileName: string): boolean { + return SCENE_RP_TOKEN_RE.test(String(fileName || "")); +} + +function removeRpTokens(baseName: string): string { + const normalized = String(baseName || "") + .replace(/(^|[._\-\s])rp(?=([._\-\s]|$))/ig, "$1") + .replace(/\.{2,}/g, ".") + .replace(/-{2,}/g, "-") + .replace(/_{2,}/g, "_") + .replace(/\s{2,}/g, " ") + .replace(/^[._\-\s]+|[._\-\s]+$/g, ""); + return normalized || String(baseName || ""); +} + +export function ensureRepackToken(baseName: string): string { + if (SCENE_REPACK_TOKEN_RE.test(baseName)) { + return baseName; + } + + const withQualityToken = baseName.replace(SCENE_QUALITY_TOKEN_RE, "$1REPACK.$2"); + if (withQualityToken !== baseName) { + return withQualityToken; + } + + const withSuffixToken = baseName.replace(/-(4sf|4sj)$/i, ".REPACK-$1"); + if (withSuffixToken !== baseName) { + return withSuffixToken; + } + + return `${baseName}.REPACK`; +} + +export function buildAutoRenameBaseName(folderName: string, sourceFileName: string): string | null { + const normalizedFolderName = String(folderName || "").trim(); + const normalizedSourceFileName = String(sourceFileName || "").trim(); + if (!normalizedFolderName || !normalizedSourceFileName) { + return null; + } + + const isLegacy4sf4sjFolder = SCENE_RELEASE_FOLDER_RE.test(normalizedFolderName); + const isSceneGroupFolder = hasSceneGroupSuffix(normalizedFolderName); + const isCompleteEpisodeFolder = Boolean(extractEpisodeToken(normalizedFolderName)) + && (SCENE_RESOLUTION_MARKER_RE.test(normalizedFolderName) || SCENE_CODEC_MARKER_RE.test(normalizedFolderName)); + if (!isLegacy4sf4sjFolder && !isSceneGroupFolder && !isCompleteEpisodeFolder) { + return null; + } + + const episodeToken = extractEpisodeToken(normalizedSourceFileName); + if (!episodeToken) { + return null; + } + + let next = isLegacy4sf4sjFolder + ? applyEpisodeTokenToFolderName(normalizedFolderName, episodeToken) + : normalizedFolderName; + + if (!isLegacy4sf4sjFolder && (isSceneGroupFolder || isCompleteEpisodeFolder)) { + const episodeRangeRe = /(^|[._\-\s])s\d{1,2}e\d{1,3}[-]e?\d{1,3}(?=[._\-\s]|$)/i; + if (episodeRangeRe.test(normalizedFolderName)) { + next = applyEpisodeTokenToFolderName(normalizedFolderName, episodeToken); + } + } + + const hasRepackHint = sourceHasRpToken(normalizedSourceFileName) + || SCENE_REPACK_TOKEN_RE.test(normalizedSourceFileName) + || sourceHasRpToken(normalizedFolderName) + || SCENE_REPACK_TOKEN_RE.test(normalizedFolderName); + if (hasRepackHint) { + next = removeRpTokens(next); + next = ensureRepackToken(next); + } + + return sanitizeFilename(next); +} + +export function buildAutoRenameBaseNameFromFolders(folderNames: string[], sourceFileName: string): string | null { + return buildAutoRenameBaseNameFromFoldersWithOptions(folderNames, sourceFileName, { forceEpisodeForSeasonFolder: false }); +} + +export function buildAutoRenameBaseNameFromFoldersWithOptions( + folderNames: string[], + sourceFileName: string, + options: { forceEpisodeForSeasonFolder?: boolean } +): string | null { + const ordered = folderNames + .map((value) => String(value || "").trim()) + .filter((value) => value.length > 0); + if (ordered.length === 0) { + return null; + } + + const normalizedSourceFileName = String(sourceFileName || "").trim(); + const resolvedEpisode = resolveEpisodeTokenForAutoRename(normalizedSourceFileName, ordered); + const forceEpisodeForSeasonFolder = Boolean(options.forceEpisodeForSeasonFolder); + const globalRepackHint = sourceHasRpToken(normalizedSourceFileName) + || SCENE_REPACK_TOKEN_RE.test(normalizedSourceFileName) + || ordered.some((folderName) => sourceHasRpToken(folderName) || SCENE_REPACK_TOKEN_RE.test(folderName)); + + for (const folderName of ordered) { + const folderHasEpisode = Boolean(extractEpisodeToken(folderName)); + const folderHasSeason = Boolean(extractSeasonToken(folderName)); + const folderHasPart = extractPartEpisodeNumber(folderName) !== null; + if (folderHasPart && !folderHasEpisode && !folderHasSeason) { + continue; + } + + let target = buildAutoRenameBaseName(folderName, normalizedSourceFileName); + if (!target && resolvedEpisode && hasSceneGroupSuffix(folderName) && (folderHasSeason || folderHasEpisode)) { + target = applyEpisodeTokenToFolderName(folderName, resolvedEpisode.token); + } + if (!target) { + continue; + } + + if (resolvedEpisode + && forceEpisodeForSeasonFolder + && hasSceneGroupSuffix(target) + && !extractEpisodeToken(target) + && SCENE_SEASON_ONLY_RE.test(target)) { + target = applyEpisodeTokenToFolderName(target, resolvedEpisode.token); + } + + if (resolvedEpisode?.fromPart + && hasSceneGroupSuffix(target) + && !extractEpisodeToken(target) + && SCENE_SEASON_ONLY_RE.test(target)) { + target = applyEpisodeTokenToFolderName(target, resolvedEpisode.token); + } + + if (resolvedEpisode + && folderHasSeason + && !folderHasEpisode + && (hasMixedSceneGroupSuffix(folderName) || !hasSceneGroupSuffix(folderName))) { + target = applySourceSceneGroupSuffix(target, normalizedSourceFileName); + } + + if (globalRepackHint) { + target = ensureRepackToken(removeRpTokens(target)); + } + return sanitizeFilename(target); + } + + if (resolvedEpisode && forceEpisodeForSeasonFolder) { + for (const folderName of ordered) { + if (!SCENE_SEASON_ONLY_RE.test(folderName) || extractEpisodeToken(folderName)) { + continue; + } + let target = applyEpisodeTokenToFolderName(folderName, resolvedEpisode.token); + if (hasMixedSceneGroupSuffix(folderName) || !hasSceneGroupSuffix(folderName)) { + target = applySourceSceneGroupSuffix(target, normalizedSourceFileName); + } + if (globalRepackHint) { + target = ensureRepackToken(removeRpTokens(target)); + } + return sanitizeFilename(target); + } + } + + return null; +} + +export type AutoRenameNameDecision = + | { kind: "rename"; baseName: string; note?: "token-inserted" | "token-applyToken" | "folder-override" | "folder-as-is"; sourceEpisodeToken?: string; targetEpisodeToken?: string } + | { kind: "skip"; reason: "no-target" | "source-better" | "token-loss" | "token-mismatch"; targetBaseName?: string; sourceEpisodeToken?: string; targetEpisodeToken?: string }; + +export function decideAutoRenameBaseName( + folderCandidates: string[], + sourceName: string, + sourceBaseName: string, + parentFolderName: string, + extractDirName: string +): AutoRenameNameDecision { + let targetBaseName = buildAutoRenameBaseNameFromFoldersWithOptions(folderCandidates, sourceBaseName, { + forceEpisodeForSeasonFolder: true + }); + + if (targetBaseName) { + const anyFolderHasSeasonOrEpisode = folderCandidates.some( + (folderName) => Boolean(extractSeasonToken(folderName)) || Boolean(extractEpisodeToken(folderName)) + ); + if (!anyFolderHasSeasonOrEpisode) { + return { kind: "skip", reason: "no-target" }; + } + } + + if (targetBaseName && sourceBaseName.length > 0) { + const sourceHasEpisode = Boolean(extractEpisodeToken(sourceBaseName)); + const targetHasEpisode = Boolean(extractEpisodeToken(targetBaseName)); + const sourceHasSeriesPrefix = hasMeaningfulSeriesPrefix(sourceBaseName); + const targetHasSeriesPrefix = hasMeaningfulSeriesPrefix(targetBaseName); + const targetIsMuchShorter = targetBaseName.length * 2 < sourceBaseName.length; + if (sourceHasEpisode && targetHasEpisode && sourceHasSeriesPrefix && !targetHasSeriesPrefix && targetIsMuchShorter) { + return { kind: "skip", reason: "source-better", targetBaseName }; + } + } + + let note: "token-inserted" | "token-applyToken" | "folder-override" | undefined; + const sourceEpisodeToken = extractEpisodeToken(sourceBaseName); + if (targetBaseName && sourceEpisodeToken) { + const targetEpisodeToken = extractEpisodeToken(targetBaseName); + if (!targetEpisodeToken) { + if (!looksLikeObfuscatedSceneFileName(sourceName)) { + return { kind: "skip", reason: "source-better", targetBaseName }; + } + const insertedEpisode = targetBaseName.replace( + /(^|[._\-\s])(s\d{1,2})(?=[A-Za-z0-9])/i, + `$1${sourceEpisodeToken}.` + ); + if (insertedEpisode !== targetBaseName && extractEpisodeToken(insertedEpisode)) { + targetBaseName = insertedEpisode; + note = "token-inserted"; + } else { + const repaired = applyEpisodeTokenToFolderName(targetBaseName, sourceEpisodeToken); + if (repaired && extractEpisodeToken(repaired)) { + targetBaseName = repaired; + note = "token-applyToken"; + } else { + return { kind: "skip", reason: "token-loss", targetBaseName, sourceEpisodeToken }; + } + } + } else if (targetEpisodeToken !== sourceEpisodeToken) { + const parentEpisodeToken = extractEpisodeToken(parentFolderName); + const sourceLooksObfuscated = looksLikeObfuscatedSceneFileName(sourceName); + const folderIsAuthoritative = Boolean( + parentEpisodeToken + && parentEpisodeToken === targetEpisodeToken + && parentFolderName.toLowerCase() !== extractDirName.toLowerCase() + && sourceLooksObfuscated + ); + if (folderIsAuthoritative) { + note = "folder-override"; + return { kind: "rename", baseName: targetBaseName, note, sourceEpisodeToken, targetEpisodeToken }; + } + return { kind: "skip", reason: "token-mismatch", targetBaseName, sourceEpisodeToken, targetEpisodeToken }; + } + } + + if (!targetBaseName) { + if (!extractEpisodeToken(sourceBaseName)) { + for (const folderName of folderCandidates) { + const f = sanitizeFilename(String(folderName || "").trim()); + if (!f) { + continue; + } + if (hasSceneGroupSuffix(f) && (SCENE_RESOLUTION_MARKER_RE.test(f) || SCENE_CODEC_MARKER_RE.test(f)) && !SCENE_SEASON_ONLY_RE.test(f)) { + return { kind: "rename", baseName: f, note: "folder-as-is" }; + } + } + } + return { kind: "skip", reason: "no-target" }; + } + return { kind: "rename", baseName: targetBaseName, note }; +} + +const ARCHIVE_MULTIPART_RAR_RE = /^(.*)\.part0*1\.rar$/; +const ARCHIVE_RAR_RE = /^(.*)\.rar$/; +const ARCHIVE_ZIP_SPLIT_RE = /^(.*)\.zip\.001$/; +const ARCHIVE_7Z_SPLIT_RE = /^(.*)\.7z\.001$/; +const ARCHIVE_GENERIC_001_RE = /^(.*)\.001$/; +const ARCHIVE_KNOWN_001_RE = /\.(zip|7z)\.001$/; +const REGEX_ESCAPE_RE = /[.*+?^${}()|[\]\\]/g; + +export function resolveArchiveItemsFromList(archiveName: string, items: DownloadItem[]): DownloadItem[] { + const normalizeArchiveMatchName = (value: string): string => + stripDuplicateSuffixBeforeExtension(path.basename(String(value || ""))); + const entryLower = normalizeArchiveMatchName(archiveName).toLowerCase(); + + const itemBaseName = (item: DownloadItem): string => + normalizeArchiveMatchName(item.targetPath || item.fileName || ""); + + let pattern: RegExp | null = null; + const multipartMatch = entryLower.match(ARCHIVE_MULTIPART_RAR_RE); + if (multipartMatch) { + const prefix = multipartMatch[1].replace(REGEX_ESCAPE_RE, "\\$&"); + pattern = new RegExp(`^${prefix}\\.part\\d+\\.rar$`, "i"); + } + if (!pattern) { + const rarMatch = entryLower.match(ARCHIVE_RAR_RE); + if (rarMatch) { + const stem = rarMatch[1].replace(REGEX_ESCAPE_RE, "\\$&"); + pattern = new RegExp(`^${stem}\\.r(ar|\\d{2,3})$`, "i"); + } + } + if (!pattern) { + const zipSplitMatch = entryLower.match(ARCHIVE_ZIP_SPLIT_RE); + if (zipSplitMatch) { + const stem = zipSplitMatch[1].replace(REGEX_ESCAPE_RE, "\\$&"); + pattern = new RegExp(`^${stem}\\.zip(\\.\\d+)?$`, "i"); + } + } + if (!pattern) { + const sevenSplitMatch = entryLower.match(ARCHIVE_7Z_SPLIT_RE); + if (sevenSplitMatch) { + const stem = sevenSplitMatch[1].replace(REGEX_ESCAPE_RE, "\\$&"); + pattern = new RegExp(`^${stem}\\.7z(\\.\\d+)?$`, "i"); + } + } + if (!pattern && ARCHIVE_GENERIC_001_RE.test(entryLower) && !ARCHIVE_KNOWN_001_RE.test(entryLower)) { + const genericSplitMatch = entryLower.match(ARCHIVE_GENERIC_001_RE); + if (genericSplitMatch) { + const stem = genericSplitMatch[1].replace(REGEX_ESCAPE_RE, "\\$&"); + pattern = new RegExp(`^${stem}\\.\\d{3}$`, "i"); + } + } + + if (pattern) { + const matched = items.filter((item) => pattern!.test(itemBaseName(item))); + if (matched.length > 0) return matched; + } + + const exactMatch = items.filter((item) => itemBaseName(item).toLowerCase() === entryLower); + if (exactMatch.length > 0) return exactMatch; + + const archiveStem = entryLower + .replace(/\.part\d+\.rar$/i, "") + .replace(/\.r\d{2,3}$/i, "") + .replace(/\.rar$/i, "") + .replace(/\.(zip|7z)\.\d{3}$/i, "") + .replace(/\.\d{3}$/i, "") + .replace(/\.(zip|7z)$/i, ""); + if (archiveStem.length > 3) { + const stemMatch = items.filter((item) => { + const name = itemBaseName(item).toLowerCase(); + return name.startsWith(archiveStem) && /\.(rar|r\d{2,3}|zip|7z|\d{3})$/i.test(name); + }); + if (stemMatch.length > 0) return stemMatch; + } + + if (items.length === 1) { + const singleName = itemBaseName(items[0]).toLowerCase(); + if (/\.(rar|zip|7z|\d{3})$/i.test(singleName)) { + return items; + } + } + + return []; +} + +function stripDuplicateSuffixBeforeExtension(fileName: string): string { + return String(fileName || "").replace(/ \(\d+\)(?=\.[^.]+$)/, ""); +} + +function hasDuplicateSuffixBeforeExtension(fileName: string): boolean { + const normalized = stripDuplicateSuffixBeforeExtension(fileName); + return normalized !== String(fileName || ""); +} + +function startupDuplicateStateRank(item: DownloadItem, diskExists: boolean): number { + let rank = diskExists ? 40 : 0; + switch (item.status) { + case "completed": + rank += 40; + break; + case "downloading": + case "validating": + case "integrity_check": + rank += 25; + break; + case "queued": + case "reconnect_wait": + case "paused": + rank += 10; + break; + case "failed": + rank += 5; + break; + default: + break; + } + const fullStatus = String(item.fullStatus || "").trim(); + if (isExtractedLabel(fullStatus)) { + rank += 65; + } else if (/^fertig\b/i.test(fullStatus)) { + rank += 30; + } else if (isTransientExtractStatus(fullStatus)) { + rank += 20; + } else if (isExtractErrorLabel(fullStatus)) { + rank += 5; + } + rank += Math.max(0, Math.min(9, Math.floor(Number(item.progressPercent || 0) / 12))); + if (Number(item.downloadedBytes || 0) > 0) { + rank += 1; + } + return rank; +} + +export function extractArchiveNameFromExtractorLogMessage(message: string): string | null { + const text = String(message || "").trim(); + if (!text) { + return null; + } + + const patterns = [ + /archive=([^,\s|]+)/i, + /Entpacke Archiv:\s*([^\s]+)\s*->/i, + /Entpack-Fehler\s+([^\s]+)\s+\[/i + ]; + for (const pattern of patterns) { + const match = text.match(pattern); + if (match?.[1]) { + return match[1].trim(); + } + } + return null; +} + +function summarizeExtractFailureReason(reason: string): string { + const text = compactErrorText(reason).replace(/^Error:\s*/i, "").trim(); + if (!text) { + return "Entpacken fehlgeschlagen"; + } + if (/checksum error|crc/i.test(text)) { + return "Checksum/CRC-Fehler im Archiv"; + } + if (/wrong password|falsches passwort|password/i.test(text) && /checksum error in the encrypted file/i.test(text)) { + return "Checksum- oder Passwortfehler im verschluesselten Archiv"; + } + if (/missing_file|next volume is required|cannot find volume|volume.*missing|part.*missing/i.test(text)) { + return "Teilarchiv fehlt oder ist nicht lesbar"; + } + if (/unexpected end of archive|no end header found|invalid or unsupported zip format|not a rar archive|ungueltig|unsupported_format/i.test(text)) { + return "Archiv unvollstaendig oder ungueltig"; + } + return text; +} + +function formatExtractFailureLabel(reason: string, archiveName = ""): string { + const summary = summarizeExtractFailureReason(reason); + const archive = String(archiveName || "").trim(); + return archive + ? `Entpack-Fehler [${archive}]: ${summary}` + : `Entpack-Fehler: ${summary}`; +} + +function retryDelayWithJitter(attempt: number, baseMs: number): number { + const exponential = baseMs * Math.pow(1.5, Math.min(attempt - 1, 14)); + const capped = Math.min(exponential, 120000); + const jitter = capped * (0.5 + Math.random() * 0.5); + return Math.floor(jitter); +} + +export class DownloadManager extends EventEmitter { + private settings: AppSettings; + + private session: SessionState; + + private storagePaths: StoragePaths; + + public skipShutdownPersist = false; + + public blockAllPersistence = false; + + private protectAgainstEmptyClobber = false; + + private emptyClobberProtectionLogged = false; + + private debridService: DebridService; + + private invalidateMegaSessionFn?: () => void; + + private activeTasks = new Map<string, ActiveTask>(); + + private scheduleRunning = false; + private schedulerGeneration = 0; + + private persistTimer: NodeJS.Timeout | null = null; + + private speedEvents: Array<{ at: number; bytes: number; pid: string }> = []; + + private summary: DownloadSummary | null = null; + + private nonResumableActive = 0; + + private stateEmitTimer: NodeJS.Timeout | null = null; + private lastStateEmitAt = 0; + + private speedBytesLastWindow = 0; + + private sessionDownloadedBytes = 0; + private sessionCompletedFiles = 0; + + private statsCache: DownloadStats | null = null; + + private statsCacheAt = 0; + + private settingsSnapshotCache: AppSettings | null = null; + private settingsSnapshotCacheAt = 0; + private invalidateSettingsSnapshotCache(): void { + this.settingsSnapshotCache = null; + this.settingsSnapshotCacheAt = 0; + } + + private lastEmittedItemHashes = new Map<string, string>(); + private lastEmittedPackageHashes = new Map<string, string>(); + private firstEmitDone = false; + private lastFullEmitAt = 0; + private static readonly FULL_RESYNC_INTERVAL_MS = 30000; + + private lastPersistAt = 0; + private lastSettingsPersistAt = 0; + private appSessionStartedAt = 0; + private runtimePersistedTotalMs = 0; + private runtimePersistedAt = 0; + + private cleanupQueue: Promise<void> = Promise.resolve(); + + private packagePostProcessQueue: Promise<void> = Promise.resolve(); + + private packagePostProcessActive = 0; + + private packagePostProcessWaiters: Array<{ packageId: string; resolve: () => void }> = []; + + private packagePostProcessTasks = new Map<string, Promise<void>>(); + + private packagePostProcessAbortControllers = new Map<string, AbortController>(); + + private packageDeferredPostProcessAbortControllers = new Map<string, AbortController>(); + + private packageHybridPostProcessControllers = new Map<string, Set<AbortController>>(); + + private packagePostProcessVersions = new Map<string, number>(); + + private hybridExtractRequeue = new Set<string>(); + + private hybridExtractedPaths = new Map<string, Set<string>>(); + + private hybridFailedArchives = new Map<string, Map<string, HybridFailedArchiveState>>(); + + private autoRecoveredForRedownload = new Set<string>(); + + private reservedTargetPaths = new Map<string, string>(); + + private claimedTargetPathByItem = new Map<string, string>(); + + private itemContributedBytes = new Map<string, number>(); + + private packageFileOpChain = new Map<string, Promise<unknown>>(); + + private fileStabilizeMinAgeMs = process.env.VITEST ? 0 : 2000; + + public setFileStabilizeMinAgeMsForTests(ms: number): void { + this.fileStabilizeMinAgeMs = Math.max(0, Math.floor(ms)); + } + + private runItemIds = new Set<string>(); + + private runPackageIds = new Set<string>(); + + private runOutcomes = new Map<string, "completed" | "failed" | "cancelled">(); + + private runCompletedPackages = new Set<string>(); + + private historyRecordedPackages = new Set<string>(); + + private notifiedPackages = new Set<string>(); + + private itemCount = 0; + + private lastSchedulerHeartbeatAt = 0; + + private lastReconnectMarkAt = 0; + + private consecutiveReconnects = 0; + + private lastGlobalProgressBytes = 0; + + private lastGlobalProgressAt = 0; + + private retryAfterByItem = new Map<string, number>(); + + private retryStateByItem = new Map<string, { + freshRetryUsed: boolean; + resumeHardResetUsed: boolean; + stallRetries: number; + genericErrorRetries: number; + unrestrictRetries: number; + }>(); + + private http416FreshRestartByItem = new Map<string, number>(); + + private providerFailures = new Map<string, { count: number; lastFailAt: number; cooldownUntil: number }>(); + + private allDebridHostInfoCache = new Map<string, { info: AllDebridHostInfo; cachedAt: number }>(); + + private providerStartReservations = new Map<string, number>(); + private pacedStartReservationByItem = new Map<string, number>(); + + private lastStaleResetAt = 0; + + private onHistoryEntryCallback?: HistoryEntryCallback; + + public constructor(settings: AppSettings, session: SessionState, storagePaths: StoragePaths, options: DownloadManagerOptions = {}) { + super(); + this.settings = settings; + const startedAt = nowMs(); + this.appSessionStartedAt = startedAt; + this.runtimePersistedTotalMs = Math.max(0, Number(settings.totalRuntimeAllTimeMs || 0)); + this.runtimePersistedAt = startedAt; + this.session = session; + this.itemCount = Object.keys(this.session.items).length; + this.storagePaths = storagePaths; + this.protectAgainstEmptyClobber = Boolean(options.protectEmptyClobber); + if (this.protectAgainstEmptyClobber) { + logger.warn("Session-Schutz aktiv: Start mit unlesbarer Session — leere Speicherungen blockiert, bis echte Daten vorliegen"); + } + this.debridService = new DebridService(settings, { + megaWebUnrestrict: options.megaWebUnrestrict, + allDebridWebUnrestrict: options.allDebridWebUnrestrict, + realDebridWebUnrestrict: options.realDebridWebUnrestrict, + bestDebridWebUnrestrict: options.bestDebridWebUnrestrict + }); + this.invalidateMegaSessionFn = options.invalidateMegaSession; + this.onHistoryEntryCallback = options.onHistoryEntry; + logger.info(`DownloadManager Init: ${Object.keys(this.session.packages).length} Pakete, ${this.itemCount} Items, cleanupPolicy=${this.settings.completedCleanupPolicy}`); + for (const pkg of Object.values(this.session.packages)) { + this.ensurePackageLogForPackage(pkg); + this.logPackageForPackage(pkg, "INFO", "Paket aus Session wiederhergestellt", { + itemCount: pkg.itemIds.length, + status: pkg.status, + enabled: pkg.enabled, + cancelled: pkg.cancelled + }); + } + this.applyOnStartCleanupPolicy(); + this.normalizeSessionStatuses(); + this.restoreTargetPathReservations(); + this.resolveExistingQueuedOpaqueFilenames(); + this.revalidateCompletedItems(); + void this.recoverRetryableItems("startup").catch((err) => logger.warn(`recoverRetryableItems Fehler (startup): ${compactErrorText(err)}`)); + this.recoverPostProcessingOnStartup(); + this.checkExistingRapidgatorLinks(); + void this.cleanupExistingExtractedArchives().catch((err) => logger.warn(`cleanupExistingExtractedArchives Fehler (constructor): ${compactErrorText(err)}`)); + setRotationEventListener(() => { + if (this.rotationListenerActive === false) { + return; + } + try { + this.emitState(true); + } catch { + } + }); + } + + private rotationListenerActive = true; + + public getPackageLogPath(packageId: string): string | null { + const pkg = this.session.packages[packageId]; + if (pkg) { + return this.ensurePackageLogForPackage(pkg); + } + return getPersistedPackageLogPath(packageId); + } + + public getItemLogPath(itemId: string): string | null { + const item = this.session.items[itemId]; + if (item) { + return this.ensureItemLogForItem(item); + } + return getPersistedItemLogPath(itemId); + } + + private ensurePackageLogForPackage(pkg: PackageEntry): string | null { + return ensurePackageLog({ + packageId: pkg.id, + name: pkg.name, + outputDir: pkg.outputDir, + extractDir: pkg.extractDir + }); + } + + private ensureItemLogForItem(item: DownloadItem): string | null { + const pkg = this.session.packages[item.packageId]; + return ensureItemLog({ + itemId: item.id, + packageId: item.packageId, + packageName: pkg?.name || "", + fileName: item.fileName, + targetPath: item.targetPath + }); + } + + private logPackage(packageId: string, level: "INFO" | "WARN" | "ERROR", message: string, fields?: Record<string, unknown>): void { + writePackageLogEvent(packageId, level, message, fields); + } + + private logPackageForPackage(pkg: PackageEntry, level: "INFO" | "WARN" | "ERROR", message: string, fields?: Record<string, unknown>): void { + this.ensurePackageLogForPackage(pkg); + this.logPackage(pkg.id, level, message, { + packageName: pkg.name, + ...fields + }); + } + + private logExtractionForItems( + pkg: PackageEntry, + items: DownloadItem[], + prefix: string, + level: "INFO" | "WARN" | "ERROR", + message: string + ): void { + const fullMessage = `${prefix}: ${message}`; + this.logPackageForPackage(pkg, level, fullMessage); + + const archiveName = extractArchiveNameFromExtractorLogMessage(message); + if (!archiveName) { + return; + } + for (const item of resolveArchiveItemsFromList(archiveName, items)) { + this.logPackageForItem(item, level, fullMessage, { archiveName }); + } + } + + private logPackageForItem( + item: DownloadItem, + level: "INFO" | "WARN" | "ERROR", + message: string, + fields?: Record<string, unknown> + ): void { + const pkg = this.session.packages[item.packageId]; + if (pkg) { + this.ensurePackageLogForPackage(pkg); + } + this.ensureItemLogForItem(item); + this.logPackage(item.packageId, level, message, { + packageName: pkg?.name || "", + itemId: item.id, + fileName: item.fileName, + status: item.status, + targetPath: item.targetPath, + ...fields + }); + writeItemLogEvent(item.id, level, message, { + packageId: item.packageId, + packageName: pkg?.name || "", + itemId: item.id, + fileName: item.fileName, + status: item.status, + targetPath: item.targetPath, + ...fields + }); + } + + private logItemOnly( + item: DownloadItem, + level: "INFO" | "WARN" | "ERROR", + message: string, + fields?: Record<string, unknown> + ): void { + const pkg = this.session.packages[item.packageId]; + this.ensureItemLogForItem(item); + writeItemLogEvent(item.id, level, message, { + packageId: item.packageId, + packageName: pkg?.name || "", + itemId: item.id, + fileName: item.fileName, + status: item.status, + targetPath: item.targetPath, + ...fields + }); + } + + private collectRenameMatchTokensForItem(pkg: PackageEntry, item: DownloadItem): string[] { + const tokens = new Set<string>(); + const maybeAdd = (value: string | null | undefined): void => { + const normalized = String(value || "").trim().toLowerCase(); + if (!normalized || normalized.length < 4) { + return; + } + tokens.add(normalized); + }; + + maybeAdd(stripArchiveSuffixForMatching(item.fileName || "")); + maybeAdd(stripArchiveSuffixForMatching(item.targetPath ? path.basename(item.targetPath) : "")); + const diskPath = resolvePackageItemDiskPath(pkg, item); + if (diskPath) { + maybeAdd(stripArchiveSuffixForMatching(path.basename(diskPath))); + } + const episodeToken = extractEpisodeToken(item.fileName || path.basename(item.targetPath || "")); + if (episodeToken) { + maybeAdd(episodeToken); + } + return [...tokens].sort((a, b) => b.length - a.length); + } + + private inferItemForMediaLog( + pkg: PackageEntry, + ...candidates: Array<string | null | undefined> + ): { item: DownloadItem | null; matchedBy: string | null } { + const items = pkg.itemIds + .map((itemId) => this.session.items[itemId]) + .filter(Boolean) as DownloadItem[]; + if (items.length === 0) { + return { item: null, matchedBy: null }; + } + if (items.length === 1) { + return { item: items[0] || null, matchedBy: items[0] ? "single_item_package" : null }; + } + + const haystack = candidates + .map((value) => String(value || "").trim().toLowerCase()) + .filter(Boolean) + .join(" || "); + if (!haystack) { + return { item: null, matchedBy: null }; + } + + let bestItem: DownloadItem | null = null; + let bestScore = 0; + let bestMatchedBy: string | null = null; + let bestPreferredEntry = false; + let ambiguous = false; + + for (const item of items) { + const fileName = item.fileName || path.basename(item.targetPath || ""); + const preferredEntry = isPreferredArchiveEntryPointName(fileName); + let score = preferredEntry ? 5 : 0; + let matchedBy: string | null = preferredEntry ? "entry_point" : null; + + const episodeToken = extractEpisodeToken(fileName); + if (episodeToken && haystack.includes(episodeToken.toLowerCase())) { + score = 110 + (preferredEntry ? 5 : 0); + matchedBy = "episode_token"; + } else { + for (const token of this.collectRenameMatchTokensForItem(pkg, item)) { + if (haystack.includes(token)) { + score = Math.max(score, Math.min(100, 40 + token.length) + (preferredEntry ? 5 : 0)); + matchedBy = token === episodeToken?.toLowerCase() ? "episode_token" : `token:${token}`; + break; + } + } + } + + if (score > bestScore || (score === bestScore && score > 0 && preferredEntry && !bestPreferredEntry)) { + bestItem = item; + bestScore = score; + bestMatchedBy = matchedBy; + bestPreferredEntry = preferredEntry; + ambiguous = false; + continue; + } + if (score > 0 && score === bestScore && preferredEntry === bestPreferredEntry) { + ambiguous = true; + } + } + + if (ambiguous || !bestItem || bestScore <= 0) { + return { item: null, matchedBy: null }; + } + return { item: bestItem, matchedBy: bestMatchedBy }; + } + + private logRenameProcess( + pkg: PackageEntry, + level: "INFO" | "WARN" | "ERROR", + stage: "auto-rename" | "mkv-move" | "audio-strip", + message: string, + fields?: Record<string, unknown>, + item?: DownloadItem | null, + matchedBy?: string | null + ): void { + writeRenameLogEvent(level, message, { + stage, + packageId: pkg.id, + packageName: pkg.name, + ...(item ? { itemId: item.id, fileName: item.fileName } : {}), + ...(matchedBy ? { matchedBy } : {}), + ...fields + }); + logDesktopRename(level, `[${stage}] ${message}`, { + paket: pkg.name, + ...(item ? { datei: item.fileName } : {}), + ...(matchedBy ? { matchedBy } : {}), + ...fields + }); + if (item) { + this.logItemOnly(item, level, message, { + stage, + ...(matchedBy ? { matchedBy } : {}), + ...fields + }); + } + } + + public applyDebridAccountStatuses(statuses: DebridAccountStatus[]): void { + const map: Record<string, DebridAccountStatus> = { ...(this.settings.debridAccountStatuses || {}) }; + for (const status of statuses) { + map[status.accountId] = status; + } + this.settings.debridAccountStatuses = map; + this.invalidateSettingsSnapshotCache(); + void saveSettingsAsync(this.storagePaths, this.settings).catch((err) => logger.warn(`saveSettingsAsync Fehler (account-status): ${compactErrorText(err as Error)}`)); + this.emitState(); + } + + public setSettings(next: AppSettings, opts?: { suppressRetroactiveCleanup?: boolean }): void { + const previous = this.settings; + next.totalDownloadedAllTime = Math.max(next.totalDownloadedAllTime || 0, this.settings.totalDownloadedAllTime || 0); + next.totalCompletedFilesAllTime = Math.max(next.totalCompletedFilesAllTime || 0, this.settings.totalCompletedFilesAllTime || 0); + const now = nowMs(); + next.totalRuntimeAllTimeMs = Math.max(next.totalRuntimeAllTimeMs || 0, this.getLiveTotalRuntimeMs(now)); + this.settings = next; + this.invalidateSettingsSnapshotCache(); + this.runtimePersistedTotalMs = this.settings.totalRuntimeAllTimeMs || 0; + this.runtimePersistedAt = now; + this.ensureProviderDailyUsageFresh(nowMs()); + this.debridService.setSettings(next); + this.allDebridHostInfoCache.clear(); + + const prevOrder = JSON.stringify(previous.providerOrder ?? []); + const nextOrder = JSON.stringify(next.providerOrder ?? []); + const prevRouting = JSON.stringify(previous.hosterRouting ?? {}); + const nextRouting = JSON.stringify(next.hosterRouting ?? {}); + if (prevOrder !== nextOrder || prevRouting !== nextRouting) { + const activeItemIds = new Set([...this.activeTasks.values()].map((t) => t.itemId)); + for (const item of Object.values(this.session.items)) { + if (!activeItemIds.has(item.id) && item.status !== "completed" && item.status !== "failed") { + item.provider = null; + } + } + } + + const previousArchivePasswords = String(previous.archivePasswordList || "").replace(/\r\n|\r/g, "\n"); + const nextArchivePasswords = String(next.archivePasswordList || "").replace(/\r\n|\r/g, "\n"); + if (previousArchivePasswords !== nextArchivePasswords) { + this.hybridExtractedPaths.clear(); + this.hybridFailedArchives.clear(); + const pwCount = nextArchivePasswords.split("\n").filter(Boolean).length; + const reset = resetExtractorCachesForPasswordChange(); + logger.info(`Archiv-Passwortliste geaendert (${pwCount} Eintraege): Extractor-Caches zurueckgesetzt (learned=${reset.learnedCleared}, daemonRestart=${reset.daemonRestarted})`); + } + + const credChanges: Array<{ prev: string; next: string; providers: string[] }> = [ + { prev: previous.token || "", next: next.token || "", providers: ["realdebrid"] }, + { prev: previous.allDebridToken || "", next: next.allDebridToken || "", providers: ["alldebrid"] }, + { prev: previous.bestToken || "", next: next.bestToken || "", providers: ["bestdebrid"] }, + { prev: previous.debridLinkApiKeys || "", next: next.debridLinkApiKeys || "", providers: ["debridlink"] }, + { prev: previous.linkSnappyLogin + "|" + previous.linkSnappyPassword, next: next.linkSnappyLogin + "|" + next.linkSnappyPassword, providers: ["linksnappy"] }, + { prev: previous.ddownloadLogin + "|" + previous.ddownloadPassword, next: next.ddownloadLogin + "|" + next.ddownloadPassword, providers: ["ddownload"] }, + { prev: previous.megaCredentials + "|" + previous.megaPassword, next: next.megaCredentials + "|" + next.megaPassword, providers: ["megadebrid", "megadebrid-api", "megadebrid-web"] } + ]; + let clearedProviderFailures = 0; + for (const change of credChanges) { + if (change.prev === change.next) continue; + for (const provider of change.providers) { + for (const key of [...this.providerFailures.keys()]) { + if (key === provider || key.startsWith(`${provider}:`)) { + this.providerFailures.delete(key); + clearedProviderFailures += 1; + } + } + } + } + if (clearedProviderFailures > 0) { + logger.info(`Settings-Update: ${clearedProviderFailures} Provider-Failure(s) gecleart wegen geaenderter Credentials`); + } + + this.resolveExistingQueuedOpaqueFilenames(); + void this.cleanupExistingExtractedArchives().catch((err) => logger.warn(`cleanupExistingExtractedArchives Fehler (setSettings): ${compactErrorText(err)}`)); + if (!opts?.suppressRetroactiveCleanup && next.completedCleanupPolicy !== "never") { + this.applyRetroactiveCleanupPolicy(); + } + this.emitState(); + } + + public getSettings(): AppSettings { + return this.settings; + } + + public getSession(): SessionState { + return cloneSession(this.session); + } + + public getSummary(): DownloadSummary | null { + return this.summary; + } + + public isSessionRunning(): boolean { + return this.session.running; + } + + public abortAllPostProcessing(): void { + this.abortPostProcessing("external"); + for (const waiter of this.packagePostProcessWaiters) { waiter.resolve(); } + this.packagePostProcessWaiters = []; + this.packagePostProcessActive = 0; + } + + public triggerIdleExtractions(): void { + if (this.session.running || !this.settings.autoExtract || !this.settings.autoExtractWhenStopped) { + return; + } + this.recoverPostProcessingOnStartup(); + this.triggerPendingExtractions(); + this.persistSoon(); + this.emitState(); + } + + private buildItemHash(item: DownloadItem): string { + return `${item.updatedAt}|${item.status}|${item.progressPercent}|${item.speedBps}|${item.downloadedBytes}|${item.totalBytes}|${item.retries}|${item.fullStatus || ""}|${item.fileName}|${item.providerLabel || ""}|${item.provider || ""}|${item.onlineStatus || ""}|${item.lastError || ""}`; + } + + private buildPackageHash(pkg: PackageEntry): string { + return `${pkg.updatedAt}|${pkg.status}|${pkg.name}|${pkg.enabled ? 1 : 0}|${pkg.cancelled ? 1 : 0}|${pkg.priority || ""}|${pkg.itemIds.length}|${pkg.postProcessLabel || ""}|${pkg.audioStripSummary?.at || 0}`; + } + + public getSnapshotForEmit(forceFull = false): UiSnapshot { + const base = this.getSnapshot(); + const now = nowMs(); + const needsFullResync = !this.firstEmitDone || forceFull + || (now - this.lastFullEmitAt) > DownloadManager.FULL_RESYNC_INTERVAL_MS; + + if (needsFullResync) { + this.lastEmittedItemHashes.clear(); + this.lastEmittedPackageHashes.clear(); + for (const id in base.session.items) { + this.lastEmittedItemHashes.set(id, this.buildItemHash(base.session.items[id])); + } + for (const id in base.session.packages) { + this.lastEmittedPackageHashes.set(id, this.buildPackageHash(base.session.packages[id])); + } + this.firstEmitDone = true; + this.lastFullEmitAt = now; + return { ...base, payloadKind: "full" }; + } + + const changedItems: Record<string, DownloadItem> = {}; + const removedItemIds: string[] = []; + const seenItemIds = new Set<string>(); + let itemChangeCount = 0; + for (const id in base.session.items) { + seenItemIds.add(id); + const item = base.session.items[id]; + const newHash = this.buildItemHash(item); + const oldHash = this.lastEmittedItemHashes.get(id); + if (oldHash !== newHash) { + changedItems[id] = item; + this.lastEmittedItemHashes.set(id, newHash); + itemChangeCount += 1; + } + } + for (const id of this.lastEmittedItemHashes.keys()) { + if (!seenItemIds.has(id)) { + removedItemIds.push(id); + } + } + for (const id of removedItemIds) { + this.lastEmittedItemHashes.delete(id); + } + + const changedPackages: Record<string, PackageEntry> = {}; + const removedPackageIds: string[] = []; + const seenPackageIds = new Set<string>(); + for (const id in base.session.packages) { + seenPackageIds.add(id); + const pkg = base.session.packages[id]; + const newHash = this.buildPackageHash(pkg); + const oldHash = this.lastEmittedPackageHashes.get(id); + if (oldHash !== newHash) { + changedPackages[id] = pkg; + this.lastEmittedPackageHashes.set(id, newHash); + } + } + for (const id of this.lastEmittedPackageHashes.keys()) { + if (!seenPackageIds.has(id)) { + removedPackageIds.push(id); + } + } + for (const id of removedPackageIds) { + this.lastEmittedPackageHashes.delete(id); + } + + return { + ...base, + session: { + ...base.session, + items: changedItems, + packages: changedPackages, + }, + payloadKind: "delta", + removedItemIds, + removedPackageIds, + }; + } + + public getSnapshot(): UiSnapshot { + const now = nowMs(); + this.ensureProviderDailyUsageFresh(now, true); + this.pruneSpeedEvents(now); + const paused = this.session.running && this.session.paused; + const speedBps = !this.session.running || paused ? 0 : this.speedBytesLastWindow / SPEED_WINDOW_SECONDS; + + let totalItems = 0; + let doneItems = 0; + if (this.session.running && this.runItemIds.size > 0) { + totalItems = this.runItemIds.size; + for (const itemId of this.runItemIds) { + if (this.runOutcomes.has(itemId)) { + doneItems += 1; + continue; + } + const item = this.session.items[itemId]; + if (item && isFinishedStatus(item.status)) { + doneItems += 1; + } + } + } else { + const sessionItems = Object.values(this.session.items); + totalItems = sessionItems.length; + for (const item of sessionItems) { + if (isFinishedStatus(item.status)) { + doneItems += 1; + } + } + } + const elapsed = this.session.runStartedAt > 0 ? (now - this.session.runStartedAt) / 1000 : 0; + const rate = doneItems > 0 && elapsed > 0 ? doneItems / elapsed : 0; + const remaining = totalItems - doneItems; + const eta = remaining > 0 && rate > 0 ? remaining / rate : -1; + + const reconnectMs = Math.max(0, this.session.reconnectUntil - now); + + const snapshotSession = cloneSession(this.session); + let snapshotSettings: AppSettings; + if (this.settingsSnapshotCache && now - this.settingsSnapshotCacheAt < 400) { + snapshotSettings = this.settingsSnapshotCache; + } else { + snapshotSettings = cloneSettings(this.settings); + this.settingsSnapshotCache = snapshotSettings; + this.settingsSnapshotCacheAt = now; + } + const snapshotSummary = this.summary + ? { ...this.summary } + : null; + + return { + rotationEvents: getRecentRotationEvents(40), + settings: snapshotSettings, + session: snapshotSession, + summary: snapshotSummary, + stats: this.getStats(now), + speedText: `Geschwindigkeit: ${humanSize(Math.max(0, Math.floor(speedBps)))}/s`, + etaText: paused || !this.session.running ? "ETA: --" : `ETA: ${formatEta(eta)}`, + canStart: !this.session.running, + canStop: this.session.running, + canPause: this.session.running, + clipboardActive: this.settings.clipboardWatch, + reconnectSeconds: Math.ceil(reconnectMs / 1000), + packageSpeedBps: !this.session.running || paused + ? EMPTY_PACKAGE_SPEED_BPS + : (() => { + const out: Record<string, number> = {}; + for (const [pid, bytes] of this.speedBytesPerPackage) { + out[pid] = Math.floor(bytes / SPEED_WINDOW_SECONDS); + } + return out; + })() + }; + } + + public getStats(now = nowMs()): DownloadStats { + const itemCount = this.itemCount; + if (this.statsCache && this.session.running && itemCount >= 500 && now - this.statsCacheAt < 1500) { + return this.statsCache; + } + + this.resetSessionTotalsIfQueueEmpty(); + + const stats = { + totalDownloaded: this.sessionDownloadedBytes, + totalDownloadedAllTime: this.settings.totalDownloadedAllTime, + totalFiles: this.sessionCompletedFiles, + totalFilesSession: this.sessionCompletedFiles, + totalFilesAllTime: this.settings.totalCompletedFilesAllTime, + totalPackages: this.session.packageOrder.length, + sessionStartedAt: this.session.runStartedAt, + appSessionStartedAt: this.appSessionStartedAt, + sessionRuntimeMs: this.getAppSessionRuntimeMs(now), + totalRuntimeMs: this.getLiveTotalRuntimeMs(now), + runtimeMeasuredAt: now + }; + this.statsCache = stats; + this.statsCacheAt = now; + return stats; + } + + public getLiveTotalRuntimeMs(now = nowMs()): number { + return Math.max(0, this.runtimePersistedTotalMs + Math.max(0, now - this.runtimePersistedAt)); + } + + private getAppSessionRuntimeMs(now = nowMs()): number { + return this.appSessionStartedAt > 0 ? Math.max(0, now - this.appSessionStartedAt) : 0; + } + + private foldRuntimeIntoSettings(now = nowMs()): boolean { + const totalRuntimeMs = this.getLiveTotalRuntimeMs(now); + if (!Number.isFinite(totalRuntimeMs) || totalRuntimeMs <= (this.settings.totalRuntimeAllTimeMs || 0)) { + return false; + } + this.settings.totalRuntimeAllTimeMs = totalRuntimeMs; + this.runtimePersistedTotalMs = totalRuntimeMs; + this.runtimePersistedAt = now; + this.invalidateStatsCache(); + return true; + } + + public persistRuntimeStats(sync = false): void { + if (this.blockAllPersistence) { + return; + } + const now = nowMs(); + if (!this.foldRuntimeIntoSettings(now)) { + if (sync && !fs.existsSync(this.storagePaths.configFile)) { + saveSettings(this.storagePaths, this.settings); + } + return; + } + this.lastSettingsPersistAt = now; + if (sync) { + saveSettings(this.storagePaths, this.settings); + return; + } + void saveSettingsAsync(this.storagePaths, this.settings).catch((err) => logger.warn(`saveSettingsAsync Fehler: ${compactErrorText(err as Error)}`)); + } + + private invalidateStatsCache(): void { + this.statsCache = null; + this.statsCacheAt = 0; + } + + private resetSessionTotalsIfQueueEmpty(force = false): void { + if (this.itemCount > 0 || this.session.packageOrder.length > 0) { + return; + } + if (!force && (this.sessionDownloadedBytes > 0 || this.sessionCompletedFiles > 0 || this.itemContributedBytes.size > 0)) { + return; + } + let changed = false; + if (this.session.totalDownloadedBytes !== 0) { + this.session.totalDownloadedBytes = 0; + changed = true; + } + if (this.sessionDownloadedBytes !== 0) { + this.sessionDownloadedBytes = 0; + changed = true; + } + if (this.sessionCompletedFiles !== 0) { + this.sessionCompletedFiles = 0; + changed = true; + } + if (this.session.runStartedAt !== 0) { + this.session.runStartedAt = 0; + changed = true; + } + if (this.session.summaryText) { + this.session.summaryText = ""; + changed = true; + } + if (this.lastGlobalProgressBytes !== 0) { + this.lastGlobalProgressBytes = 0; + changed = true; + } + if (this.speedEvents.length > 0 || this.speedBytesLastWindow !== 0 || this.speedBytesPerPackage.size > 0) { + this.speedEvents = []; + this.speedBytesLastWindow = 0; + this.speedBytesPerPackage.clear(); + this.speedEventsHead = 0; + changed = true; + } + if (changed) { + this.invalidateStatsCache(); + } + } + + private getPackagePostProcessVersion(packageId: string): number { + return this.packagePostProcessVersions.get(packageId) || 0; + } + + private bumpPackagePostProcessVersion(packageId: string): number { + const next = this.getPackagePostProcessVersion(packageId) + 1; + this.packagePostProcessVersions.set(packageId, next); + return next; + } + + private abortPackagePostProcessing(packageId: string, reason: string, invalidateDeferred = true): void { + if (invalidateDeferred) { + this.bumpPackagePostProcessVersion(packageId); + } + + const postProcessController = this.packagePostProcessAbortControllers.get(packageId); + if (postProcessController && !postProcessController.signal.aborted) { + postProcessController.abort(reason); + } + this.packagePostProcessAbortControllers.delete(packageId); + this.packagePostProcessTasks.delete(packageId); + + const deferredController = this.packageDeferredPostProcessAbortControllers.get(packageId); + if (deferredController && !deferredController.signal.aborted) { + deferredController.abort(reason); + } + this.packageDeferredPostProcessAbortControllers.delete(packageId); + + const hybridSet = this.packageHybridPostProcessControllers.get(packageId); + if (hybridSet) { + for (const controller of hybridSet) { + if (!controller.signal.aborted) { + controller.abort(reason); + } + } + this.packageHybridPostProcessControllers.delete(packageId); + } + + this.hybridExtractRequeue.delete(packageId); + this.clearHybridArchiveState(packageId); + } + + private isDeferredPostProcessStillCurrent( + packageId: string, + pkg: PackageEntry, + version: number, + signal?: AbortSignal + ): boolean { + if (signal?.aborted) { + return false; + } + if (this.session.packages[packageId] !== pkg) { + return false; + } + return this.getPackagePostProcessVersion(packageId) === version; + } + + private throwIfDeferredPostProcessAborted( + packageId: string, + pkg: PackageEntry, + version: number, + signal?: AbortSignal + ): void { + if (this.isDeferredPostProcessStillCurrent(packageId, pkg, version, signal)) { + return; + } + throw new Error(String(signal?.reason || "aborted:deferred")); + } + + private packageOutputDirInUse(outputDir: string): boolean { + const key = pathKey(outputDir); + return Object.values(this.session.packages).some((pkg) => pathKey(pkg.outputDir) === key); + } + + public resetSessionStats(): void { + const now = nowMs(); + this.session.totalDownloadedBytes = 0; + this.sessionDownloadedBytes = 0; + this.sessionCompletedFiles = 0; + this.session.runStartedAt = this.session.running ? now : 0; + this.appSessionStartedAt = now; + this.session.summaryText = ""; + this.lastGlobalProgressBytes = 0; + this.lastGlobalProgressAt = now; + this.speedEvents = []; + this.speedEventsHead = 0; + this.speedBytesLastWindow = 0; + this.speedBytesPerPackage.clear(); + this.summary = null; + this.invalidateStatsCache(); + this.persistSoon(); + this.emitState(true); + } + + public resetDownloadStats(): void { + this.settings.totalDownloadedAllTime = 0; + this.settings.totalCompletedFilesAllTime = 0; + this.settings.providerTotalUsageBytes = {}; + this.settings.debridLinkApiKeyTotalUsageBytes = {}; + this.lastSettingsPersistAt = nowMs(); + saveSettings(this.storagePaths, this.settings); + this.invalidateStatsCache(); + this.emitState(true); + } + + public renamePackage(packageId: string, newName: string): void { + const pkg = this.session.packages[packageId]; + if (!pkg) { + return; + } + const previousName = pkg.name; + pkg.name = sanitizeFilename(newName) || pkg.name; + pkg.updatedAt = nowMs(); + this.logPackageForPackage(pkg, "INFO", "Paket umbenannt", { + oldName: previousName, + newName: pkg.name + }); + this.persistSoon(); + this.emitState(true); + } + + public reorderPackages(packageIds: string[]): void { + const seen = new Set<string>(); + const valid = packageIds.filter((id) => { + if (!this.session.packages[id] || seen.has(id)) { + return false; + } + seen.add(id); + return true; + }); + const remaining = this.session.packageOrder.filter((id) => !seen.has(id)); + this.session.packageOrder = [...valid, ...remaining]; + this.persistSoon(); + this.emitState(true); + } + + public removeItem(itemId: string): void { + const item = this.session.items[itemId]; + if (!item) { + return; + } + this.logPackageForItem(item, "WARN", "Item entfernt", { + url: item.url + }); + this.recordRunOutcome(itemId, "cancelled"); + const active = this.activeTasks.get(itemId); + const hasActiveTask = Boolean(active); + if (active) { + active.abortReason = "cancel"; + active.abortController.abort("cancel"); + } + const pkg = this.session.packages[item.packageId]; + let removedByPackageCleanup = false; + if (pkg) { + pkg.itemIds = pkg.itemIds.filter((id) => id !== itemId); + if (pkg.itemIds.length === 0) { + this.removePackageFromSession(item.packageId, [itemId]); + removedByPackageCleanup = true; + } else { + pkg.updatedAt = nowMs(); + } + } + if (!removedByPackageCleanup) { + delete this.session.items[itemId]; + this.itemCount = Math.max(0, this.itemCount - 1); + } + this.retryAfterByItem.delete(itemId); + this.retryStateByItem.delete(itemId); + this.dropItemContribution(itemId); + if (!hasActiveTask) { + this.releaseTargetPath(itemId); + } + this.persistSoon(); + this.emitState(true); + } + + public togglePackage(packageId: string): void { + const pkg = this.session.packages[packageId]; + if (!pkg) { + return; + } + + const nextEnabled = !pkg.enabled; + pkg.enabled = nextEnabled; + + if (!nextEnabled) { + if (pkg.status === "downloading" || pkg.status === "extracting") { + pkg.status = "paused"; + } + this.abortPackagePostProcessing(packageId, "package_toggle"); + for (const itemId of pkg.itemIds) { + const item = this.session.items[itemId]; + if (!item) { + continue; + } + if (this.session.running && !isFinishedStatus(item.status) && !this.runOutcomes.has(itemId)) { + this.runItemIds.delete(itemId); + } + const active = this.activeTasks.get(itemId); + if (active) { + active.abortReason = "package_toggle"; + active.abortController.abort("package_toggle"); + continue; + } + if (item.status === "queued" || item.status === "reconnect_wait") { + item.status = "queued"; + item.speedBps = 0; + item.fullStatus = "Paket gestoppt"; + item.updatedAt = nowMs(); + } + } + this.runPackageIds.delete(packageId); + this.runCompletedPackages.delete(packageId); + } else { + if (pkg.status === "paused") { + pkg.status = "queued"; + } + let hasReactivatedRunItems = false; + for (const itemId of pkg.itemIds) { + const item = this.session.items[itemId]; + if (!item) { + continue; + } + if (this.session.running && !isFinishedStatus(item.status)) { + this.runOutcomes.delete(itemId); + this.runItemIds.add(itemId); + hasReactivatedRunItems = true; + } + if (item.status === "queued" && item.fullStatus === "Paket gestoppt") { + item.fullStatus = "Wartet"; + item.updatedAt = nowMs(); + } + } + if (this.session.running) { + if (hasReactivatedRunItems) { + this.runPackageIds.add(packageId); + } + void this.ensureScheduler().catch((err) => logger.warn(`ensureScheduler Fehler (togglePackage): ${compactErrorText(err)}`)); + } + } + + pkg.updatedAt = nowMs(); + this.persistSoon(); + this.emitState(true); + } + + public exportQueue(): string { + const exportData = { + version: 1, + packages: this.session.packageOrder.map((id) => { + const pkg = this.session.packages[id]; + if (!pkg) { + return null; + } + const entries = pkg.itemIds + .map((itemId) => this.session.items[itemId]) + .filter((item): item is DownloadItem => Boolean(item && item.url)); + return { + name: pkg.name, + links: entries.map((item) => item.url), + fileNames: entries.map((item) => item.fileName || "") + }; + }).filter(Boolean) + }; + return JSON.stringify(exportData, null, 2); + } + + public importQueue(json: string): { addedPackages: number; addedLinks: number } { + const trimmed = String(json || "").trim(); + if (trimmed.startsWith("{") || trimmed.startsWith("[")) { + let data: { packages?: Array<{ name: string; links: string[]; fileNames?: string[] }> }; + try { + data = JSON.parse(json) as { packages?: Array<{ name: string; links: string[]; fileNames?: string[] }> }; + } catch { + throw new Error("Ungultige Queue-Datei (JSON)"); + } + if (!Array.isArray(data.packages)) { + return { addedPackages: 0, addedLinks: 0 }; + } + const inputs: ParsedPackageInput[] = data.packages + .map((pkg) => { + const name = typeof pkg?.name === "string" ? pkg.name : ""; + const linksRaw = Array.isArray(pkg?.links) ? pkg.links : []; + const fileNamesRaw = Array.isArray(pkg?.fileNames) ? pkg.fileNames : []; + const entries = linksRaw + .map((link, index) => ({ + link: typeof link === "string" ? link.trim() : "", + fileName: typeof fileNamesRaw[index] === "string" ? fileNamesRaw[index].trim() : "" + })) + .filter((entry) => entry.link.length > 0); + const links = entries.map((entry) => entry.link); + const fileNames = entries.map((entry) => entry.fileName); + return { + name, + links, + ...(fileNames.some((fileName) => fileName.length > 0) ? { fileNames } : {}) + }; + }) + .filter((pkg) => pkg.name.trim().length > 0 && pkg.links.length > 0); + return this.addPackages(inputs); + } + + const inputs = parseCollectorInput(json, ""); + if (inputs.length === 0) { + return { addedPackages: 0, addedLinks: 0 }; + } + return this.addPackages(inputs); + } + + public clearAll(): void { + this.clearPersistTimer(); + this.stop(); + this.abortPostProcessing("clear_all"); + if (this.stateEmitTimer) { + clearTimeout(this.stateEmitTimer); + this.stateEmitTimer = null; + } + this.session.packageOrder = []; + this.session.packages = {}; + this.session.items = {}; + this.itemCount = 0; + this.session.summaryText = ""; + this.runItemIds.clear(); + this.runPackageIds.clear(); + this.runOutcomes.clear(); + this.runCompletedPackages.clear(); + this.historyRecordedPackages.clear(); + this.notifiedPackages.clear(); + this.retryAfterByItem.clear(); + this.providerStartReservations.clear(); + this.pacedStartReservationByItem.clear(); + this.retryStateByItem.clear(); + this.reservedTargetPaths.clear(); + this.claimedTargetPathByItem.clear(); + this.itemContributedBytes.clear(); + this.speedEvents = []; + this.speedEventsHead = 0; + this.speedBytesLastWindow = 0; + this.speedBytesPerPackage.clear(); + this.packagePostProcessTasks.clear(); + this.packagePostProcessAbortControllers.clear(); + this.packageDeferredPostProcessAbortControllers.clear(); + this.packageHybridPostProcessControllers.clear(); + this.hybridExtractRequeue.clear(); + this.hybridExtractedPaths.clear(); + this.hybridFailedArchives.clear(); + this.providerFailures.clear(); + this.packagePostProcessQueue = Promise.resolve(); + this.packagePostProcessActive = 0; + for (const waiter of this.packagePostProcessWaiters) { waiter.resolve(); } + this.packagePostProcessWaiters = []; + this.summary = null; + this.nonResumableActive = 0; + this.resetSessionTotalsIfQueueEmpty(true); + this.persistNow(); + this.emitState(true); + } + + public addPackages(packages: ParsedPackageInput[]): { addedPackages: number; addedLinks: number } { + let addedPackages = 0; + let addedLinks = 0; + const unresolvedByLink = new Map<string, string[]>(); + const newItemIds: string[] = []; + for (const pkg of packages) { + const links = pkg.links.filter((link) => !!link.trim()); + if (links.length === 0) { + continue; + } + const packageId = uuidv4(); + const safeName = sanitizeFilename(pkg.name); + const outputDir = ensureDirPath(this.settings.outputDir, safeName); + const extractBase = this.settings.extractDir || path.join(this.settings.outputDir, "_entpackt"); + const extractDir = this.settings.createExtractSubfolder ? ensureDirPath(extractBase, safeName) : extractBase; + const packageEntry: PackageEntry = { + id: packageId, + name: safeName, + outputDir, + extractDir, + status: "queued", + itemIds: [], + cancelled: false, + enabled: true, + priority: "normal", + downloadStartedAt: 0, + downloadCompletedAt: 0, + createdAt: nowMs(), + updatedAt: nowMs() + }; + this.ensurePackageLogForPackage(packageEntry); + this.logPackageForPackage(packageEntry, "INFO", "Paket angelegt", { + outputDir, + extractDir, + linkCount: links.length + }); + + const registeredLinkSummary: string[] = []; + for (let linkIdx = 0; linkIdx < links.length; linkIdx += 1) { + const link = links[linkIdx]; + const itemId = uuidv4(); + const hintName = pkg.fileNames?.[linkIdx]; + const fileName = (hintName && !looksLikeOpaqueFilename(hintName)) ? sanitizeFilename(hintName) : filenameFromUrl(link); + const item: DownloadItem = { + id: itemId, + packageId, + url: link, + provider: null, + status: "queued", + retries: 0, + speedBps: 0, + downloadedBytes: 0, + totalBytes: null, + progressPercent: 0, + fileName, + targetPath: "", + resumable: true, + attempts: 0, + lastError: "", + fullStatus: "Wartet", + createdAt: nowMs(), + updatedAt: nowMs() + }; + this.assignItemTargetPath(item, path.join(outputDir, fileName)); + registeredLinkSummary.push(`#${linkIdx + 1} ${fileName} <- ${link}`); + packageEntry.itemIds.push(itemId); + this.session.items[itemId] = item; + this.itemCount += 1; + if (this.session.running) { + this.runItemIds.add(itemId); + this.runPackageIds.add(packageId); + } + if (looksLikeOpaqueFilename(fileName)) { + const existing = unresolvedByLink.get(link) ?? []; + existing.push(itemId); + unresolvedByLink.set(link, existing); + } + newItemIds.push(itemId); + addedLinks += 1; + } + + if (registeredLinkSummary.length > 0) { + const PREVIEW = 20; + const linksField = registeredLinkSummary.length <= 50 + ? registeredLinkSummary.join(" | ") + : `${registeredLinkSummary.slice(0, PREVIEW).join(" | ")} | ... +${registeredLinkSummary.length - PREVIEW} more`; + this.logPackageForPackage(packageEntry, "INFO", `Links registriert (${registeredLinkSummary.length})`, { + links: linksField + }); + } + + this.session.packages[packageId] = packageEntry; + this.session.packageOrder.push(packageId); + addedPackages += 1; + } + + if (addedPackages > 0 || addedLinks > 0) { + const pkgNames = packages.filter((p) => p.links.length > 0).map((p) => p.name).join(", "); + logger.info(`Pakete hinzugefügt: ${addedPackages} Paket(e), ${addedLinks} Link(s) [${pkgNames}]`); + } + this.persistSoon(); + this.emitState(); + if (unresolvedByLink.size > 0) { + void this.resolveQueuedFilenames(unresolvedByLink).catch((err) => logger.warn(`resolveQueuedFilenames Fehler (addPackages): ${compactErrorText(err)}`)); + } + if (newItemIds.length > 0) { + void this.checkRapidgatorLinks(newItemIds).catch((err) => logger.warn(`checkRapidgatorLinks Fehler: ${compactErrorText(err)}`)); + } + return { addedPackages, addedLinks }; + } + + public async getStartConflicts(): Promise<StartConflictEntry[]> { + const hasFilesByExtractDir = new Map<string, boolean>(); + const conflicts: StartConflictEntry[] = []; + for (const packageId of this.session.packageOrder) { + const pkg = this.session.packages[packageId]; + if (!pkg || pkg.cancelled || !pkg.enabled) { + continue; + } + + const hasPendingItems = pkg.itemIds.some((itemId) => { + const item = this.session.items[itemId]; + if (!item) { + return false; + } + return item.status === "queued" || item.status === "reconnect_wait"; + }); + if (!hasPendingItems) { + continue; + } + + const hasOwnCompletedOutput = pkg.itemIds.some((itemId) => { + const item = this.session.items[itemId]; + return Boolean(item && item.status === "completed"); + }); + if (hasOwnCompletedOutput) { + continue; + } + + if (!this.isPackageSpecificExtractDir(pkg)) { + continue; + } + + const extractDirKey = pathKey(pkg.extractDir); + const hasExtractedFiles = hasFilesByExtractDir.has(extractDirKey) + ? Boolean(hasFilesByExtractDir.get(extractDirKey)) + : await this.directoryHasAnyFiles(pkg.extractDir); + if (!hasFilesByExtractDir.has(extractDirKey)) { + hasFilesByExtractDir.set(extractDirKey, hasExtractedFiles); + } + + if (hasExtractedFiles) { + conflicts.push({ + packageId: pkg.id, + packageName: pkg.name, + extractDir: pkg.extractDir + }); + } + } + return conflicts; + } + + public async resolveStartConflict(packageId: string, policy: DuplicatePolicy): Promise<StartConflictResolutionResult> { + const pkg = this.session.packages[packageId]; + if (!pkg || pkg.cancelled) { + return { skipped: false, overwritten: false }; + } + + if (policy === "skip") { + let hadPendingItems = false; + for (const itemId of pkg.itemIds) { + const item = this.session.items[itemId]; + if (!item) { + continue; + } + if (item.status === "queued" || item.status === "reconnect_wait") { + hadPendingItems = true; + } + + const active = this.activeTasks.get(itemId); + if (active) { + active.abortReason = "package_toggle"; + active.abortController.abort("package_toggle"); + } + + if (item.status === "queued" || item.status === "reconnect_wait") { + item.status = "queued"; + item.speedBps = 0; + item.lastError = ""; + item.fullStatus = "Wartet"; + item.updatedAt = nowMs(); + } + + this.runItemIds.delete(itemId); + this.runOutcomes.delete(itemId); + + this.retryAfterByItem.delete(itemId); + this.retryStateByItem.delete(itemId); + } + + this.abortPackagePostProcessing(packageId, "skip"); + + this.runPackageIds.delete(packageId); + this.runCompletedPackages.delete(packageId); + + const items = pkg.itemIds + .map((itemId) => this.session.items[itemId]) + .filter(Boolean) as DownloadItem[]; + const hasPendingNow = items.some((item) => item.status === "queued" || item.status === "reconnect_wait"); + if (hadPendingItems || hasPendingNow) { + pkg.status = pkg.enabled ? "queued" : "paused"; + } + pkg.updatedAt = nowMs(); + + this.persistSoon(); + this.emitState(true); + + const pkgItemIds = [...pkg.itemIds]; + queueMicrotask(() => { + for (const iid of pkgItemIds) { + const it = this.session.items[iid]; + if (it && it.status === "queued" && it.fullStatus === "Paket gestoppt") { + it.fullStatus = "Wartet"; + it.updatedAt = nowMs(); + } + } + this.emitState(true); + }); + + return { skipped: true, overwritten: false }; + } + + if (policy === "overwrite") { + this.abortPackagePostProcessing(packageId, "overwrite"); + const canDeleteExtractDir = this.isPackageSpecificExtractDir(pkg) && !this.isExtractDirSharedWithOtherPackages(pkg.id, pkg.extractDir); + if (canDeleteExtractDir) { + try { + await fs.promises.rm(pkg.extractDir, { recursive: true, force: true }); + } catch { + } + } + try { + await fs.promises.rm(pkg.outputDir, { recursive: true, force: true }); + } catch { + } + + for (const itemId of pkg.itemIds) { + const item = this.session.items[itemId]; + if (!item) { + continue; + } + const active = this.activeTasks.get(itemId); + if (active) { + active.abortReason = "reset"; + active.abortController.abort("reset"); + } + this.releaseTargetPath(itemId); + item.status = "queued"; + item.retries = 0; + item.speedBps = 0; + item.downloadedBytes = 0; + item.totalBytes = null; + item.progressPercent = 0; + item.resumable = true; + item.attempts = 0; + item.lastError = ""; + item.fullStatus = "Wartet"; + item.provider = null; + item.updatedAt = nowMs(); + this.assignItemTargetPath(item, path.join(pkg.outputDir, sanitizeFilename(item.fileName || filenameFromUrl(item.url)))); + this.runOutcomes.delete(itemId); + this.dropItemContribution(itemId); + this.retryAfterByItem.delete(itemId); + this.retryStateByItem.delete(itemId); + if (this.session.running) { + this.runItemIds.add(itemId); + } + } + this.runCompletedPackages.delete(packageId); + if (this.session.running) { + this.runPackageIds.add(packageId); + } + pkg.status = "queued"; + pkg.updatedAt = nowMs(); + this.persistSoon(); + this.emitState(true); + void this.ensureScheduler().catch((err) => logger.warn(`ensureScheduler Fehler (resolveStartConflict): ${compactErrorText(err)}`)); + return { skipped: false, overwritten: true }; + } + + return { skipped: false, overwritten: false }; + } + + private isPackageSpecificExtractDir(pkg: PackageEntry): boolean { + const expectedName = sanitizeFilename(pkg.name).toLowerCase(); + if (!expectedName) { + return false; + } + return path.basename(pkg.extractDir).toLowerCase() === expectedName; + } + + private isExtractDirSharedWithOtherPackages(packageId: string, extractDir: string): boolean { + const key = pathKey(extractDir); + for (const otherId of this.session.packageOrder) { + if (otherId === packageId) { + continue; + } + const other = this.session.packages[otherId]; + if (!other || other.cancelled) { + continue; + } + if (pathKey(other.extractDir) === key) { + return true; + } + } + return false; + } + + private async resolveQueuedFilenames(unresolvedByLink: Map<string, string[]>): Promise<void> { + try { + let changed = false; + const applyResolvedName = (link: string, fileName: string): void => { + const itemIds = unresolvedByLink.get(link); + if (!itemIds || itemIds.length === 0) { + return; + } + if (!fileName || fileName.toLowerCase() === "download.bin") { + return; + } + const normalized = sanitizeFilename(fileName); + if (!normalized || normalized.toLowerCase() === "download.bin") { + return; + } + + let changedForLink = false; + for (const itemId of itemIds) { + const item = this.session.items[itemId]; + if (!item) { + continue; + } + if (!looksLikeOpaqueFilename(item.fileName)) { + continue; + } + if (item.status !== "queued" && item.status !== "reconnect_wait") { + continue; + } + item.fileName = normalized; + this.assignItemTargetPath(item, path.join(this.session.packages[item.packageId]?.outputDir || this.settings.outputDir, normalized)); + item.updatedAt = nowMs(); + changed = true; + changedForLink = true; + } + + if (changedForLink) { + this.persistSoon(); + this.emitState(); + } + }; + + await this.debridService.resolveFilenames(Array.from(unresolvedByLink.keys()), applyResolvedName); + + if (changed) { + this.persistSoon(); + this.emitState(); + } + } catch (error) { + logger.warn(`Dateinamen-Resolve fehlgeschlagen: ${compactErrorText(error)}`); + } + } + + private async checkRapidgatorLinks(itemIds: string[]): Promise<void> { + const itemsToCheck: Array<{ itemId: string; url: string }> = []; + + for (const itemId of itemIds) { + const item = this.session.items[itemId]; + if (!item || item.status !== "queued") continue; + item.onlineStatus = "checking"; + itemsToCheck.push({ itemId, url: item.url }); + } + if (itemsToCheck.length > 0) { + this.emitState(); + } + + const checkedUrls = new Map<string, Awaited<ReturnType<typeof checkRapidgatorOnline>>>(); + + for (const { itemId, url } of itemsToCheck) { + const item = this.session.items[itemId]; + if (!item) continue; + + if (checkedUrls.has(url)) { + const cached = checkedUrls.get(url); + if (cached !== undefined) { + this.applyRapidgatorCheckResult(item, cached); + } + this.emitState(); + continue; + } + + try { + const result = await checkRapidgatorOnline(url); + checkedUrls.set(url, result); + this.applyRapidgatorCheckResult(item, result); + } catch (err) { + logger.warn(`checkRapidgatorOnline Fehler für ${url}: ${compactErrorText(err)}`); + item.onlineStatus = undefined; + } + this.emitState(); + } + + this.persistSoon(); + } + + private resolveExistingQueuedOpaqueFilenames(): void { + const unresolvedByLink = new Map<string, string[]>(); + for (const item of Object.values(this.session.items)) { + if (!looksLikeOpaqueFilename(item.fileName)) { + continue; + } + if (item.status !== "queued" && item.status !== "reconnect_wait") { + continue; + } + const pkg = this.session.packages[item.packageId]; + if (!pkg || pkg.cancelled) { + continue; + } + const existing = unresolvedByLink.get(item.url) ?? []; + existing.push(item.id); + unresolvedByLink.set(item.url, existing); + } + + if (unresolvedByLink.size > 0) { + void this.resolveQueuedFilenames(unresolvedByLink).catch((err) => logger.warn(`resolveQueuedFilenames Fehler (resolveExisting): ${compactErrorText(err)}`)); + } + } + + private applyRapidgatorCheckResult(item: DownloadItem, result: Awaited<ReturnType<typeof checkRapidgatorOnline>>): void { + if (!result) { + if (item.onlineStatus === "checking") { + item.onlineStatus = undefined; + } + return; + } + if (item.status !== "queued") return; + + if (!result.online) { + item.status = "failed"; + item.fullStatus = "Offline"; + item.lastError = "Datei nicht gefunden auf Rapidgator"; + item.onlineStatus = "offline"; + item.updatedAt = nowMs(); + if (this.runItemIds.has(item.id)) { + this.recordRunOutcome(item.id, "failed"); + } + const pkg = this.session.packages[item.packageId]; + if (pkg) this.refreshPackageStatus(pkg); + } else { + if (result.fileName && looksLikeOpaqueFilename(item.fileName)) { + item.fileName = sanitizeFilename(result.fileName); + this.assignItemTargetPath(item, path.join(this.session.packages[item.packageId]?.outputDir || this.settings.outputDir, item.fileName)); + } + item.onlineStatus = "online"; + item.updatedAt = nowMs(); + } + } + + private checkExistingRapidgatorLinks(): void { + const uncheckedIds: string[] = []; + for (const item of Object.values(this.session.items)) { + if (item.status !== "queued") continue; + if (item.onlineStatus) continue; + try { + const host = new URL(item.url).hostname.toLowerCase(); + if (host !== "rapidgator.net" && !host.endsWith(".rapidgator.net") && host !== "rg.to" && !host.endsWith(".rg.to")) continue; + } catch { continue; } + uncheckedIds.push(item.id); + } + if (uncheckedIds.length > 0) { + void this.checkRapidgatorLinks(uncheckedIds).catch((err) => logger.warn(`checkRapidgatorLinks Fehler (startup): ${compactErrorText(err)}`)); + } + } + + private async cleanupExistingExtractedArchives(): Promise<void> { + if (this.settings.cleanupMode === "none") { + return; + } + + const extractDirUsage = new Map<string, number>(); + for (const packageId of this.session.packageOrder) { + const pkg = this.session.packages[packageId]; + if (!pkg || pkg.cancelled || !pkg.extractDir) { + continue; + } + const key = pathKey(pkg.extractDir); + extractDirUsage.set(key, (extractDirUsage.get(key) || 0) + 1); + } + + const cleanupTargetsByPackage = new Map<string, Set<string>>(); + const dirFilesCache = new Map<string, string[]>(); + for (const packageId of this.session.packageOrder) { + const pkg = this.session.packages[packageId]; + if (!pkg || pkg.cancelled || pkg.status !== "completed") { + continue; + } + if (this.packagePostProcessTasks.has(packageId)) { + continue; + } + + const items = pkg.itemIds + .map((itemId) => this.session.items[itemId]) + .filter(Boolean) as DownloadItem[]; + if (items.length === 0 || !items.every((item) => item.status === "completed")) { + continue; + } + + const hasExtractErrors = items.some((item) => isExtractErrorLabel(item.fullStatus || "")); + if (hasExtractErrors) { + logger.info(`Nachträgliches Cleanup übersprungen: pkg=${pkg.name}, reason=extract_error_present`); + continue; + } + + const hasExtractMarker = items.some((item) => isExtractedLabel(item.fullStatus)); + const extractDirIsUnique = (extractDirUsage.get(pathKey(pkg.extractDir)) || 0) === 1; + const hasExtractedOutput = extractDirIsUnique && await this.directoryHasAnyFiles(pkg.extractDir); + if (!hasExtractMarker && !hasExtractedOutput) { + continue; + } + + const packageTargets = cleanupTargetsByPackage.get(packageId) ?? new Set<string>(); + for (const item of items) { + const rawTargetPath = String(item.targetPath || "").trim(); + const fallbackTargetPath = item.fileName ? path.join(pkg.outputDir, sanitizeFilename(item.fileName)) : ""; + const targetPath = rawTargetPath || fallbackTargetPath; + if (!targetPath || !isArchiveLikePath(targetPath)) { + continue; + } + const dir = path.dirname(targetPath); + let filesInDir = dirFilesCache.get(dir); + if (!filesInDir) { + try { + filesInDir = (await fs.promises.readdir(dir, { withFileTypes: true })) + .filter((entry) => entry.isFile()) + .map((entry) => entry.name); + } catch { + filesInDir = []; + } + dirFilesCache.set(dir, filesInDir); + } + + for (const cleanupTarget of collectArchiveCleanupTargets(targetPath, filesInDir)) { + packageTargets.add(cleanupTarget); + } + } + if (packageTargets.size > 0) { + cleanupTargetsByPackage.set(packageId, packageTargets); + } + } + + if (cleanupTargetsByPackage.size === 0) { + return; + } + + this.cleanupQueue = this.cleanupQueue + .then(async () => { + for (const [packageId, targets] of cleanupTargetsByPackage.entries()) { + const pkg = this.session.packages[packageId]; + if (!pkg) { + continue; + } + + logger.info(`Nachträgliches Cleanup geprüft: pkg=${pkg.name}, targets=${targets.size}, marker=${pkg.itemIds.some((id) => isExtractedLabel(this.session.items[id]?.fullStatus || ""))}`); + + let removed = 0; + for (const targetPath of targets) { + if (!await this.existsAsync(targetPath)) { + continue; + } + try { + await fs.promises.rm(targetPath, { force: true }); + removed += 1; + } catch { + } + } + + if (removed > 0) { + logger.info(`Nachträgliches Archive-Cleanup für ${pkg.name}: ${removed} Datei(en) gelöscht`); + if (!await this.directoryHasAnyFiles(pkg.outputDir)) { + const removedDirs = await this.removeEmptyDirectoryTree(pkg.outputDir); + if (removedDirs > 0) { + logger.info(`Nachträgliches Cleanup entfernte leere Download-Ordner für ${pkg.name}: ${removedDirs}`); + } + } + } else { + logger.info(`Nachträgliches Archive-Cleanup für ${pkg.name}: keine Dateien entfernt`); + } + } + }) + .catch((error) => { + logger.warn(`Nachträgliches Archive-Cleanup fehlgeschlagen: ${compactErrorText(error)}`); + }); + } + + private async directoryHasAnyFiles(rootDir: string): Promise<boolean> { + if (!rootDir) { + return false; + } + try { + await fs.promises.access(rootDir); + } catch { + return false; + } + const deadline = nowMs() + 55; + let inspectedDirs = 0; + const stack = [rootDir]; + while (stack.length > 0) { + inspectedDirs += 1; + if (inspectedDirs > 6000 || nowMs() > deadline) { + return true; + } + const current = stack.pop() as string; + let entries: fs.Dirent[] = []; + try { + entries = await fs.promises.readdir(current, { withFileTypes: true }); + } catch { + continue; + } + + for (const entry of entries) { + if (entry.isFile() && !isIgnorableEmptyDirFileName(entry.name)) { + return true; + } + if (entry.isDirectory()) { + stack.push(path.join(current, entry.name)); + } + } + } + return false; + } + + private async removeEmptyDirectoryTree(rootDir: string): Promise<number> { + if (!rootDir) { + return 0; + } + try { + await fs.promises.access(rootDir); + } catch { + return 0; + } + + const dirs = [rootDir]; + const stack = [rootDir]; + while (stack.length > 0) { + const current = stack.pop() as string; + let entries: fs.Dirent[] = []; + try { + entries = await fs.promises.readdir(current, { withFileTypes: true }); + } catch { + continue; + } + for (const entry of entries) { + if (entry.isDirectory()) { + const full = path.join(current, entry.name); + dirs.push(full); + stack.push(full); + } + } + } + + dirs.sort((a, b) => b.length - a.length); + let removed = 0; + for (const dirPath of dirs) { + try { + let entries = await fs.promises.readdir(dirPath, { withFileTypes: true }); + for (const entry of entries) { + if (!entry.isFile() || !isIgnorableEmptyDirFileName(entry.name)) { + continue; + } + try { + await fs.promises.rm(path.join(dirPath, entry.name), { force: true }); + } catch { + } + } + + entries = await fs.promises.readdir(dirPath, { withFileTypes: true }); + if (entries.length === 0) { + await fs.promises.rmdir(dirPath); + removed += 1; + } + } catch { + } + } + return removed; + } + + private async collectFilesByExtensions(rootDir: string, extensions: Set<string>): Promise<string[]> { + if (!rootDir || extensions.size === 0) { + return []; + } + try { + await fs.promises.access(rootDir); + } catch { + return []; + } + + const normalizedExtensions = new Set<string>(); + for (const extension of extensions) { + const normalized = String(extension || "").trim().toLowerCase(); + if (normalized) { + normalizedExtensions.add(normalized); + } + } + if (normalizedExtensions.size === 0) { + return []; + } + + const files: string[] = []; + const stack = [rootDir]; + while (stack.length > 0) { + const current = stack.pop() as string; + let entries: fs.Dirent[] = []; + try { + entries = await fs.promises.readdir(current, { withFileTypes: true }); + } catch { + continue; + } + + for (const entry of entries) { + const fullPath = path.join(current, entry.name); + if (entry.isSymbolicLink()) { + continue; + } + if (entry.isDirectory()) { + stack.push(fullPath); + continue; + } + if (!entry.isFile()) { + continue; + } + // Never collect our own remux temp/orphan sidecars (~rd<token>.<ext>): a + // partial file left by a crash mid-remux must not be swept into the library. + if (entry.name.startsWith("~rd")) { + continue; + } + const extension = path.extname(entry.name).toLowerCase(); + if (!normalizedExtensions.has(extension)) { + continue; + } + files.push(fullPath); + } + } + + return files; + } + + private async collectVideoFiles(rootDir: string): Promise<string[]> { + return await this.collectFilesByExtensions(rootDir, SAMPLE_VIDEO_EXTENSIONS); + } + + private async existsAsync(filePath: string): Promise<boolean> { + try { + await fs.promises.access(toWindowsLongPathIfNeeded(filePath)); + return true; + } catch { + return false; + } + } + + private logVerifiedRenameSync(label: string, sourcePath: string, targetPath: string, extraFields?: Record<string, unknown>): void { + const v = verifyRename(sourcePath, targetPath); + logDesktopRename(v.level, `${label}: ${verifyHeadline(v)}`, { + ...(extraFields || {}), + source: path.basename(sourcePath), + requested: path.basename(targetPath), + onDisk: v.onDiskName, + targetDir: path.dirname(targetPath), + sourceGone: v.sourceGone, + sizeBytes: v.targetSize, + ...(v.ok ? {} : { grund: v.reason }) + }); + } + + private async renamePathWithExdevFallback(sourcePath: string, targetPath: string, ctx?: { label?: string; fields?: Record<string, unknown> }): Promise<void> { + const label = ctx?.label || "rename"; + try { + await this.renamePathWithExdevFallbackRaw(sourcePath, targetPath); + } catch (error) { + logDesktopRename("ERROR", `${label}: Rename fehlgeschlagen`, { + ...(ctx?.fields || {}), + source: path.basename(sourcePath), + sourceDir: path.dirname(sourcePath), + target: path.basename(targetPath), + error: compactErrorText(error) + }); + throw error; + } + const v = await verifyRenameAsync(sourcePath, targetPath); + logDesktopRename(v.level, `${label}: ${verifyHeadline(v)}`, { + ...(ctx?.fields || {}), + source: path.basename(sourcePath), + requested: path.basename(targetPath), + onDisk: v.onDiskName, + targetDir: path.dirname(targetPath), + sourceGone: v.sourceGone, + sizeBytes: v.targetSize, + ...(v.ok ? {} : { grund: v.reason }) + }); + } + + private async renamePathWithExdevFallbackRaw(sourcePath: string, targetPath: string): Promise<void> { + const sourceFsPath = toWindowsLongPathIfNeeded(sourcePath); + const targetFsPath = toWindowsLongPathIfNeeded(targetPath); + const TRANSIENT_RENAME_ERROR_CODES = new Set(["EBUSY", "EACCES", "EPERM", "EEXIST"]); + const RENAME_RETRY_DELAYS_MS = [200, 500, 1000]; + let lastError: unknown = null; + for (let attempt = 0; attempt <= RENAME_RETRY_DELAYS_MS.length; attempt += 1) { + try { + await fs.promises.rename(sourceFsPath, targetFsPath); + if (attempt > 0) { + logger.info(`Rename erfolgreich nach ${attempt} Retry(s): ${path.basename(sourcePath)}`); + } + return; + } catch (error) { + lastError = error; + const code = error && typeof error === "object" && "code" in error + ? String((error as NodeJS.ErrnoException).code || "") + : ""; + if (code === "EXDEV") { + break; + } + if (TRANSIENT_RENAME_ERROR_CODES.has(code) && attempt < RENAME_RETRY_DELAYS_MS.length) { + const delay = RENAME_RETRY_DELAYS_MS[attempt]; + logger.info(`Rename ${code} (vermutlich Antivirus/Indexer/Player), Retry in ${delay}ms: ${path.basename(sourcePath)}`); + await sleep(delay); + continue; + } + throw error; + } + } + + const lastCode = lastError && typeof lastError === "object" && "code" in lastError + ? String((lastError as NodeJS.ErrnoException).code || "") + : ""; + if (lastCode !== "EXDEV") { + throw lastError; + } + await fs.promises.copyFile(sourceFsPath, targetFsPath); + await fs.promises.rm(sourceFsPath, { force: true }); + } + + private async renameCompanionFiles( + sourceVideoPath: string, + targetVideoPath: string, + pkg?: PackageEntry + ): Promise<void> { + const COMPANION_EXTENSIONS = new Set([".srt", ".ass", ".ssa", ".sub", ".idx", ".vtt", ".smi", ".nfo"]); + const sourceDir = path.dirname(sourceVideoPath); + const targetDir = path.dirname(targetVideoPath); + const sourceVideoBase = path.basename(sourceVideoPath, path.extname(sourceVideoPath)); + const targetVideoBase = path.basename(targetVideoPath, path.extname(targetVideoPath)); + if (!sourceVideoBase || !targetVideoBase || sourceVideoBase === targetVideoBase) { + return; + } + let entries: fs.Dirent[]; + try { + entries = await fs.promises.readdir(sourceDir, { withFileTypes: true }); + } catch { + return; + } + for (const entry of entries) { + if (!entry.isFile() || entry.isSymbolicLink()) { + continue; + } + const entryName = entry.name; + const entryExt = path.extname(entryName).toLowerCase(); + if (!COMPANION_EXTENSIONS.has(entryExt)) { + continue; + } + const entryBase = path.basename(entryName, path.extname(entryName)); + const isExactMatch = entryBase === sourceVideoBase; + const isPrefixMatch = entryBase.startsWith(`${sourceVideoBase}.`); + if (!isExactMatch && !isPrefixMatch) { + continue; + } + const suffixAfterBase = isExactMatch ? "" : entryBase.slice(sourceVideoBase.length); + const newCompanionName = `${targetVideoBase}${suffixAfterBase}${entryExt}`; + const sourceCompanionPath = path.join(sourceDir, entryName); + const targetCompanionPath = path.join(targetDir, newCompanionName); + if (sourceCompanionPath === targetCompanionPath) { + continue; + } + try { + await this.renamePathWithExdevFallback(sourceCompanionPath, targetCompanionPath, { label: "companion" }); + logger.info(`Auto-Rename Companion: ${entryName} -> ${newCompanionName}`); + if (pkg) { + this.logPackageForPackage(pkg, "INFO", "Auto-Rename Companion umbenannt", { + source: sourceCompanionPath, + target: targetCompanionPath + }); + } + } catch (err) { + logger.warn(`Auto-Rename Companion fehlgeschlagen: ${entryName} -> ${newCompanionName}: ${compactErrorText(err as Error)}`); + } + } + } + + private async moveCompanionFiles( + sourceVideoPath: string, + targetVideoPath: string, + pkg?: PackageEntry + ): Promise<void> { + const COMPANION_EXTENSIONS = new Set([".srt", ".ass", ".ssa", ".sub", ".idx", ".vtt", ".smi"]); + const sourceDir = path.dirname(sourceVideoPath); + const targetDir = path.dirname(targetVideoPath); + const sourceVideoBase = path.basename(sourceVideoPath, path.extname(sourceVideoPath)); + const targetVideoBase = path.basename(targetVideoPath, path.extname(targetVideoPath)); + if (!sourceVideoBase || !targetVideoBase) { + return; + } + let entries: fs.Dirent[]; + try { + entries = await fs.promises.readdir(sourceDir, { withFileTypes: true }); + } catch { + return; + } + for (const entry of entries) { + if (!entry.isFile() || entry.isSymbolicLink()) { + continue; + } + const entryName = entry.name; + const entryExt = path.extname(entryName).toLowerCase(); + if (!COMPANION_EXTENSIONS.has(entryExt)) { + continue; + } + const entryBase = path.basename(entryName, path.extname(entryName)); + const isExactMatch = entryBase === sourceVideoBase; + const isPrefixMatch = entryBase.startsWith(`${sourceVideoBase}.`); + if (!isExactMatch && !isPrefixMatch) { + continue; + } + const suffixAfterBase = isExactMatch ? "" : entryBase.slice(sourceVideoBase.length); + const newCompanionName = `${targetVideoBase}${suffixAfterBase}${entryExt}`; + const sourceCompanionPath = path.join(sourceDir, entryName); + const targetCompanionPath = path.join(targetDir, newCompanionName); + if (sourceCompanionPath === targetCompanionPath) { + continue; + } + try { + await this.moveFileWithExdevFallback(sourceCompanionPath, targetCompanionPath); + logger.info(`MKV-Move Companion: ${entryName} -> ${newCompanionName}`); + if (pkg) { + this.logPackageForPackage(pkg, "INFO", "Companion mit-verschoben", { + source: sourceCompanionPath, + target: targetCompanionPath + }); + } + } catch (err) { + logger.warn(`MKV-Move Companion fehlgeschlagen: ${entryName}: ${compactErrorText(err as Error)}`); + } + } + } + + private isPathLengthRenameError(error: unknown): boolean { + const code = error && typeof error === "object" && "code" in error + ? String((error as NodeJS.ErrnoException).code || "") + : ""; + if (code === "ENAMETOOLONG") { + return true; + } + const text = String(error || "").toLowerCase(); + return text.includes("path too long") + || text.includes("name too long") + || text.includes("filename or extension is too long"); + } + + private buildSafeAutoRenameTargetPath(sourcePath: string, targetBaseName: string, sourceExt: string): string | null { + const dirPath = path.dirname(sourcePath); + const safeBaseName = sanitizeFilename(String(targetBaseName || "").trim()); + if (!safeBaseName) { + return null; + } + const safeExt = String(sourceExt || "").trim(); + + const candidatePath = path.join(dirPath, `${safeBaseName}${safeExt}`); + if (process.platform !== "win32") { + return candidatePath; + } + + const fileName = path.basename(candidatePath); + if (fileName.length > 255) { + return null; + } + + return candidatePath; + } + + private buildShortPackageFallbackBaseName(folderCandidates: string[], sourceBaseName: string, targetBaseName: string): string | null { + const normalizedCandidates = folderCandidates + .map((value) => String(value || "").trim()) + .filter((value) => value.length > 0); + const fallbackTemplate = [...normalizedCandidates].reverse().find((folderName) => { + return hasSceneGroupSuffix(folderName) && Boolean(extractSeasonToken(folderName)); + }) || ""; + if (!fallbackTemplate) { + return null; + } + + const seasonMatch = fallbackTemplate.match(/^(.*?)(?:[._\-\s])s(\d{1,2})(?=[._\-\s]|$)/i); + if (!seasonMatch?.[1] || !seasonMatch?.[2]) { + return null; + } + + const seasonNumber = Number(seasonMatch[2]); + if (!Number.isFinite(seasonNumber) || seasonNumber < 0) { + return null; + } + + const shortRootName = String(seasonMatch[1]).replace(/[._\-\s]+$/g, "").trim(); + if (!shortRootName) { + return null; + } + + let fallback = `${shortRootName}.S${String(seasonNumber).padStart(2, "0")}`; + const resolvedEpisode = resolveEpisodeTokenForAutoRename(sourceBaseName, normalizedCandidates); + if (resolvedEpisode && new RegExp(`^S${String(seasonNumber).padStart(2, "0")}E\\d{2,3}$`, "i").test(resolvedEpisode.token)) { + fallback = `${shortRootName}.${resolvedEpisode.token}`; + } + const hasRepackHint = sourceHasRpToken(sourceBaseName) + || SCENE_REPACK_TOKEN_RE.test(sourceBaseName) + || sourceHasRpToken(targetBaseName) + || SCENE_REPACK_TOKEN_RE.test(targetBaseName) + || folderCandidates.some((folderName) => sourceHasRpToken(folderName) || SCENE_REPACK_TOKEN_RE.test(folderName)); + if (hasRepackHint) { + fallback = ensureRepackToken(removeRpTokens(fallback)); + } + + const normalized = sanitizeFilename(fallback); + if (!normalized || normalized.toLowerCase() === String(targetBaseName || "").trim().toLowerCase()) { + return null; + } + return normalized; + } + + private buildVeryShortPackageFallbackBaseName(folderCandidates: string[], sourceBaseName: string, targetBaseName: string): string | null { + const base = this.buildShortPackageFallbackBaseName(folderCandidates, sourceBaseName, targetBaseName); + if (!base) { + return null; + } + const match = base.match(/^(.+?)[._\-\s](S\d{2})(?:\b|[._\-\s])/i) || base.match(/^(.+?)\.(S\d{2})$/i); + if (!match?.[1] || !match?.[2]) { + return null; + } + const firstToken = match[1].split(/[._\-\s]+/).filter(Boolean)[0] || ""; + if (!firstToken) { + return null; + } + const next = sanitizeFilename(`${firstToken}.${match[2].toUpperCase()}`); + if (!next || next.toLowerCase() === String(targetBaseName || "").trim().toLowerCase()) { + return null; + } + if (SCENE_REPACK_TOKEN_RE.test(base)) { + return ensureRepackToken(next); + } + return next; + } + + private chainPackageFileOp<T>(pkgId: string, fn: () => Promise<T>): Promise<T> { + const previous = this.packageFileOpChain.get(pkgId); + const result = (previous ?? Promise.resolve()).catch(() => undefined).then(fn); + this.packageFileOpChain.set(pkgId, result); + return result.finally(() => { + if (this.packageFileOpChain.get(pkgId) === result) { + this.packageFileOpChain.delete(pkgId); + } + }); + } + + private async autoRenameExtractedVideoFiles( + extractDir: string, + pkg?: PackageEntry, + shouldAbort?: () => boolean, + treatFilesAsStable = false + ): Promise<number> { + if (!pkg) { + return this.autoRenameExtractedVideoFilesImpl(extractDir, undefined, shouldAbort, treatFilesAsStable); + } + return this.chainPackageFileOp(pkg.id, () => + this.autoRenameExtractedVideoFilesImpl(extractDir, pkg, shouldAbort, treatFilesAsStable) + ); + } + + private async keepGermanAudioOnly( + extractDir: string, + pkg?: PackageEntry, + shouldAbort?: () => boolean, + signal?: AbortSignal + ): Promise<number> { + if (!pkg) { + return this.keepGermanAudioOnlyImpl(extractDir, undefined, shouldAbort, signal); + } + return this.chainPackageFileOp(pkg.id, () => + this.keepGermanAudioOnlyImpl(extractDir, pkg, shouldAbort, signal) + ); + } + + // Post-extract step: for ".DL." (Dual-Language) MKV/MP4 files, keep only the + // German audio track and strip the ".DL." marker from the filename. Operates + // only inside pkg.extractDir, before MKV-collect. Best-effort per file; an + // error never fails the package. Original is never lost (see video-processor). + private async keepGermanAudioOnlyImpl( + extractDir: string, + pkg?: PackageEntry, + shouldAbort?: () => boolean, + signal?: AbortSignal + ): Promise<number> { + if (!this.settings.keepGermanAudioOnly) { + return 0; + } + const mkvLibraryDir = String(this.settings.mkvLibraryDir || "").trim(); + if (mkvLibraryDir && (isPathInsideDir(mkvLibraryDir, extractDir) || isPathInsideDir(extractDir, mkvLibraryDir))) { + logger.warn(`Tonspur-Bereinigung ABGEBROCHEN: extractDir=${extractDir} ueberlappt mit mkvLibraryDir=${mkvLibraryDir}`); + return 0; + } + + const videoFiles = await this.collectVideoFiles(extractDir); + const sampleTokenRe = /(^|[._\-\s])sample([._\-\s]|$)/i; + const targets = videoFiles.filter((p) => { + const name = path.basename(p); + if (!isRemuxableVideoFile(name) || !hasDualLangMarker(name)) { + return false; + } + if (sampleTokenRe.test(name) || ["sample", "samples"].includes(path.basename(path.dirname(p)).toLowerCase())) { + return false; + } + return true; + }); + if (targets.length === 0) { + return 0; + } + logger.info(`Tonspur-Bereinigung: ${targets.length} .DL.-Datei(en) in ${extractDir}, Modus=${this.settings.germanAudioMode}`); + if (pkg) { + this.logRenameProcess(pkg, "INFO", "audio-strip", "Tonspur-Bereinigung gestartet", { extractDir, candidates: targets.length, mode: this.settings.germanAudioMode }); + } + + const summary: AudioStripSummary = { + at: nowMs(), + candidates: targets.length, + remuxed: 0, + keptSingle: 0, + skippedNoGerman: 0, + skippedNoTool: 0, + failed: 0, + files: [] + }; + const recordFile = (name: string, action: string, reason: string, languages?: string): void => { + if (summary.files.length < 100) { + summary.files.push({ name, action, reason, ...(languages ? { languages } : {}) }); + } + }; + const writeSummary = (): void => { + if (pkg) { + pkg.audioStripSummary = summary; + pkg.updatedAt = nowMs(); + } + }; + + // Resolve ffmpeg/ffprobe ONCE up front and log it loudly — a missing tool is + // the single most common reason the whole step silently does nothing. + const tooling = await resolveVideoTooling(); + if (!tooling) { + logger.warn(`Tonspur-Bereinigung: ffmpeg/ffprobe NICHT gefunden — Schritt uebersprungen, ${targets.length} Datei(en) unangetastet. ffmpeg in den PATH legen oder RD_FFMPEG_BIN/RD_FFPROBE_BIN setzen.`); + if (pkg) { + this.logRenameProcess(pkg, "WARN", "audio-strip", "Tonspur-Bereinigung uebersprungen: ffmpeg/ffprobe nicht gefunden", { candidates: targets.length }); + } + summary.skippedNoTool = targets.length; + for (const p of targets) { + recordFile(path.basename(p), "skipped-no-tool", "ffmpeg/ffprobe nicht gefunden"); + } + writeSummary(); + return 0; + } + logger.info(`Tonspur-Bereinigung: ffmpeg=${tooling.ffmpeg} ffprobe=${tooling.ffprobe}`); + + const mode: GermanAudioMode = this.settings.germanAudioMode === "first" ? "first" : "tag"; + let processed = 0; + let failed = 0; + for (const sourcePath of targets) { + if (shouldAbort?.() || signal?.aborted) { + return processed; + } + const sourceName = path.basename(sourcePath); + let result: VideoProcessResult; + try { + result = await processVideoFile(sourcePath, { mode, cpuPriority: this.settings.extractCpuPriority, signal }); + } catch (error) { + result = { action: "error", reason: "exception", error: compactErrorText(error) }; + } + if (result.action === "aborted") { + return processed; + } + const langs = (result.audioLanguages || []).join(","); + if (pkg) { + const level = result.action === "error" ? "WARN" : "INFO"; + const resolved = this.inferItemForMediaLog(pkg, sourcePath, sourceName); + this.logRenameProcess(pkg, level, "audio-strip", `Tonspur: ${result.action} (${result.reason})`, { + sourceName, + keptTrack: result.keptTrackIndex, + audioTracks: result.totalAudioTracks, + languages: langs || undefined, + ...(result.error ? { error: result.error } : {}) + }, resolved.item, resolved.matchedBy); + } + recordFile(sourceName, result.action, result.error ? `${result.reason} — ${result.error}` : result.reason, langs || undefined); + // Per-file main-log lines so the cause of any unprocessed file is visible + // without opening the rename/item logs. + if (result.action === "error") { + failed += 1; + summary.failed += 1; + logger.warn(`Tonspur-Bereinigung FEHLER: ${sourceName} — ${result.reason}${result.error ? ` — ${result.error}` : ""} (Spuren: ${langs || "?"}, ${result.totalAudioTracks ?? "?"} Audio)`); + } else if (result.action === "remuxed") { + processed += 1; + summary.remuxed += 1; + logger.info(`Tonspur-Bereinigung OK: ${sourceName} — Spur ${result.keptTrackIndex} behalten (${langs || "?"})`); + } else if (result.action === "kept-single") { + summary.keptSingle += 1; + } else if (result.action === "skipped-no-german") { + summary.skippedNoGerman += 1; + logger.info(`Tonspur-Bereinigung uebersprungen (kein Deutsch-Tag, Spuren: ${langs || "?"}): ${sourceName}`); + } else if (result.action === "skipped-no-space") { + summary.failed += 1; + logger.warn(`Tonspur-Bereinigung uebersprungen (zu wenig Speicher): ${sourceName}`); + } else if (result.action === "skipped-no-tool") { + summary.skippedNoTool += 1; + } else { + summary.failed += 1; + } + // Only strip ".DL." once the file is confirmed German-only (remuxed) or + // already single-track. Skips/errors leave the file fully untouched so the + // unprocessed state stays visible. + if (result.action === "remuxed" || result.action === "kept-single") { + await this.stripDualLangFromFileName(sourcePath, pkg); + } + } + writeSummary(); + logger.info(`Tonspur-Bereinigung fertig: ${processed} verarbeitet, ${failed} Fehler von ${targets.length} Kandidaten in ${extractDir}`); + if (pkg) { + this.logRenameProcess(pkg, failed > 0 ? "WARN" : "INFO", "audio-strip", "Tonspur-Bereinigung fertig", { processed, failed, candidates: targets.length }); + } + return processed; + } + + private async stripDualLangFromFileName(sourcePath: string, pkg?: PackageEntry): Promise<void> { + const dir = path.dirname(sourcePath); + const name = path.basename(sourcePath); + const newName = stripDualLangMarker(name); + if (newName === name) { + return; + } + const targetPath = path.join(dir, newName); + if (pathKey(targetPath) !== pathKey(sourcePath) && await this.existsAsync(targetPath)) { + logger.warn(`.DL.-Strip uebersprungen (Ziel existiert): ${newName}`); + return; + } + try { + await this.renamePathWithExdevFallback(sourcePath, targetPath, { label: "audio-strip" }); + await this.renameCompanionFiles(sourcePath, targetPath, pkg); + if (pkg) { + const resolved = this.inferItemForMediaLog(pkg, targetPath, path.basename(targetPath)); + this.logRenameProcess(pkg, "INFO", "audio-strip", ".DL. aus Dateiname entfernt", { sourcePath, targetPath }, resolved.item, resolved.matchedBy); + } + } catch (error) { + logger.warn(`.DL.-Strip fehlgeschlagen (${name}): ${compactErrorText(error)}`); + } + } + + private async autoRenameExtractedVideoFilesImpl( + extractDir: string, + pkg?: PackageEntry, + shouldAbort?: () => boolean, + treatFilesAsStable = false + ): Promise<number> { + if (!this.settings.autoRename4sf4sj) { + return 0; + } + + const mkvLibraryDir = String(this.settings.mkvLibraryDir || "").trim(); + if (mkvLibraryDir) { + const sameOrLibraryInside = isPathInsideDir(mkvLibraryDir, extractDir); + const extractInsideLibrary = isPathInsideDir(extractDir, mkvLibraryDir); + if (sameOrLibraryInside || extractInsideLibrary) { + logger.warn(`Auto-Rename ABGEBROCHEN: extractDir=${extractDir} ueberlappt mit mkvLibraryDir=${mkvLibraryDir} — Cross-Package Korruption verhindert`); + if (pkg) { + this.logPackageForPackage(pkg, "ERROR", "Auto-Rename abgebrochen: extractDir ueberlappt mit MKV-Bibliothek", { + extractDir, + mkvLibraryDir + }); + } + return 0; + } + } + + const videoFiles = await this.collectVideoFiles(extractDir); + logger.info(`Auto-Rename: ${videoFiles.length} Video-Dateien gefunden in ${extractDir}`); + if (pkg) { + this.logPackageForPackage(pkg, "INFO", "Auto-Rename Scan gestartet", { + extractDir, + videoFiles: videoFiles.length + }); + this.logRenameProcess(pkg, "INFO", "auto-rename", "Auto-Rename Scan gestartet", { + extractDir, + videoFiles: videoFiles.length + }); + } + let renamed = 0; + + const packageExtraCandidates: string[] = []; + if (pkg) { + const outputBase = path.basename(pkg.outputDir || ""); + if (outputBase) { + packageExtraCandidates.push(outputBase); + } + } + + const sampleTokenRe = /(^|[._\-\s])sample([._\-\s]|$)/i; + const sampleDirNames = new Set(["sample", "samples"]); + const sampleSuffixRe = /[._\-]s$/i; + const FILE_STABILIZE_MIN_AGE_MS = this.fileStabilizeMinAgeMs; + for (const sourcePath of videoFiles) { + if (shouldAbort?.()) { + return renamed; + } + const sourceName = path.basename(sourcePath); + const sourceExt = path.extname(sourceName); + const sourceBaseName = path.basename(sourceName, sourceExt); + const parentDirName = path.basename(path.dirname(sourcePath)).toLowerCase(); + + let sourceStat: fs.Stats | null = null; + try { + sourceStat = await fs.promises.stat(sourcePath); + } catch { + continue; + } + const now = Date.now(); + const ageMs = now - sourceStat.mtimeMs; + if (!treatFilesAsStable && ageMs >= 0 && ageMs < FILE_STABILIZE_MIN_AGE_MS) { + logger.info(`Auto-Rename: ${sourceName} uebersprungen — Datei noch frisch (${Math.floor(ageMs)}ms), wird beim naechsten Scan behandelt`); + continue; + } + + const SAMPLE_MAX_BYTES = 150 * 1024 * 1024; + const looksLikeSampleByName = sampleTokenRe.test(sourceBaseName) || sampleSuffixRe.test(sourceBaseName); + const insideSampleDir = sampleDirNames.has(parentDirName); + if (insideSampleDir) { + continue; + } + if (looksLikeSampleByName) { + if (sourceStat.size <= SAMPLE_MAX_BYTES) { + continue; + } + logger.info(`Auto-Rename: ${sourceName} matcht Sample-Pattern, aber Groesse ${Math.round(sourceStat.size / (1024 * 1024))} MB > Schwelle — wird als echter Inhalt behandelt`); + } + if (isBonusContent(sourcePath, extractDir, sourceBaseName)) { + continue; + } + const folderCandidates: string[] = []; + let currentDir = path.dirname(sourcePath); + while (currentDir && isPathInsideDir(currentDir, extractDir)) { + folderCandidates.push(path.basename(currentDir)); + const parent = path.dirname(currentDir); + if (!parent || parent === currentDir) { + break; + } + currentDir = parent; + } + const seen = new Set(folderCandidates.map(c => c.toLowerCase())); + for (const extra of packageExtraCandidates) { + if (!seen.has(extra.toLowerCase())) { + seen.add(extra.toLowerCase()); + folderCandidates.push(extra); + } + } + const decision = decideAutoRenameBaseName( + folderCandidates, + sourceName, + sourceBaseName, + path.basename(path.dirname(sourcePath)), + path.basename(extractDir) + ); + let targetBaseName: string | null = decision.kind === "rename" ? decision.baseName : (decision.targetBaseName ?? null); + const resolveRenameItem = (...extra: Array<string | null | undefined>): { item: DownloadItem | null; matchedBy: string | null } => { + if (!pkg) { + return { item: null, matchedBy: null }; + } + return this.inferItemForMediaLog(pkg, sourcePath, sourceName, folderCandidates.join(" "), targetBaseName || "", ...extra); + }; + if (decision.kind === "skip" && decision.reason === "source-better") { + logger.info(`Auto-Rename uebersprungen: Source "${sourceBaseName}" ist bereits aussagekraeftiger als computed Target "${decision.targetBaseName}"`); + if (pkg) { + const resolved = resolveRenameItem(); + this.logRenameProcess(pkg, "INFO", "auto-rename", "Auto-Rename uebersprungen: Source schon besser als computed Target", { + sourcePath, + sourceName, + sourceBaseName, + targetBaseName: decision.targetBaseName, + folders: folderCandidates.join(", ") + }, resolved.item, resolved.matchedBy); + } + continue; + } + if (decision.kind === "skip" && decision.reason === "token-loss") { + logger.warn(`Auto-Rename Safety: Skipping rename - target wuerde Episode-Token verlieren (source=${sourceBaseName}, target=${decision.targetBaseName})`); + if (pkg) { + const resolved = resolveRenameItem(); + this.logRenameProcess(pkg, "WARN", "auto-rename", "Auto-Rename uebersprungen: Episode-Token wuerde verloren gehen", { + sourcePath, + sourceName, + sourceEpisodeToken: decision.sourceEpisodeToken, + targetBaseName: decision.targetBaseName + }, resolved.item, resolved.matchedBy); + } + continue; + } + if (decision.kind === "skip" && decision.reason === "token-mismatch") { + logger.warn(`Auto-Rename Safety: Skipping rename - Episode-Token Mismatch (source=${decision.sourceEpisodeToken}, target=${decision.targetEpisodeToken})`); + if (pkg) { + const resolved = resolveRenameItem(); + this.logRenameProcess(pkg, "WARN", "auto-rename", "Auto-Rename uebersprungen: Episode-Token Mismatch", { + sourcePath, + sourceName, + sourceEpisodeToken: decision.sourceEpisodeToken, + targetEpisodeToken: decision.targetEpisodeToken, + targetBaseName: decision.targetBaseName + }, resolved.item, resolved.matchedBy); + } + continue; + } + if (decision.kind === "rename" && decision.note === "token-inserted") { + logger.info(`Auto-Rename Safety: Episode-Token in Target eingefuegt -> ${decision.baseName}`); + } else if (decision.kind === "rename" && decision.note === "token-applyToken") { + logger.info(`Auto-Rename Safety: Episode-Token via applyToken -> ${decision.baseName}`); + } else if (decision.kind === "rename" && decision.note === "folder-override") { + const parentFolderName = path.basename(path.dirname(sourcePath)); + logger.info(`Auto-Rename: source-Token ${decision.sourceEpisodeToken} ignoriert, Folder-Token ${decision.targetEpisodeToken} ist authoritativ (vermutlich obfuskierter Dateiname in ${parentFolderName})`); + if (pkg) { + const resolved = resolveRenameItem(); + this.logRenameProcess(pkg, "INFO", "auto-rename", "Auto-Rename: Folder-Token uebersteuert obfuskierten Datei-Token", { + sourcePath, + sourceName, + sourceEpisodeToken: decision.sourceEpisodeToken, + targetEpisodeToken: decision.targetEpisodeToken, + parentFolder: parentFolderName, + targetBaseName: decision.baseName + }, resolved.item, resolved.matchedBy); + } + } + if (!targetBaseName) { + if (pkg) { + this.logPackageForPackage(pkg, "WARN", "Auto-Rename übersprungen: kein Zielname", { + sourceName, + sourceBaseName, + folders: folderCandidates.join(", ") + }); + const resolved = resolveRenameItem(); + this.logRenameProcess(pkg, "WARN", "auto-rename", "Auto-Rename übersprungen: kein Zielname", { + sourcePath, + sourceName, + sourceBaseName, + folders: folderCandidates.join(", ") + }, resolved.item, resolved.matchedBy); + } + logger.info(`Auto-Rename: kein Zielname für ${sourceName} (folders=${folderCandidates.join(", ")})`); + continue; + } + + let targetPath = this.buildSafeAutoRenameTargetPath(sourcePath, targetBaseName, sourceExt); + if (!targetPath) { + const fallbackBaseName = this.buildShortPackageFallbackBaseName(folderCandidates, sourceBaseName, targetBaseName); + if (fallbackBaseName) { + targetPath = this.buildSafeAutoRenameTargetPath(sourcePath, fallbackBaseName, sourceExt); + if (targetPath) { + logger.warn(`Auto-Rename Fallback wegen Pfadlänge: ${sourceName} -> ${path.basename(targetPath)}`); + if (pkg) { + const resolved = resolveRenameItem(targetPath, fallbackBaseName); + this.logRenameProcess(pkg, "WARN", "auto-rename", "Auto-Rename Fallback wegen Pfadlänge gewählt", { + sourcePath, + sourceName, + targetPath, + targetBaseName, + fallbackBaseName + }, resolved.item, resolved.matchedBy); + } + } + } + if (!targetPath) { + const veryShortFallback = this.buildVeryShortPackageFallbackBaseName(folderCandidates, sourceBaseName, targetBaseName); + if (veryShortFallback) { + targetPath = this.buildSafeAutoRenameTargetPath(sourcePath, veryShortFallback, sourceExt); + if (targetPath) { + logger.warn(`Auto-Rename Kurz-Fallback wegen Pfadlänge: ${sourceName} -> ${path.basename(targetPath)}`); + if (pkg) { + const resolved = resolveRenameItem(targetPath, veryShortFallback); + this.logRenameProcess(pkg, "WARN", "auto-rename", "Auto-Rename Kurz-Fallback wegen Pfadlänge gewählt", { + sourcePath, + sourceName, + targetPath, + targetBaseName, + fallbackBaseName: veryShortFallback + }, resolved.item, resolved.matchedBy); + } + } + } + } + } + if (!targetPath) { + if (pkg) { + this.logPackageForPackage(pkg, "WARN", "Auto-Rename übersprungen: Zielpfad ungültig", { + sourceName, + sourceBaseName, + targetBaseName + }); + const resolved = resolveRenameItem(); + this.logRenameProcess(pkg, "WARN", "auto-rename", "Auto-Rename übersprungen: Zielpfad ungültig", { + sourcePath, + sourceName, + sourceBaseName, + targetBaseName + }, resolved.item, resolved.matchedBy); + } + logger.warn(`Auto-Rename übersprungen (Zielpfad zu lang/ungültig): ${sourcePath}`); + continue; + } + if (targetPath === sourcePath) { + if (pkg) { + const resolved = resolveRenameItem(targetPath); + this.logRenameProcess(pkg, "INFO", "auto-rename", "Auto-Rename übersprungen: Name bereits passend", { + sourcePath, + sourceName, + targetPath, + targetBaseName + }, resolved.item, resolved.matchedBy); + } + continue; + } + if (pathKey(targetPath) === pathKey(sourcePath) && targetPath !== sourcePath) { + try { + await this.renamePathWithExdevFallback(sourcePath, targetPath, { label: "auto-rename (Schreibweise)" }); + renamed += 1; + if (pkg) { + const resolved = resolveRenameItem(targetPath); + this.logRenameProcess(pkg, "INFO", "auto-rename", "Auto-Rename (Casing korrigiert)", { + sourcePath, + sourceName, + targetPath, + targetBaseName + }, resolved.item, resolved.matchedBy); + } + logger.info(`Auto-Rename Casing: ${sourcePath} -> ${targetPath}`); + } catch (err) { + logger.warn(`Auto-Rename Casing fehlgeschlagen: ${sourcePath} -> ${targetPath}: ${compactErrorText(err as Error)}`); + } + continue; + } + if (await this.existsAsync(targetPath)) { + const targetDir = path.dirname(targetPath); + const targetExt = path.extname(targetPath); + const targetBase = path.basename(targetPath, targetExt); + let resolvedTarget: string | null = null; + for (let suffixN = 2; suffixN <= 99; suffixN += 1) { + const candidate = path.join(targetDir, `${targetBase}.${suffixN}${targetExt}`); + if (pathKey(candidate) === pathKey(sourcePath)) { + continue; + } + if (!(await this.existsAsync(candidate))) { + resolvedTarget = candidate; + break; + } + } + if (!resolvedTarget) { + if (pkg) { + this.logPackageForPackage(pkg, "WARN", "Auto-Rename übersprungen: Ziel existiert (>99 Varianten belegt)", { + sourceName, + targetPath + }); + const resolved = resolveRenameItem(targetPath); + this.logRenameProcess(pkg, "WARN", "auto-rename", "Auto-Rename übersprungen: Ziel existiert", { + sourcePath, + sourceName, + targetPath, + targetBaseName + }, resolved.item, resolved.matchedBy); + } + logger.warn(`Auto-Rename übersprungen (Ziel existiert, >99 Varianten belegt): ${targetPath}`); + continue; + } + if (pkg) { + this.logPackageForPackage(pkg, "INFO", "Auto-Rename mit Suffix (Ziel existierte)", { + sourceName, + originalTarget: targetPath, + resolvedTarget + }); + } + logger.info(`Auto-Rename mit Suffix: Ziel ${path.basename(targetPath)} existierte → benutze ${path.basename(resolvedTarget)}`); + targetPath = resolvedTarget; + } + + try { + await this.renamePathWithExdevFallback(sourcePath, targetPath, { label: "auto-rename" }); + if (pkg) { + this.logPackageForPackage(pkg, "INFO", "Auto-Rename durchgeführt", { + sourcePath, + targetPath, + sourceName + }); + const resolved = resolveRenameItem(targetPath); + this.logRenameProcess(pkg, "INFO", "auto-rename", "Auto-Rename durchgeführt", { + sourcePath, + targetPath, + sourceName, + targetBaseName, + folders: folderCandidates.join(", ") + }, resolved.item, resolved.matchedBy); + } + logger.info(`Auto-Rename: ${sourceName} -> ${path.basename(targetPath)}`); + renamed += 1; + await this.renameCompanionFiles(sourcePath, targetPath, pkg); + } catch (error) { + if (this.isPathLengthRenameError(error)) { + const fallbackCandidates = [ + this.buildShortPackageFallbackBaseName(folderCandidates, sourceBaseName, targetBaseName), + this.buildVeryShortPackageFallbackBaseName(folderCandidates, sourceBaseName, targetBaseName) + ].filter((value): value is string => Boolean(value)); + let fallbackRenamed = false; + for (const fallbackBaseName of fallbackCandidates) { + const fallbackPath = this.buildSafeAutoRenameTargetPath(sourcePath, fallbackBaseName, sourceExt); + if (!fallbackPath || pathKey(fallbackPath) === pathKey(sourcePath)) { + continue; + } + if (await this.existsAsync(fallbackPath)) { + continue; + } + try { + await this.renamePathWithExdevFallback(sourcePath, fallbackPath, { label: "auto-rename (Pfadlaenge-Fallback)" }); + logger.warn(`Auto-Rename Fallback wegen Pfadlänge: ${sourceName} -> ${path.basename(fallbackPath)}`); + renamed += 1; + if (pkg) { + const resolved = resolveRenameItem(fallbackPath, fallbackBaseName); + this.logRenameProcess(pkg, "WARN", "auto-rename", "Auto-Rename Fallback durchgeführt", { + sourcePath, + sourceName, + targetPath: fallbackPath, + targetBaseName, + fallbackBaseName + }, resolved.item, resolved.matchedBy); + } + fallbackRenamed = true; + break; + } catch { + } + } + if (fallbackRenamed) { + continue; + } + } + logger.warn(`Auto-Rename fehlgeschlagen (${sourceName}): ${compactErrorText(error)}`); + if (pkg) { + this.logPackageForPackage(pkg, "WARN", "Auto-Rename fehlgeschlagen", { + sourceName, + error: compactErrorText(error) + }); + const resolved = resolveRenameItem(targetPath); + this.logRenameProcess(pkg, "WARN", "auto-rename", "Auto-Rename fehlgeschlagen", { + sourcePath, + sourceName, + targetPath, + targetBaseName, + folders: folderCandidates.join(", "), + error: compactErrorText(error) + }, resolved.item, resolved.matchedBy); + } + } + } + + if (renamed > 0) { + logger.info(`Auto-Rename (Scene): ${renamed} Datei(en) umbenannt`); + if (pkg) { + this.logPackageForPackage(pkg, "INFO", "Auto-Rename abgeschlossen", { + renamed + }); + this.logRenameProcess(pkg, "INFO", "auto-rename", "Auto-Rename abgeschlossen", { + extractDir, + renamed + }); + } + } + return renamed; + } + + private async moveFileWithExdevFallback(sourcePath: string, targetPath: string): Promise<void> { + await this.renamePathWithExdevFallback(sourcePath, targetPath, { label: "mkv-move" }); + } + + private async cleanupNonMkvResidualFiles(rootDir: string, targetDir: string): Promise<number> { + if (!rootDir || !await this.existsAsync(rootDir)) { + return 0; + } + + let removed = 0; + const stack = [rootDir]; + while (stack.length > 0) { + const current = stack.pop() as string; + let entries: fs.Dirent[] = []; + try { + entries = await fs.promises.readdir(current, { withFileTypes: true }); + } catch { + continue; + } + + for (const entry of entries) { + const fullPath = path.join(current, entry.name); + if (entry.isDirectory()) { + if (isPathInsideDir(fullPath, targetDir)) { + continue; + } + stack.push(fullPath); + continue; + } + if (!entry.isFile()) { + continue; + } + const extension = path.extname(entry.name).toLowerCase(); + if (SAMPLE_VIDEO_EXTENSIONS.has(extension)) { + continue; + } + try { + await fs.promises.rm(toWindowsLongPathIfNeeded(fullPath), { force: true }); + removed += 1; + } catch { + } + } + } + + return removed; + } + + private async cleanupRemainingArchiveArtifacts(packageDir: string, shouldAbort?: () => boolean): Promise<number> { + if (this.settings.cleanupMode === "none") { + return 0; + } + if (shouldAbort?.()) { + return 0; + } + const candidates = await findArchiveCandidates(packageDir); + if (candidates.length === 0) { + return 0; + } + + let removed = 0; + const dirFilesCache = new Map<string, string[]>(); + const targets = new Set<string>(); + for (const sourceFile of candidates) { + if (shouldAbort?.()) { + return removed; + } + const dir = path.dirname(sourceFile); + let filesInDir = dirFilesCache.get(dir); + if (!filesInDir) { + try { + filesInDir = (await fs.promises.readdir(dir, { withFileTypes: true })) + .filter((entry) => entry.isFile()) + .map((entry) => entry.name); + } catch { + filesInDir = []; + } + dirFilesCache.set(dir, filesInDir); + } + for (const target of collectArchiveCleanupTargets(sourceFile, filesInDir)) { + targets.add(target); + } + } + + for (const targetPath of targets) { + if (shouldAbort?.()) { + return removed; + } + try { + if (!await this.existsAsync(targetPath)) { + continue; + } + if (this.settings.cleanupMode === "trash") { + const parsed = path.parse(targetPath); + const trashDir = path.join(parsed.dir, ".rd-trash"); + await fs.promises.mkdir(trashDir, { recursive: true }); + let moved = false; + for (let index = 0; index <= 1000; index += 1) { + const suffix = index === 0 ? "" : `-${index}`; + const candidate = path.join(trashDir, `${parsed.base}.${Date.now()}${suffix}`); + if (await this.existsAsync(candidate)) { + continue; + } + await this.renamePathWithExdevFallback(targetPath, candidate, { label: "mkv-move (Konflikt-Aufloesung)" }); + moved = true; + break; + } + if (moved) { + removed += 1; + } + continue; + } + await fs.promises.rm(toWindowsLongPathIfNeeded(targetPath), { force: true }); + removed += 1; + } catch { + } + } + + return removed; + } + + private hasDeferredPostProcessPending(packageId: string): boolean { + const controller = this.packageDeferredPostProcessAbortControllers.get(packageId); + if (controller && !controller.signal.aborted) { + return true; + } + const hybridSet = this.packageHybridPostProcessControllers.get(packageId); + if (hybridSet) { + for (const c of hybridSet) { + if (!c.signal.aborted) { + return true; + } + } + } + return false; + } + + private hasAnyDeferredPostProcessPending(): boolean { + for (const controller of this.packageDeferredPostProcessAbortControllers.values()) { + if (!controller.signal.aborted) { + return true; + } + } + for (const hybridSet of this.packageHybridPostProcessControllers.values()) { + for (const c of hybridSet) { + if (!c.signal.aborted) { + return true; + } + } + } + return false; + } + + // Packages whose post-processing (task, deferred pass or hybrid round) is still + // alive must keep their run-membership when the run set is replaced/cleared — + // otherwise their terminal notification is dropped as soon as session.running + // flips false (the notify guard checks running || runPackageIds). + private addTrailingPostProcessPackageIds(target: Set<string>): void { + for (const id of this.packagePostProcessTasks.keys()) { + target.add(id); + } + for (const [id, controller] of this.packageDeferredPostProcessAbortControllers) { + if (!controller.signal.aborted) { + target.add(id); + } + } + for (const [id, hybridSet] of this.packageHybridPostProcessControllers) { + for (const c of hybridSet) { + if (!c.signal.aborted) { + target.add(id); + break; + } + } + } + } + + private buildCollectFolderCandidates(sourcePath: string, sourceRoot: string, pkg: PackageEntry): string[] { + const folderCandidates: string[] = []; + let currentDir = path.dirname(sourcePath); + while (currentDir && isPathInsideDir(currentDir, sourceRoot)) { + folderCandidates.push(path.basename(currentDir)); + const parent = path.dirname(currentDir); + if (!parent || parent === currentDir) { + break; + } + currentDir = parent; + } + const seen = new Set(folderCandidates.map((c) => c.toLowerCase())); + const rootBase = path.basename(sourceRoot || ""); + if (rootBase && !seen.has(rootBase.toLowerCase())) { + folderCandidates.push(rootBase); + seen.add(rootBase.toLowerCase()); + } + const outputBase = path.basename(pkg.outputDir || ""); + if (outputBase && !seen.has(outputBase.toLowerCase())) { + folderCandidates.push(outputBase); + } + return folderCandidates; + } + + private deriveCleanCollectFileName(sourcePath: string, sourceRoot: string, pkg: PackageEntry): string | null { + if (!this.settings.autoRename4sf4sj) { + return null; + } + const parsed = path.parse(path.basename(sourcePath)); + const sourceExt = parsed.ext || ".mkv"; + const folderCandidates = this.buildCollectFolderCandidates(sourcePath, sourceRoot, pkg); + const decision = decideAutoRenameBaseName( + folderCandidates, + path.basename(sourcePath), + parsed.name, + path.basename(path.dirname(sourcePath)), + path.basename(sourceRoot || "") + ); + if (decision.kind !== "rename") { + return null; + } + const cleanBase = sanitizeFilename(decision.baseName); + if (!cleanBase) { + return null; + } + // Safety net: collect derives names from folder tokens that may still carry + // ".DL.". When German-audio-only is on, never reintroduce the marker the + // audio step already stripped from the file. + const cleanFileName = this.settings.keepGermanAudioOnly + ? stripDualLangMarker(`${cleanBase}${sourceExt}`) + : `${cleanBase}${sourceExt}`; + if (cleanFileName.toLowerCase() === path.basename(sourcePath).toLowerCase()) { + return null; + } + return cleanFileName; + } + + private async buildUniqueFlattenTargetPath(targetDir: string, sourcePath: string, reserved: Set<string>, desiredFileName?: string): Promise<string> { + const parsed = path.parse(desiredFileName && desiredFileName.trim() ? desiredFileName : path.basename(sourcePath)); + const extension = parsed.ext || ".mkv"; + const baseName = sanitizeFilename(parsed.name || "video"); + + let index = 1; + const MAX_ATTEMPTS = 10000; + while (index <= MAX_ATTEMPTS) { + const candidateName = index <= 1 + ? `${baseName}${extension}` + : `${baseName} (${index})${extension}`; + const candidatePath = path.join(targetDir, candidateName); + const candidateKey = pathKey(candidatePath); + if (reserved.has(candidateKey)) { + index += 1; + continue; + } + if (!await this.existsAsync(candidatePath)) { + reserved.add(candidateKey); + return candidatePath; + } + index += 1; + } + const fallbackName = `${baseName} (${Date.now()})${extension}`; + const fallbackPath = path.join(targetDir, fallbackName); + reserved.add(pathKey(fallbackPath)); + return fallbackPath; + } + + private async collectMkvFilesToLibrary( + packageId: string, + pkg: PackageEntry, + shouldAbort?: () => boolean, + deferFreshFiles = false + ): Promise<void> { + if (!this.settings.collectMkvToLibrary) { + return; + } + + const sourceDirCandidates: string[] = []; + if (this.settings.autoExtract && pkg.extractDir) { + sourceDirCandidates.push(pkg.extractDir); + } + if (pkg.outputDir) { + sourceDirCandidates.push(pkg.outputDir); + } + const sourceDirSeen = new Set<string>(); + const sourceDirsAll: string[] = []; + for (const dir of sourceDirCandidates) { + const resolved = path.resolve(dir).toLowerCase(); + if (sourceDirSeen.has(resolved)) continue; + sourceDirSeen.add(resolved); + sourceDirsAll.push(dir); + } + + const targetDirRaw = String(this.settings.mkvLibraryDir || "").trim(); + if (sourceDirsAll.length === 0 || !targetDirRaw) { + logger.warn(`MKV-Sammelordner übersprungen: pkg=${pkg.name}, ungültiger Pfad`); + return; + } + const targetDir = path.resolve(targetDirRaw); + + const sourceDirs: string[] = []; + for (const dir of sourceDirsAll) { + if (isPathInsideDir(dir, targetDir) || isPathInsideDir(targetDir, dir)) { + logger.warn(`MKV-Sammelordner: Source uebersprungen (ueberlappt mit mkvLibraryDir): pkg=${pkg.name}, dir=${dir}, target=${targetDir}`); + this.logPackageForPackage(pkg, "WARN", "MKV-Sammelordner: Source uebersprungen (ueberlappt mit MKV-Bibliothek)", { + sourceDir: dir, + targetDir + }); + continue; + } + if (!await this.existsAsync(dir)) { + continue; + } + sourceDirs.push(dir); + } + if (sourceDirs.length === 0) { + logger.info(`MKV-Sammelordner: pkg=${pkg.name}, keine nutzbare Quelle (alle Source-Dirs fehlen oder ueberlappen mit Library)`); + return; + } + + const cleanupDirCandidate = this.settings.autoExtract ? pkg.extractDir : pkg.outputDir; + const cleanupDir = (cleanupDirCandidate && sourceDirs.some( + (d) => path.resolve(d).toLowerCase() === path.resolve(cleanupDirCandidate).toLowerCase() + )) ? cleanupDirCandidate : null; + + try { + await fs.promises.mkdir(targetDir, { recursive: true }); + } catch (error) { + logger.warn(`MKV-Sammelordner konnte nicht erstellt werden: pkg=${pkg.name}, dir=${targetDir}, reason=${compactErrorText(error)}`); + return; + } + + const seenBasenames = new Set<string>(); + const collected: { filePath: string; sourceRoot: string }[] = []; + for (const dir of sourceDirs) { + const filesInDir = await this.collectFilesByExtensions(dir, SAMPLE_VIDEO_EXTENSIONS); + for (const filePath of filesInDir) { + const baseLower = path.basename(filePath).toLowerCase(); + if (seenBasenames.has(baseLower)) continue; + seenBasenames.add(baseLower); + collected.push({ filePath, sourceRoot: dir }); + } + } + if (collected.length === 0) { + logger.info(`MKV-Sammelordner: pkg=${pkg.name}, keine MKV gefunden`); + return; + } + + const sampleDirNames = new Set(["sample", "samples"]); + const sampleTokenRe = /(^|[._\-\s])sample([._\-\s]|$)/i; + const mkvFiles: { filePath: string; sourceRoot: string }[] = []; + let sampleSkipped = 0; + let bonusSkipped = 0; + for (const { filePath, sourceRoot } of collected) { + if (shouldAbort?.()) { + return; + } + const parentDir = path.basename(path.dirname(filePath)).toLowerCase(); + const stem = path.parse(path.basename(filePath)).name; + if (sampleDirNames.has(parentDir) || sampleTokenRe.test(stem)) { + sampleSkipped += 1; + continue; + } + if (isBonusContent(filePath, sourceRoot, stem)) { + bonusSkipped += 1; + logger.info(`MKV-Sammelordner: Bonus-Datei uebersprungen: ${path.basename(filePath)} (Pfad: ${path.relative(sourceRoot, filePath)})`); + continue; + } + mkvFiles.push({ filePath, sourceRoot }); + } + if (sampleSkipped > 0) { + logger.info(`MKV-Sammelordner: pkg=${pkg.name}, ${sampleSkipped} Sample-MKV(s) übersprungen`); + } + if (bonusSkipped > 0) { + logger.info(`MKV-Sammelordner: pkg=${pkg.name}, ${bonusSkipped} Bonus-MKV(s) übersprungen (Extras/Featurettes/etc.)`); + } + if (mkvFiles.length === 0) { + logger.info(`MKV-Sammelordner: pkg=${pkg.name}, keine MKV nach Sample/Bonus-Filter`); + return; + } + + this.logRenameProcess(pkg, "INFO", "mkv-move", "MKV-Sammelordner Scan gestartet", { + sourceDirs: sourceDirs.join(" | "), + targetDir, + mkvFiles: mkvFiles.length + }); + + const reservedTargets = new Set<string>(); + let moved = 0; + let skipped = 0; + let failed = 0; + let sourceArtifactsChanged = false; + let sourceCleanupRelevant = false; + + for (const { filePath: sourcePath, sourceRoot } of mkvFiles) { + if (shouldAbort?.()) { + return; + } + if (isPathInsideDir(sourcePath, targetDir)) { + skipped += 1; + continue; + } + + let sourceSize = 0; + let sourceMtimeMs = 0; + try { + const stat = await fs.promises.stat(sourcePath); + sourceSize = stat.size; + sourceMtimeMs = stat.mtimeMs; + } catch { + skipped += 1; + continue; + } + if (deferFreshFiles && this.fileStabilizeMinAgeMs > 0) { + const ageMs = Date.now() - sourceMtimeMs; + if (ageMs >= 0 && ageMs < this.fileStabilizeMinAgeMs) { + logger.info(`MKV-Sammelordner: ${path.basename(sourcePath)} uebersprungen — Datei noch frisch (${Math.floor(ageMs)}ms), wird nach Stabilisierung gesammelt`); + skipped += 1; + continue; + } + } + if (deferFreshFiles && this.settings.keepGermanAudioOnly) { + const baseName = path.basename(sourcePath); + if (isRemuxableVideoFile(baseName) && hasDualLangMarker(baseName)) { + logger.info(`MKV-Sammelordner: ${baseName} uebersprungen — .DL. noch nicht tonspur-bereinigt (Race-Schutz), wird im finalen Durchlauf gesammelt`); + skipped += 1; + continue; + } + } + if (sourceSize === 0) { + logger.warn(`MKV-Sammelordner: überspringe 0-Byte-Datei ${path.basename(sourcePath)}`); + const resolved = this.inferItemForMediaLog(pkg, sourcePath, path.basename(sourcePath), targetDir); + this.logRenameProcess(pkg, "WARN", "mkv-move", "MKV übersprungen: 0-Byte-Datei", { + sourcePath, + targetDir, + sourceSize + }, resolved.item, resolved.matchedBy); + skipped += 1; + continue; + } + + const derivedFileName = this.deriveCleanCollectFileName(sourcePath, sourceRoot, pkg); + const desiredFileName = derivedFileName || path.basename(sourcePath); + if (derivedFileName) { + const resolvedDerive = this.inferItemForMediaLog(pkg, sourcePath, path.basename(sourcePath), targetDir); + this.logRenameProcess(pkg, "INFO", "mkv-move", "MKV-Name beim Sammeln aus Ordnerkontext abgeleitet (Auto-Rename hatte Datei nicht erfasst)", { + sourcePath, + rohName: path.basename(sourcePath), + sauberName: derivedFileName + }, resolvedDerive.item, resolvedDerive.matchedBy); + } + + const idealTargetPath = path.join(targetDir, desiredFileName); + try { + const existingStat = await fs.promises.stat(idealTargetPath); + if (existingStat.size === sourceSize) { + logger.info(`MKV-Sammelordner: Duplikat übersprungen (gleiche Größe ${humanSize(sourceSize)}): ${path.basename(sourcePath)}`); + const resolved = this.inferItemForMediaLog(pkg, sourcePath, path.basename(sourcePath), idealTargetPath); + this.logRenameProcess(pkg, "INFO", "mkv-move", "MKV-Duplikat übersprungen", { + sourcePath, + targetPath: idealTargetPath, + sourceSize + }, resolved.item, resolved.matchedBy); + try { + await fs.promises.unlink(sourcePath); + sourceArtifactsChanged = true; + } catch { + } + sourceCleanupRelevant = true; + skipped += 1; + continue; + } + } catch { + } + + const targetPath = await this.buildUniqueFlattenTargetPath(targetDir, sourcePath, reservedTargets, desiredFileName); + if (pathKey(sourcePath) === pathKey(targetPath)) { + skipped += 1; + continue; + } + + try { + await this.moveFileWithExdevFallback(sourcePath, targetPath); + moved += 1; + sourceArtifactsChanged = true; + sourceCleanupRelevant = true; + this.logPackageForPackage(pkg, "INFO", "MKV verschoben", { + sourcePath, + targetPath, + sourceSize + }); + const resolved = this.inferItemForMediaLog(pkg, sourcePath, path.basename(sourcePath), targetPath); + this.logRenameProcess(pkg, "INFO", "mkv-move", "MKV verschoben", { + sourcePath, + targetPath, + sourceSize + }, resolved.item, resolved.matchedBy); + await this.moveCompanionFiles(sourcePath, targetPath, pkg); + } catch (error) { + failed += 1; + logger.warn(`MKV verschieben fehlgeschlagen: ${sourcePath} -> ${targetPath} (${compactErrorText(error)})`); + this.logPackageForPackage(pkg, "WARN", "MKV verschieben fehlgeschlagen", { + sourcePath, + targetPath, + error: compactErrorText(error) + }); + const resolved = this.inferItemForMediaLog(pkg, sourcePath, path.basename(sourcePath), targetPath); + this.logRenameProcess(pkg, "WARN", "mkv-move", "MKV verschieben fehlgeschlagen", { + sourcePath, + targetPath, + sourceSize, + error: compactErrorText(error) + }, resolved.item, resolved.matchedBy); + } + } + + if ((sourceArtifactsChanged || sourceCleanupRelevant) && cleanupDir && await this.existsAsync(cleanupDir)) { + const removedResidual = await this.cleanupNonMkvResidualFiles(cleanupDir, targetDir); + if (removedResidual > 0) { + logger.info(`MKV-Sammelordner entfernte Restdateien: pkg=${pkg.name}, dir=${cleanupDir}, entfernt=${removedResidual}`); + } + const removedDirs = await this.removeEmptyDirectoryTree(cleanupDir); + if (removedDirs > 0) { + logger.info(`MKV-Sammelordner entfernte leere Ordner: pkg=${pkg.name}, dir=${cleanupDir}, entfernt=${removedDirs}`); + } + } + + logger.info(`MKV-Sammelordner: pkg=${pkg.name}, packageId=${packageId}, moved=${moved}, skipped=${skipped}, failed=${failed}, target=${targetDir}`); + this.logRenameProcess(pkg, "INFO", "mkv-move", "MKV-Sammelordner abgeschlossen", { + sourceDirs: sourceDirs.join(" | "), + targetDir, + moved, + skipped, + failed + }); + } + + public cancelPackage(packageId: string): void { + const pkg = this.session.packages[packageId]; + if (!pkg) { + return; + } + this.logPackageForPackage(pkg, "WARN", "Paketabbruch angefordert", { + itemCount: pkg.itemIds.length + }); + pkg.cancelled = true; + pkg.updatedAt = nowMs(); + const packageName = pkg.name; + const outputDir = pkg.outputDir; + const itemIds = [...pkg.itemIds]; + + for (const itemId of itemIds) { + const item = this.session.items[itemId]; + if (!item) { + continue; + } + if (item.status !== "completed") { + this.recordRunOutcome(itemId, "cancelled"); + } + const active = this.activeTasks.get(itemId); + if (active) { + active.abortReason = "cancel"; + active.abortController.abort("cancel"); + } + } + + this.abortPackagePostProcessing(packageId, "cancel"); + + this.removePackageFromSession(packageId, itemIds); + this.persistSoon(); + this.emitState(true); + + this.cleanupQueue = this.cleanupQueue + .then(async () => { + const removed = await cleanupCancelledPackageArtifactsAsync(outputDir, { + shouldAbort: () => this.packageOutputDirInUse(outputDir) + }); + logger.info(`Paket ${packageName} abgebrochen, ${removed} Artefakte gelöscht`); + }) + .catch((error) => { + logger.warn(`Cleanup für Paket ${packageName} fehlgeschlagen: ${compactErrorText(error)}`); + }); + } + + public resetPackage(packageId: string): void { + const pkg = this.session.packages[packageId]; + if (!pkg) return; + + const itemIds = [...pkg.itemIds]; + + for (const itemId of itemIds) { + const item = this.session.items[itemId]; + if (!item) continue; + + const active = this.activeTasks.get(itemId); + if (active) { + active.abortReason = "reset"; + active.abortController.abort("reset"); + } + + const targetPath = String(item.targetPath || "").trim(); + if (targetPath) { + try { fs.rmSync(targetPath, { force: true }); } catch { } + this.releaseTargetPath(itemId); + } + + this.dropItemContribution(itemId); + this.runOutcomes.delete(itemId); + this.runItemIds.delete(itemId); + this.retryAfterByItem.delete(itemId); + this.retryStateByItem.delete(itemId); + + item.status = "queued"; + item.downloadedBytes = 0; + item.totalBytes = null; + item.progressPercent = 0; + item.speedBps = 0; + item.attempts = 0; + item.retries = 0; + item.lastError = ""; + item.resumable = true; + item.targetPath = ""; + item.provider = null; + item.fullStatus = "Wartet"; + item.onlineStatus = undefined; + item.updatedAt = nowMs(); + } + + this.abortPackagePostProcessing(packageId, "reset"); + this.runCompletedPackages.delete(packageId); + + if (pkg.outputDir) { + clearExtractResumeState(pkg.outputDir, packageId).catch(() => {}); + clearExtractResumeState(pkg.outputDir).catch(() => {}); + } + + pkg.status = "queued"; + pkg.cancelled = false; + pkg.enabled = true; + pkg.updatedAt = nowMs(); + this.historyRecordedPackages.delete(packageId); + this.notifiedPackages.delete(packageId); + + if (this.session.running) { + for (const itemId of itemIds) { + this.runItemIds.add(itemId); + } + this.runPackageIds.add(packageId); + } + + logger.info(`Paket "${pkg.name}" zurückgesetzt (${itemIds.length} Items)`); + this.persistSoon(); + this.emitState(true); + if (this.session.running) { + void this.ensureScheduler().catch((err) => logger.warn(`ensureScheduler Fehler (resetPackage): ${compactErrorText(err)}`)); + } + } + + public resetItems(itemIds: string[]): void { + const affectedPackageIds = new Set<string>(); + for (const itemId of itemIds) { + const item = this.session.items[itemId]; + if (!item) continue; + + affectedPackageIds.add(item.packageId); + + const active = this.activeTasks.get(itemId); + if (active) { + active.abortReason = "reset"; + active.abortController.abort("reset"); + } + + const targetPath = String(item.targetPath || "").trim(); + if (targetPath) { + try { fs.rmSync(targetPath, { force: true }); } catch { } + this.releaseTargetPath(itemId); + } + + this.dropItemContribution(itemId); + this.runOutcomes.delete(itemId); + this.retryAfterByItem.delete(itemId); + this.retryStateByItem.delete(itemId); + + item.status = "queued"; + item.downloadedBytes = 0; + item.totalBytes = null; + item.progressPercent = 0; + item.speedBps = 0; + item.attempts = 0; + item.retries = 0; + item.lastError = ""; + item.resumable = true; + item.targetPath = ""; + item.provider = null; + item.fullStatus = "Wartet"; + item.onlineStatus = undefined; + item.updatedAt = nowMs(); + + if (this.session.running) { + this.runItemIds.add(itemId); + } else { + this.runItemIds.delete(itemId); + } + } + + for (const pkgId of affectedPackageIds) { + this.abortPackagePostProcessing(pkgId, "reset"); + this.runCompletedPackages.delete(pkgId); + this.historyRecordedPackages.delete(pkgId); + this.notifiedPackages.delete(pkgId); + + const pkg = this.session.packages[pkgId]; + if (pkg && (pkg.status === "completed" || pkg.status === "failed" || pkg.status === "cancelled")) { + pkg.status = "queued"; + pkg.cancelled = false; + pkg.updatedAt = nowMs(); + } + if (this.session.running) { + this.runPackageIds.add(pkgId); + } + } + + logger.info(`${itemIds.length} Item(s) zurückgesetzt`); + this.persistSoon(); + this.emitState(true); + if (this.session.running) { + void this.ensureScheduler().catch((err) => logger.warn(`ensureScheduler Fehler (resetItems): ${compactErrorText(err)}`)); + } + } + + public setPackagePriority(packageId: string, priority: PackagePriority): void { + const pkg = this.session.packages[packageId]; + if (!pkg) return; + if (priority !== "high" && priority !== "normal" && priority !== "low") return; + pkg.priority = priority; + pkg.updatedAt = nowMs(); + + if (priority === "high") { + const order = this.session.packageOrder; + const idx = order.indexOf(packageId); + if (idx > 0) { + order.splice(idx, 1); + let insertAt = 0; + for (let i = 0; i < order.length; i++) { + const p = this.session.packages[order[i]]; + if (p && p.priority === "high") { + insertAt = i + 1; + } + } + order.splice(insertAt, 0, packageId); + } + } + + this.persistSoon(); + this.emitState(); + } + + public skipItems(itemIds: string[]): void { + const affectedPackageIds = new Set<string>(); + for (const itemId of itemIds) { + const item = this.session.items[itemId]; + if (!item) continue; + if (item.status !== "queued" && item.status !== "reconnect_wait") continue; + item.status = "cancelled"; + item.fullStatus = "Übersprungen"; + item.speedBps = 0; + item.updatedAt = nowMs(); + this.retryAfterByItem.delete(itemId); + this.retryStateByItem.delete(itemId); + this.releaseTargetPath(itemId); + this.recordRunOutcome(itemId, "cancelled"); + affectedPackageIds.add(item.packageId); + } + for (const pkgId of affectedPackageIds) { + const pkg = this.session.packages[pkgId]; + if (pkg) this.refreshPackageStatus(pkg); + } + // A skip can be the package's LAST terminal event; without this trigger the + // post-processing terminal block (notify, history, cleanup policy) never runs + // for it — earlier rounds returned while the skipped item was still pending. + for (const pkgId of affectedPackageIds) { + const pkg = this.session.packages[pkgId]; + if (!pkg || pkg.cancelled || this.packagePostProcessTasks.has(pkgId)) continue; + const pkgItems = pkg.itemIds.map((id) => this.session.items[id]).filter(Boolean) as DownloadItem[]; + const hasPending = pkgItems.some((i) => i.status !== "completed" && i.status !== "failed" && i.status !== "cancelled"); + if (hasPending) continue; + const hasFailed = pkgItems.some((i) => i.status === "failed"); + const hasUnextracted = pkgItems.some((i) => i.status === "completed" && shouldAutoRetryExtraction(i.fullStatus || "")); + if (this.settings.autoExtract && !hasFailed && hasUnextracted) { + for (const it of pkgItems) { + if (it.status === "completed" && shouldAutoRetryExtraction(it.fullStatus || "")) { + it.fullStatus = "Entpacken - Ausstehend"; + it.updatedAt = nowMs(); + } + } + } + void this.runPackagePostProcessing(pkgId).catch((err) => logger.warn(`Post-processing nach Skip: ${compactErrorText(err)}`)); + } + this.persistSoon(); + this.emitState(); + } + + public async startPackages(packageIds: string[]): Promise<void> { + const targetSet = new Set(packageIds); + + for (const pkgId of targetSet) { + const pkg = this.session.packages[pkgId]; + if (pkg && !pkg.cancelled && !pkg.enabled) { + pkg.enabled = true; + } + } + + for (const item of Object.values(this.session.items)) { + if (!targetSet.has(item.packageId)) continue; + if (item.status === "cancelled" && item.fullStatus === "Gestoppt") { + const pkg = this.session.packages[item.packageId]; + if (pkg && !pkg.cancelled && pkg.enabled) { + item.status = "queued"; + item.fullStatus = "Wartet"; + item.lastError = ""; + item.speedBps = 0; + item.updatedAt = nowMs(); + } + } + } + + if (this.session.running) { + for (const item of Object.values(this.session.items)) { + if (!targetSet.has(item.packageId)) continue; + if (item.status === "queued" || item.status === "reconnect_wait") { + this.runItemIds.add(item.id); + this.runPackageIds.add(item.packageId); + } + } + this.persistSoon(); + this.emitState(true); + return; + } + + this.triggerPendingExtractions(); + const runItems = Object.values(this.session.items) + .filter((item) => { + if (!targetSet.has(item.packageId)) return false; + if (item.status !== "queued" && item.status !== "reconnect_wait") return false; + const pkg = this.session.packages[item.packageId]; + return Boolean(pkg && !pkg.cancelled && pkg.enabled); + }); + if (runItems.length === 0) { + this.persistSoon(); + this.emitState(true); + return; + } + this.runItemIds = new Set(runItems.map((item) => item.id)); + this.runPackageIds = new Set(runItems.map((item) => item.packageId)); + this.addTrailingPostProcessPackageIds(this.runPackageIds); + this.runOutcomes.clear(); + this.runCompletedPackages.clear(); + this.retryAfterByItem.clear(); + this.providerStartReservations.clear(); + this.pacedStartReservationByItem.clear(); + this.retryStateByItem.clear(); + this.itemContributedBytes.clear(); + this.reservedTargetPaths.clear(); + this.claimedTargetPathByItem.clear(); + this.session.running = true; + this.session.paused = false; + this.session.runStartedAt = nowMs(); + this.session.totalDownloadedBytes = 0; + this.sessionCompletedFiles = 0; + this.session.summaryText = ""; + this.session.reconnectUntil = 0; + this.session.reconnectReason = ""; + this.speedEvents = []; + this.speedBytesLastWindow = 0; + this.speedBytesPerPackage.clear(); + this.speedEventsHead = 0; + this.lastGlobalProgressBytes = 0; + this.lastGlobalProgressAt = nowMs(); + this.lastReconnectMarkAt = 0; + this.consecutiveReconnects = 0; + this.globalSpeedLimitQueue = Promise.resolve(); + this.globalSpeedLimitNextAt = 0; + this.summary = null; + this.nonResumableActive = 0; + this.persistSoon(); + this.emitState(true); + logger.info(`Start (nur Pakete: ${packageIds.length}): ${runItems.length} Items`); + void this.ensureScheduler().catch((error) => { + logger.error(`Scheduler abgestürzt: ${compactErrorText(error)}`); + this.session.running = false; + this.session.paused = false; + this.persistSoon(); + this.emitState(true); + }); + } + + public async startItems(itemIds: string[]): Promise<void> { + const targetSet = new Set(itemIds); + + const affectedPackageIds = new Set<string>(); + for (const itemId of targetSet) { + const item = this.session.items[itemId]; + if (item) affectedPackageIds.add(item.packageId); + } + + for (const pkgId of affectedPackageIds) { + const pkg = this.session.packages[pkgId]; + if (pkg && !pkg.cancelled && !pkg.enabled) { + pkg.enabled = true; + } + } + + for (const itemId of targetSet) { + const item = this.session.items[itemId]; + if (!item) continue; + if (item.status === "cancelled" && item.fullStatus === "Gestoppt") { + const pkg = this.session.packages[item.packageId]; + if (pkg && !pkg.cancelled && pkg.enabled) { + item.status = "queued"; + item.fullStatus = "Wartet"; + item.lastError = ""; + item.speedBps = 0; + item.updatedAt = nowMs(); + } + } + } + + if (this.session.running) { + for (const itemId of targetSet) { + const item = this.session.items[itemId]; + if (!item) continue; + const pkg = this.session.packages[item.packageId]; + if (!pkg || pkg.cancelled || !pkg.enabled) continue; + if (item.status === "queued" || item.status === "reconnect_wait") { + this.runItemIds.add(item.id); + this.runPackageIds.add(item.packageId); + } + } + this.persistSoon(); + this.emitState(true); + return; + } + + this.triggerPendingExtractions(); + const runItems = [...targetSet] + .map((id) => this.session.items[id]) + .filter((item) => { + if (!item) return false; + if (item.status !== "queued" && item.status !== "reconnect_wait") return false; + const pkg = this.session.packages[item.packageId]; + return Boolean(pkg && !pkg.cancelled && pkg.enabled); + }); + if (runItems.length === 0) { + this.persistSoon(); + this.emitState(true); + return; + } + this.runItemIds = new Set(runItems.map((item) => item.id)); + this.runPackageIds = new Set(runItems.map((item) => item.packageId)); + this.addTrailingPostProcessPackageIds(this.runPackageIds); + this.runOutcomes.clear(); + this.runCompletedPackages.clear(); + this.retryAfterByItem.clear(); + this.providerStartReservations.clear(); + this.pacedStartReservationByItem.clear(); + this.retryStateByItem.clear(); + this.itemContributedBytes.clear(); + this.reservedTargetPaths.clear(); + this.claimedTargetPathByItem.clear(); + this.session.running = true; + this.session.paused = false; + this.session.runStartedAt = nowMs(); + this.session.totalDownloadedBytes = 0; + this.sessionCompletedFiles = 0; + this.session.summaryText = ""; + this.session.reconnectUntil = 0; + this.session.reconnectReason = ""; + this.speedEvents = []; + this.speedBytesLastWindow = 0; + this.speedBytesPerPackage.clear(); + this.speedEventsHead = 0; + this.lastGlobalProgressBytes = 0; + this.lastGlobalProgressAt = nowMs(); + this.lastReconnectMarkAt = 0; + this.consecutiveReconnects = 0; + this.globalSpeedLimitQueue = Promise.resolve(); + this.globalSpeedLimitNextAt = 0; + this.summary = null; + this.nonResumableActive = 0; + this.persistSoon(); + this.emitState(true); + logger.info(`Start (nur Items: ${itemIds.length}): ${runItems.length} Items`); + void this.ensureScheduler().catch((error) => { + logger.error(`Scheduler abgestürzt: ${compactErrorText(error)}`); + this.session.running = false; + this.session.paused = false; + this.persistSoon(); + this.emitState(true); + }); + } + + public async start(options?: { excludePackageIds?: ReadonlySet<string> }): Promise<void> { + if (this.session.running) { + return; + } + this.schedulerGeneration += 1; + + this.session.running = true; + + const recoveredItems = await this.recoverRetryableItems("start"); + + await sleep(0); + + let recoveredStoppedItems = 0; + for (const item of Object.values(this.session.items)) { + if (item.status !== "cancelled" || item.fullStatus !== "Gestoppt") { + continue; + } + const pkg = this.session.packages[item.packageId]; + if (!pkg || pkg.cancelled || !pkg.enabled) { + continue; + } + item.status = "queued"; + item.fullStatus = "Wartet"; + item.lastError = ""; + item.speedBps = 0; + item.updatedAt = nowMs(); + recoveredStoppedItems += 1; + } + + if (recoveredItems > 0 || recoveredStoppedItems > 0) { + this.persistSoon(); + this.emitState(true); + } + + this.triggerPendingExtractions(); + + const runItems = Object.values(this.session.items) + .filter((item) => { + if (item.status !== "queued" && item.status !== "reconnect_wait") { + return false; + } + if (options?.excludePackageIds?.has(item.packageId)) { + return false; + } + const pkg = this.session.packages[item.packageId]; + return Boolean(pkg && !pkg.cancelled && pkg.enabled); + }); + if (runItems.length === 0) { + if (this.packagePostProcessTasks.size > 0) { + this.runItemIds.clear(); + this.runPackageIds.clear(); + this.addTrailingPostProcessPackageIds(this.runPackageIds); + this.runOutcomes.clear(); + this.runCompletedPackages.clear(); + this.session.running = true; + this.session.paused = false; + this.session.runStartedAt = this.session.runStartedAt || nowMs(); + this.persistSoon(); + this.emitState(true); + void this.ensureScheduler().catch((error) => { + logger.error(`Scheduler abgestürzt: ${compactErrorText(error)}`); + this.session.running = false; + this.session.paused = false; + this.persistSoon(); + this.emitState(true); + }); + return; + } + this.runItemIds.clear(); + this.runPackageIds.clear(); + this.addTrailingPostProcessPackageIds(this.runPackageIds); + this.runOutcomes.clear(); + this.runCompletedPackages.clear(); + this.retryAfterByItem.clear(); + this.providerStartReservations.clear(); + this.pacedStartReservationByItem.clear(); + this.retryStateByItem.clear(); + this.reservedTargetPaths.clear(); + this.claimedTargetPathByItem.clear(); + this.session.running = false; + this.session.paused = false; + this.session.runStartedAt = 0; + this.session.totalDownloadedBytes = 0; + this.sessionCompletedFiles = 0; + this.session.summaryText = ""; + this.session.reconnectUntil = 0; + this.session.reconnectReason = ""; + this.speedEvents = []; + this.speedBytesLastWindow = 0; + this.speedBytesPerPackage.clear(); + this.speedEventsHead = 0; + this.lastGlobalProgressBytes = 0; + this.lastGlobalProgressAt = nowMs(); + this.summary = null; + this.nonResumableActive = 0; + this.persistSoon(); + this.emitState(true); + return; + } + this.runItemIds = new Set(runItems.map((item) => item.id)); + this.runPackageIds = new Set(runItems.map((item) => item.packageId)); + this.addTrailingPostProcessPackageIds(this.runPackageIds); + this.runOutcomes.clear(); + this.runCompletedPackages.clear(); + this.retryAfterByItem.clear(); + this.providerStartReservations.clear(); + this.pacedStartReservationByItem.clear(); + this.retryStateByItem.clear(); + this.http416FreshRestartByItem.clear(); + this.itemContributedBytes.clear(); + this.reservedTargetPaths.clear(); + this.claimedTargetPathByItem.clear(); + if (options?.excludePackageIds) { + for (const excluded of options.excludePackageIds) { + this.runPackageIds.delete(excluded); + } + } + + this.session.running = true; + this.session.paused = false; + this.session.runStartedAt = nowMs(); + this.session.totalDownloadedBytes = 0; + this.sessionCompletedFiles = 0; + this.session.summaryText = ""; + this.session.reconnectUntil = 0; + this.session.reconnectReason = ""; + this.lastReconnectMarkAt = 0; + this.consecutiveReconnects = 0; + this.speedEvents = []; + this.speedBytesLastWindow = 0; + this.speedBytesPerPackage.clear(); + this.speedEventsHead = 0; + this.lastGlobalProgressBytes = 0; + this.lastGlobalProgressAt = nowMs(); + this.globalSpeedLimitQueue = Promise.resolve(); + this.globalSpeedLimitNextAt = 0; + this.summary = null; + this.nonResumableActive = 0; + this.persistSoon(); + this.emitState(true); + void this.ensureScheduler().catch((error) => { + logger.error(`Scheduler abgestürzt: ${compactErrorText(error)}`); + this.session.running = false; + this.session.paused = false; + this.persistSoon(); + this.emitState(true); + }); + } + + public stop(options?: { parkForRestart?: boolean }): void { + const parkForRestart = options?.parkForRestart === true; + const abortReason: "stop" | "shutdown" = parkForRestart ? "shutdown" : "stop"; + const keepExtraction = this.settings.autoExtractWhenStopped; + const wasRunning = this.session.running; + this.schedulerGeneration += 1; + this.session.running = false; + this.session.paused = false; + this.session.reconnectUntil = 0; + this.session.reconnectReason = ""; + this.retryAfterByItem.clear(); + this.providerStartReservations.clear(); + this.pacedStartReservationByItem.clear(); + this.retryStateByItem.clear(); + this.lastGlobalProgressBytes = this.session.totalDownloadedBytes; + this.lastGlobalProgressAt = nowMs(); + this.speedEvents = []; + this.speedBytesLastWindow = 0; + this.speedBytesPerPackage.clear(); + this.speedEventsHead = 0; + if (!keepExtraction) { + this.abortPostProcessing("stop"); + for (const waiter of this.packagePostProcessWaiters) { waiter.resolve(); } + this.packagePostProcessWaiters = []; + this.packagePostProcessActive = 0; + } + for (const active of this.activeTasks.values()) { + active.abortReason = abortReason; + active.abortController.abort(abortReason); + } + for (const item of Object.values(this.session.items)) { + if (!isFinishedStatus(item.status)) { + item.status = "queued"; + item.speedBps = 0; + const pkg = this.session.packages[item.packageId]; + item.fullStatus = pkg && !pkg.enabled ? "Paket gestoppt" : "Wartet"; + item.updatedAt = nowMs(); + } + } + for (const pkg of Object.values(this.session.packages)) { + if (keepExtraction && (pkg.status === "extracting" || pkg.status === "integrity_check")) { + continue; + } + if (pkg.status === "downloading" || pkg.status === "validating" + || pkg.status === "extracting" || pkg.status === "integrity_check" + || pkg.status === "paused" || pkg.status === "reconnect_wait") { + pkg.status = "queued"; + pkg.updatedAt = nowMs(); + } + } + // A manual stop ends the run without ever reaching finishRun (the scheduler + // loop exits at its while condition), so the run summary would be lost. + // Suppressed for the restart/shutdown path: the process is about to die. + if (wasRunning && !parkForRestart && this.settings.notifyOnRunFinished && this.runItemIds.size > 0) { + const outcomes = Array.from(this.runOutcomes.values()); + const success = outcomes.filter((s) => s === "completed").length; + const failed = outcomes.filter((s) => s === "failed").length; + void sendNotification(this.settings.notifyUrl, { + title: "⏹️ Durchlauf gestoppt", + message: `${success}/${this.runItemIds.size} erfolgreich, ${failed} fehlgeschlagen — Rest zurueck in der Warteschlange`, + mention: this.settings.notifyMention + }); + } + this.persistSoon(); + this.emitState(true); + } + + public prepareForShutdown(): void { + logger.info(`Shutdown-Vorbereitung gestartet: active=${this.activeTasks.size}, running=${this.session.running}, paused=${this.session.paused}`); + this.rotationListenerActive = false; + this.clearPersistTimer(); + if (this.stateEmitTimer) { + clearTimeout(this.stateEmitTimer); + this.stateEmitTimer = null; + } + this.session.running = false; + this.session.paused = false; + this.session.reconnectUntil = 0; + this.session.reconnectReason = ""; + this.lastGlobalProgressBytes = this.session.totalDownloadedBytes; + this.lastGlobalProgressAt = nowMs(); + this.abortPostProcessing("shutdown"); + + let requeuedItems = 0; + for (const active of this.activeTasks.values()) { + const item = this.session.items[active.itemId]; + if (item && !isFinishedStatus(item.status)) { + item.status = "queued"; + item.speedBps = 0; + const pkg = this.session.packages[item.packageId]; + item.fullStatus = pkg && !pkg.enabled ? "Paket gestoppt" : "Wartet"; + item.updatedAt = nowMs(); + requeuedItems += 1; + } + active.abortReason = "shutdown"; + active.abortController.abort("shutdown"); + } + + for (const pkg of Object.values(this.session.packages)) { + if (pkg.status === "downloading" + || pkg.status === "validating" + || pkg.status === "extracting" + || pkg.status === "integrity_check" + || pkg.status === "paused" + || pkg.status === "reconnect_wait") { + pkg.status = pkg.enabled ? "queued" : "paused"; + pkg.updatedAt = nowMs(); + } + } + + for (const item of Object.values(this.session.items)) { + if (item.status !== "completed") continue; + const fullSt = item.fullStatus || ""; + if (/^Entpacken\b/i.test(fullSt) && !/Ausstehend/i.test(fullSt) && !/Warten/i.test(fullSt) && !isExtractedLabel(fullSt)) { + item.fullStatus = "Entpacken abgebrochen (wird fortgesetzt)"; + item.updatedAt = nowMs(); + const pkg = this.session.packages[item.packageId]; + if (pkg) { + pkg.status = pkg.enabled ? "queued" : "paused"; + pkg.updatedAt = nowMs(); + } + } + } + + this.speedEvents = []; + this.speedBytesLastWindow = 0; + this.speedBytesPerPackage.clear(); + this.speedEventsHead = 0; + this.runItemIds.clear(); + this.runPackageIds.clear(); + this.runOutcomes.clear(); + this.runCompletedPackages.clear(); + this.retryAfterByItem.clear(); + this.providerStartReservations.clear(); + this.pacedStartReservationByItem.clear(); + this.nonResumableActive = 0; + this.session.summaryText = ""; + if (!this.skipShutdownPersist) { + const pkgCount = Object.keys(this.session.packages).length; + const itemCount = Object.keys(this.session.items).length; + logger.info(`Shutdown-Save: ${pkgCount} Pakete, ${itemCount} Items`); + this.foldRuntimeIntoSettings(nowMs()); + if (!this.guardBlocksSessionSave()) { + saveSession(this.storagePaths, this.session); + } + saveSettings(this.storagePaths, this.settings); + } else { + logger.info(`Shutdown-Save übersprungen: skipShutdownPersist=${this.skipShutdownPersist}, blockAllPersistence=${this.blockAllPersistence}`); + } + this.emitState(true); + logger.info(`Shutdown-Vorbereitung beendet: requeued=${requeuedItems}`); + } + + public togglePause(): boolean { + if (!this.session.running) { + return false; + } + const wasPaused = this.session.paused; + this.session.paused = !this.session.paused; + + if (!wasPaused && this.session.paused) { + this.speedEvents = []; + this.speedBytesLastWindow = 0; + this.speedBytesPerPackage.clear(); + this.speedEventsHead = 0; + } + + if (wasPaused && !this.session.paused) { + this.retryAfterByItem.clear(); + this.providerStartReservations.clear(); + this.pacedStartReservationByItem.clear(); + this.providerFailures.clear(); + + const now = nowMs(); + for (const active of this.activeTasks.values()) { + if (active.abortController.signal.aborted) { + continue; + } + const item = this.session.items[active.itemId]; + if (!item) { + continue; + } + const stuckSeconds = item.updatedAt > 0 ? (now - item.updatedAt) / 1000 : 0; + const isStuckValidating = item.status === "validating" && stuckSeconds > 30; + const isStuckDownloading = item.status === "downloading" && item.speedBps === 0 && stuckSeconds > 30; + if (isStuckValidating || isStuckDownloading) { + active.abortReason = "stall"; + active.abortController.abort("stall"); + } + } + + this.triggerPendingExtractions(); + } + + this.persistSoon(); + this.emitState(true); + return this.session.paused; + } + + private normalizeSessionStatuses(): void { + this.session.running = false; + this.session.paused = false; + this.session.reconnectUntil = 0; + this.session.reconnectReason = ""; + + for (const item of Object.values(this.session.items)) { + if (item.provider === "megadebrid") { + item.provider = resolveMegaDebridProvider(this.settings, item.provider); + } + if (item.provider !== "realdebrid" + && item.provider !== "megadebrid" + && item.provider !== "megadebrid-api" + && item.provider !== "megadebrid-web" + && item.provider !== "bestdebrid" + && item.provider !== "alldebrid" + && item.provider !== "ddownload") { + item.provider = null; + } + if (item.status === "cancelled" && item.fullStatus === "Gestoppt") { + item.status = "queued"; + item.fullStatus = "Wartet"; + item.lastError = ""; + item.speedBps = 0; + item.provider = null; + item.updatedAt = nowMs(); + continue; + } + if (item.status === "extracting" || item.status === "integrity_check") { + item.status = "completed"; + item.fullStatus = `Fertig (${humanSize(item.downloadedBytes)})`; + item.speedBps = 0; + item.updatedAt = nowMs(); + } else if (item.status === "downloading" + || item.status === "validating" + || item.status === "paused" + || item.status === "reconnect_wait") { + const preserveRecoveryStatus = shouldPreflightFinalizeItemFromDisk(item); + item.status = "queued"; + if (preserveRecoveryStatus) { + item.fullStatus = (item.fullStatus || "").trim() || "Wartet"; + } else { + const itemPkg = this.session.packages[item.packageId]; + item.fullStatus = (itemPkg && itemPkg.enabled === false) ? "Paket gestoppt" : "Wartet"; + } + item.speedBps = 0; + item.updatedAt = nowMs(); + } + if (item.status === "queued") { + const statusText = (item.fullStatus || "").trim(); + if (statusText !== "Wartet" + && statusText !== "Paket gestoppt" + && statusText !== "Online" + && !shouldPreflightFinalizeItemFromDisk(item)) { + item.fullStatus = "Wartet"; + } + } + if (item.onlineStatus === "checking") { + item.onlineStatus = undefined; + } + if (item.status === "completed") { + const statusText = (item.fullStatus || "").trim(); + if (/^Entpacken\b/i.test(statusText) || isExtractErrorLabel(statusText) || isExtractedLabel(statusText) || /^Fertig\b/i.test(statusText)) { + } else { + item.fullStatus = this.settings.autoExtract + ? "Entpacken - Ausstehend" + : `Fertig (${humanSize(item.downloadedBytes)})`; + } + } + } + for (const pkg of Object.values(this.session.packages)) { + if (pkg.enabled === undefined) { + pkg.enabled = true; + } + if (!pkg.priority) { + pkg.priority = "normal"; + } + if (pkg.status === "downloading" + || pkg.status === "validating" + || pkg.status === "extracting" + || pkg.status === "integrity_check" + || pkg.status === "paused" + || pkg.status === "reconnect_wait") { + pkg.status = "queued"; + } + + const items = pkg.itemIds + .map((itemId) => this.session.items[itemId]) + .filter(Boolean) as DownloadItem[]; + if (items.length === 0) { + continue; + } + + const hasPending = items.some((item) => ( + item.status === "queued" + || item.status === "reconnect_wait" + )); + if (hasPending) { + pkg.status = pkg.enabled ? "queued" : "paused"; + continue; + } + + const success = items.filter((item) => item.status === "completed").length; + const failed = items.filter((item) => item.status === "failed").length; + const cancelled = items.filter((item) => item.status === "cancelled").length; + + if (failed > 0) { + pkg.status = "failed"; + } else if (cancelled > 0) { + pkg.status = success > 0 ? "completed" : "cancelled"; + } else if (success > 0) { + pkg.status = "completed"; + } + } + this.resetSessionTotalsIfQueueEmpty(true); + this.persistSoon(); + } + + private applyOnStartCleanupPolicy(): void { + if (this.settings.completedCleanupPolicy !== "on_start") { + return; + } + logger.info(`applyOnStartCleanupPolicy: ${Object.keys(this.session.packages).length} Pakete, ${Object.keys(this.session.items).length} Items vor Bereinigung`); + for (const pkgId of [...this.session.packageOrder]) { + const pkg = this.session.packages[pkgId]; + if (!pkg) { + continue; + } + const completedItemIds: string[] = []; + pkg.itemIds = pkg.itemIds.filter((itemId) => { + const item = this.session.items[itemId]; + if (!item) { + return false; + } + if (item.status === "completed") { + if (this.settings.autoExtract && !isExtractedLabel(item.fullStatus || "")) { + return true; + } + completedItemIds.push(itemId); + return false; + } + return true; + }); + if (pkg.itemIds.length === 0) { + logger.info(`applyOnStartCleanupPolicy: entferne Paket ${pkg.name} (${completedItemIds.length} completed Items)`); + this.removePackageFromSession(pkgId, completedItemIds); + } else { + if (completedItemIds.length > 0) { + logger.info(`applyOnStartCleanupPolicy: entferne ${completedItemIds.length} completed Items aus Paket ${pkg.name} (${pkg.itemIds.length} Items verbleiben)`); + } + for (const itemId of completedItemIds) { + delete this.session.items[itemId]; + this.itemCount = Math.max(0, this.itemCount - 1); + } + } + } + logger.info(`applyOnStartCleanupPolicy: ${Object.keys(this.session.packages).length} Pakete, ${Object.keys(this.session.items).length} Items nach Bereinigung`); + } + + private applyRetroactiveCleanupPolicy(): void { + const policy = this.settings.completedCleanupPolicy; + if (policy === "never") return; + + let removed = 0; + for (const pkgId of [...this.session.packageOrder]) { + const pkg = this.session.packages[pkgId]; + if (!pkg) continue; + + if (policy === "immediate") { + const completedItemIds = pkg.itemIds.filter((itemId) => { + const item = this.session.items[itemId]; + if (!item || item.status !== "completed") return false; + if (this.settings.autoExtract) return isExtractedLabel(item.fullStatus || ""); + return true; + }); + for (const itemId of completedItemIds) { + pkg.itemIds = pkg.itemIds.filter((id) => id !== itemId); + this.releaseTargetPath(itemId); + this.dropItemContribution(itemId); + this.retryAfterByItem.delete(itemId); + this.retryStateByItem.delete(itemId); + removed += 1; + } + if (pkg.itemIds.length === 0) { + this.removePackageFromSession(pkgId, completedItemIds); + } else { + for (const itemId of completedItemIds) { + delete this.session.items[itemId]; + this.itemCount = Math.max(0, this.itemCount - 1); + } + } + } else if (policy === "package_done" || policy === "on_start") { + const allCompleted = pkg.itemIds.every((id) => { + const item = this.session.items[id]; + return !item || item.status === "completed" || item.status === "failed" || item.status === "cancelled"; + }); + if (!allCompleted) continue; + if (this.settings.autoExtract) { + const allExtracted = pkg.itemIds.every((id) => { + const item = this.session.items[id]; + if (!item) return true; + if (item.status === "failed" || item.status === "cancelled") return true; + return isExtractedLabel(item.fullStatus || ""); + }); + if (!allExtracted) continue; + } + removed += pkg.itemIds.length; + this.removePackageFromSession(pkgId, [...pkg.itemIds], "completed"); + } + } + if (removed > 0) { + logger.info(`Retroaktive Bereinigung: ${removed} fertige Items entfernt (policy=${policy})`); + this.persistSoon(); + } + } + + public clearPersistTimer(): void { + if (!this.persistTimer) { + return; + } + clearTimeout(this.persistTimer); + this.persistTimer = null; + } + + private persistSoon(): void { + if (this.persistTimer || this.blockAllPersistence) { + return; + } + + const itemCount = this.itemCount; + const minGapMs = this.session.running + ? itemCount >= 1500 + ? 3000 + : itemCount >= 700 + ? 2200 + : itemCount >= 250 + ? 1500 + : 700 + : 300; + const sinceLastPersist = nowMs() - this.lastPersistAt; + const delay = Math.max(120, minGapMs - sinceLastPersist); + + this.persistTimer = setTimeout(() => { + this.persistTimer = null; + this.persistNow(); + }, delay); + } + + private guardBlocksSessionSave(): boolean { + if (!this.protectAgainstEmptyClobber) { + return false; + } + const isEmpty = Object.keys(this.session.packages).length === 0 && Object.keys(this.session.items).length === 0; + if (isEmpty) { + if (!this.emptyClobberProtectionLogged) { + logger.warn("Leere Session-Speicherung uebersprungen (Schutz nach unlesbarem Start) — vorhandene Datei bleibt unangetastet"); + this.emptyClobberProtectionLogged = true; + } + return true; + } + this.protectAgainstEmptyClobber = false; + logger.info("Session-Schutz aufgehoben: nicht-leere Session wird wieder normal gespeichert"); + return false; + } + + private persistNow(): void { + const now = nowMs(); + this.lastPersistAt = now; + if (!this.guardBlocksSessionSave()) { + void saveSessionAsync(this.storagePaths, this.session).catch((err) => logger.warn(`saveSessionAsync Fehler: ${compactErrorText(err)}`)); + } + if (now - this.lastSettingsPersistAt >= 30000) { + this.foldRuntimeIntoSettings(now); + this.lastSettingsPersistAt = now; + void saveSettingsAsync(this.storagePaths, this.settings).catch((err) => logger.warn(`saveSettingsAsync Fehler: ${compactErrorText(err as Error)}`)); + } + } + + public persistNowSync(): void { + this.clearPersistTimer(); + const pkgCount = Object.keys(this.session.packages).length; + const itemCount = Object.keys(this.session.items).length; + logger.info(`Pre-Update Sync-Save: ${pkgCount} Pakete, ${itemCount} Items`); + this.foldRuntimeIntoSettings(nowMs()); + if (!this.guardBlocksSessionSave()) { + saveSession(this.storagePaths, this.session); + } + saveSettings(this.storagePaths, this.settings); + } + + private emitState(force = false): void { + const now = nowMs(); + const MIN_FORCE_GAP_MS = 120; + if (force) { + const sinceLastEmit = now - this.lastStateEmitAt; + if (sinceLastEmit >= MIN_FORCE_GAP_MS) { + if (this.stateEmitTimer) { + clearTimeout(this.stateEmitTimer); + this.stateEmitTimer = null; + } + this.lastStateEmitAt = now; + this.emit("state", this.getSnapshotForEmit()); + return; + } + if (this.stateEmitTimer) { + clearTimeout(this.stateEmitTimer); + this.stateEmitTimer = null; + } + this.stateEmitTimer = setTimeout(() => { + this.stateEmitTimer = null; + this.lastStateEmitAt = nowMs(); + this.emit("state", this.getSnapshotForEmit()); + }, MIN_FORCE_GAP_MS - sinceLastEmit); + return; + } + if (this.stateEmitTimer) { + return; + } + const itemCount = this.itemCount; + const emitDelay = this.session.running + ? itemCount >= 1500 + ? 700 + : itemCount >= 700 + ? 500 + : itemCount >= 250 + ? 300 + : 150 + : 200; + this.stateEmitTimer = setTimeout(() => { + this.stateEmitTimer = null; + this.lastStateEmitAt = nowMs(); + this.emit("state", this.getSnapshotForEmit()); + }, emitDelay); + } + + private speedEventsHead = 0; + private speedBytesPerPackage = new Map<string, number>(); + + private pruneSpeedEvents(now: number): void { + const cutoff = now - SPEED_WINDOW_SECONDS * 1000; + while (this.speedEventsHead < this.speedEvents.length && this.speedEvents[this.speedEventsHead].at < cutoff) { + const ev = this.speedEvents[this.speedEventsHead]; + this.speedBytesLastWindow = Math.max(0, this.speedBytesLastWindow - ev.bytes); + const pkgBytes = (this.speedBytesPerPackage.get(ev.pid) ?? 0) - ev.bytes; + if (pkgBytes <= 0) this.speedBytesPerPackage.delete(ev.pid); + else this.speedBytesPerPackage.set(ev.pid, pkgBytes); + this.speedEventsHead += 1; + } + if (this.speedEventsHead > 200) { + this.speedEvents = this.speedEvents.slice(this.speedEventsHead); + this.speedEventsHead = 0; + } + } + + private lastSpeedPruneAt = 0; + + private recordSpeed(bytes: number, packageId: string = ""): void { + const now = nowMs(); + if (bytes > 0 && this.consecutiveReconnects > 0) { + this.consecutiveReconnects = 0; + } + const bucket = now - (now % 120); + const last = this.speedEvents[this.speedEvents.length - 1]; + if (last && last.at === bucket && last.pid === packageId) { + last.bytes += bytes; + } else { + this.speedEvents.push({ at: bucket, bytes, pid: packageId }); + } + this.speedBytesLastWindow += bytes; + this.speedBytesPerPackage.set(packageId, (this.speedBytesPerPackage.get(packageId) ?? 0) + bytes); + if (now - this.lastSpeedPruneAt >= 1500) { + this.pruneSpeedEvents(now); + this.lastSpeedPruneAt = now; + } + } + + private recordRunOutcome(itemId: string, status: "completed" | "failed" | "cancelled"): void { + if (!this.runItemIds.has(itemId)) { + return; + } + const previous = this.runOutcomes.get(itemId); + this.runOutcomes.set(itemId, status); + if (status === "completed" && previous !== "completed") { + this.sessionCompletedFiles += 1; + this.settings.totalCompletedFilesAllTime = Math.max(0, Number(this.settings.totalCompletedFilesAllTime || 0)) + 1; + this.invalidateStatsCache(); + } + } + + private dropItemContribution(itemId: string): void { + // NOTE: deliberately does NOT subtract from session.totalDownloadedBytes / + // sessionDownloadedBytes. Those are cumulative-session counters and must stay + // put when a completed item is removed from the queue (see the test "keeps + // cumulative session totals when completed items are removed from the queue"). + // The retry path subtracts on its own because those bytes get re-downloaded. + this.itemContributedBytes.delete(itemId); + this.invalidateStatsCache(); + } + + private claimTargetPath(itemId: string, preferredPath: string, allowExistingFile = false): string { + const preferredKey = pathKey(preferredPath); + const existingClaim = this.claimedTargetPathByItem.get(itemId); + if (existingClaim) { + const existingKey = pathKey(existingClaim); + const owner = this.reservedTargetPaths.get(existingKey); + if (owner === itemId) { + if (existingKey === preferredKey) { + return existingClaim; + } + this.reservedTargetPaths.delete(existingKey); + } + this.claimedTargetPathByItem.delete(itemId); + } + + const parsed = path.parse(preferredPath); + const maxIndex = 10000; + for (let index = 0; index <= maxIndex; index += 1) { + const candidate = index === 0 + ? preferredPath + : path.join(parsed.dir, `${parsed.name} (${index})${parsed.ext}`); + const key = index === 0 + ? preferredKey + : pathKey(candidate); + const owner = this.reservedTargetPaths.get(key); + const existsOnDisk = fs.existsSync(candidate); + const allowExistingCandidate = allowExistingFile && index === 0; + if ((!owner || owner === itemId) && (owner === itemId || !existsOnDisk || allowExistingCandidate)) { + this.reservedTargetPaths.set(key, itemId); + this.claimedTargetPathByItem.set(itemId, candidate); + return candidate; + } + } + logger.error(`claimTargetPath: Limit erreicht für ${preferredPath}`); + const fallbackPath = path.join(parsed.dir, `${parsed.name} (${Date.now()})${parsed.ext}`); + this.reservedTargetPaths.set(pathKey(fallbackPath), itemId); + this.claimedTargetPathByItem.set(itemId, fallbackPath); + return fallbackPath; + } + + private releaseTargetPath(itemId: string): void { + const claimedPath = this.claimedTargetPathByItem.get(itemId); + if (!claimedPath) { + return; + } + const key = pathKey(claimedPath); + const owner = this.reservedTargetPaths.get(key); + if (owner === itemId) { + this.reservedTargetPaths.delete(key); + } + this.claimedTargetPathByItem.delete(itemId); + } + + private restoreTargetPathReservations(): void { + let restored = 0; + let droppedUnsafe = 0; + for (const item of Object.values(this.session.items)) { + const pkg = this.session.packages[item.packageId]; + if (!pkg) { + continue; + } + const tp = String(item.targetPath || "").trim(); + if (!tp) continue; + if (!isPathInsideDir(tp, pkg.outputDir)) { + droppedUnsafe += 1; + item.targetPath = ""; + continue; + } + const key = pathKey(tp); + if (!this.reservedTargetPaths.has(key)) { + this.reservedTargetPaths.set(key, item.id); + this.claimedTargetPathByItem.set(item.id, tp); + restored += 1; + } + } + if (restored > 0) { + logger.info(`restoreTargetPathReservations: ${restored} Pfade aus Session wiederhergestellt`); + } + if (droppedUnsafe > 0) { + logger.warn(`restoreTargetPathReservations: ${droppedUnsafe} unsichere targetPath-Eintraege verworfen`); + } + this.reconcileDuplicateSuffixSessionItems(); + this.fixDuplicateSuffixFiles(); + } + + private reconcileDuplicateSuffixSessionItems(): void { + let merged = 0; + const touchedPackageIds = new Set<string>(); + + for (const packageId of this.session.packageOrder) { + const pkg = this.session.packages[packageId]; + if (!pkg) { + continue; + } + + for (const itemId of [...pkg.itemIds]) { + const duplicateItem = this.session.items[itemId]; + if (!duplicateItem) { + continue; + } + const duplicateTargetPath = String(duplicateItem.targetPath || "").trim(); + if (!duplicateTargetPath) { + continue; + } + const duplicateBaseName = path.basename(duplicateTargetPath); + if (!hasDuplicateSuffixBeforeExtension(duplicateBaseName)) { + continue; + } + + const canonicalBaseName = stripDuplicateSuffixBeforeExtension(duplicateBaseName); + const canonicalPath = path.join(path.dirname(duplicateTargetPath), canonicalBaseName); + const canonicalKey = pathKey(canonicalPath); + let primaryItem = Object.values(this.session.items).find((candidate) => + candidate.packageId === packageId + && candidate.id !== duplicateItem.id + && ( + pathKey(String(candidate.targetPath || "")) === canonicalKey + || ( + !candidate.targetPath + && stripDuplicateSuffixBeforeExtension(candidate.fileName || "") === canonicalBaseName + ) + ) + ); + if (!primaryItem) { + continue; + } + + const duplicateExists = fs.existsSync(duplicateTargetPath); + let canonicalExists = fs.existsSync(canonicalPath); + const primaryWins = startupDuplicateStateRank(primaryItem, canonicalExists) >= startupDuplicateStateRank(duplicateItem, duplicateExists); + + if (duplicateExists && !canonicalExists) { + try { + fs.renameSync(duplicateTargetPath, canonicalPath); + canonicalExists = true; + this.logVerifiedRenameSync("startup-dedup", duplicateTargetPath, canonicalPath); + logger.info(`startupDuplicateMerge: ${path.basename(duplicateTargetPath)} → ${canonicalBaseName}`); + } catch (err) { + logDesktopRename("ERROR", "startup-dedup: Rename fehlgeschlagen", { + source: path.basename(duplicateTargetPath), + target: canonicalBaseName, + error: compactErrorText(err) + }); + logger.warn(`startupDuplicateMerge: Umbenennung fehlgeschlagen ${duplicateTargetPath}: ${compactErrorText(err)}`); + } + } else if (duplicateExists && canonicalExists && primaryWins) { + try { + fs.rmSync(duplicateTargetPath, { force: true }); + } catch { + } + } else if (duplicateExists && canonicalExists && !primaryWins && primaryItem.status !== "completed") { + let canonicalSize = -1; + let duplicateSize = -1; + try { canonicalSize = fs.statSync(canonicalPath).size; } catch { } + try { duplicateSize = fs.statSync(duplicateTargetPath).size; } catch { } + if (canonicalSize >= 0 && duplicateSize >= 0 && canonicalSize >= duplicateSize) { + try { + fs.rmSync(duplicateTargetPath, { force: true }); + } catch { + } + logger.info(`startupDuplicateMerge: kanonische Datei behalten (${canonicalSize}B >= Duplikat ${duplicateSize}B), Duplikat verworfen: ${canonicalBaseName}`); + } else { + const dedupBackupPath = `${canonicalPath}.dedupbak`; + try { + fs.renameSync(canonicalPath, dedupBackupPath); + try { + fs.renameSync(duplicateTargetPath, canonicalPath); + try { fs.rmSync(dedupBackupPath, { force: true }); } catch { } + canonicalExists = true; + this.logVerifiedRenameSync("startup-dedup (Austausch)", duplicateTargetPath, canonicalPath); + logger.info(`startupDuplicateMerge: ersetze verwaisten Originalpfad ${canonicalBaseName} durch ${path.basename(duplicateTargetPath)}`); + } catch (swapErr) { + try { fs.renameSync(dedupBackupPath, canonicalPath); } catch { } + throw swapErr; + } + } catch (err) { + logDesktopRename("ERROR", "startup-dedup (Austausch): Rename fehlgeschlagen", { + source: path.basename(duplicateTargetPath), + target: canonicalBaseName, + error: compactErrorText(err) + }); + logger.warn(`startupDuplicateMerge: Austausch fehlgeschlagen ${canonicalPath}: ${compactErrorText(err)}`); + } + } + } + + const duplicateShouldWin = !primaryWins || (duplicateItem.status === "completed" && primaryItem.status !== "completed"); + if (duplicateShouldWin) { + primaryItem.status = duplicateItem.status; + primaryItem.fullStatus = duplicateItem.fullStatus; + primaryItem.lastError = duplicateItem.lastError; + primaryItem.downloadedBytes = Math.max(Number(primaryItem.downloadedBytes || 0), Number(duplicateItem.downloadedBytes || 0)); + primaryItem.totalBytes = Math.max(Number(primaryItem.totalBytes || 0), Number(duplicateItem.totalBytes || 0)) || primaryItem.totalBytes; + primaryItem.progressPercent = Math.max(Number(primaryItem.progressPercent || 0), Number(duplicateItem.progressPercent || 0)); + } + + if (canonicalExists) { + try { + const stat = fs.statSync(canonicalPath); + primaryItem.downloadedBytes = Math.max(Number(primaryItem.downloadedBytes || 0), stat.size); + if (!primaryItem.totalBytes || primaryItem.totalBytes < stat.size) { + primaryItem.totalBytes = stat.size; + } + if (primaryItem.status === "completed") { + primaryItem.progressPercent = 100; + } + } catch { + } + } + + primaryItem.fileName = canonicalBaseName; + primaryItem.targetPath = canonicalPath; + primaryItem.updatedAt = Math.max(Number(primaryItem.updatedAt || 0), Number(duplicateItem.updatedAt || 0), nowMs()); + this.claimedTargetPathByItem.set(primaryItem.id, canonicalPath); + this.reservedTargetPaths.set(canonicalKey, primaryItem.id); + + this.retryAfterByItem.delete(duplicateItem.id); + this.retryStateByItem.delete(duplicateItem.id); + this.releaseTargetPath(duplicateItem.id); + this.dropItemContribution(duplicateItem.id); + delete this.session.items[duplicateItem.id]; + pkg.itemIds = pkg.itemIds.filter((candidateId) => candidateId !== duplicateItem.id); + this.itemCount = Math.max(0, this.itemCount - 1); + merged += 1; + touchedPackageIds.add(packageId); + } + } + + if (merged > 0) { + for (const packageId of touchedPackageIds) { + const pkg = this.session.packages[packageId]; + if (pkg) { + this.refreshPackageStatus(pkg); + } + } + logger.info(`reconcileDuplicateSuffixSessionItems: ${merged} Duplikat-Items zusammengeführt`); + this.persistSoon(); + } + } + + private revalidateCompletedItems(): void { + let fixed = 0; + const touchedPackageIds = new Set<string>(); + for (const item of Object.values(this.session.items)) { + if (item.status !== "completed") continue; + if (isExtractedLabel(item.fullStatus || "")) continue; + const targetPath = String(item.targetPath || "").trim(); + const archiveLike = isArchiveLikePath(targetPath || item.fileName || ""); + if (archiveLike) { + let statSize: number | null = null; + if (targetPath) { + try { + statSize = fs.statSync(targetPath).size; + } catch { + statSize = null; + } + } + const zeroByteArchive = statSize != null + ? statSize <= 0 + : (item.downloadedBytes <= 0 && item.progressPercent >= 100) || /\b0\s*B\b/i.test(item.fullStatus || ""); + if (zeroByteArchive) { + logger.warn(`revalidateCompleted: ${item.fileName} ist 0B/leer, setze auf queued`); + this.queueItemForRetry(item, { + hardReset: true, + reason: "Wartet (Auto-Retry: 0B-Datei)" + }); + fixed += 1; + touchedPackageIds.add(item.packageId); + continue; + } + } + if (!targetPath || !item.totalBytes || item.totalBytes <= 0) continue; + try { + const stat = fs.statSync(targetPath); + const expectedMinSize = expectedMinBytes(item.totalBytes, isLargeBinaryLikePath(item.fileName || targetPath)); + const persistedShortfall = item.downloadedBytes < expectedMinSize && stat.size >= expectedMinSize; + if (stat.size < expectedMinSize) { + logger.warn(`revalidateCompleted: ${item.fileName} ist nur ${humanSize(stat.size)} statt ${humanSize(item.totalBytes)}, setze auf queued`); + item.status = "queued"; + item.fullStatus = "Wartet"; + item.downloadedBytes = stat.size; + item.progressPercent = Math.floor((stat.size / item.totalBytes) * 100); + item.speedBps = 0; + fixed += 1; + touchedPackageIds.add(item.packageId); + } else if (persistedShortfall) { + logger.warn(`revalidateCompleted: ${item.fileName} wirkt pre-alloc/unvollständig (stat=${humanSize(stat.size)}, bytes=${humanSize(item.downloadedBytes)}, total=${humanSize(item.totalBytes)}), setze auf queued`); + item.status = "queued"; + item.fullStatus = "Wartet (Auto-Recovery: pre-alloc)"; + item.progressPercent = Math.max(0, Math.min(99, Math.floor((Math.max(0, item.downloadedBytes) / item.totalBytes) * 100))); + item.speedBps = 0; + fixed += 1; + touchedPackageIds.add(item.packageId); + } + } catch { + if (archiveLike && this.shouldPreserveMissingCompletedArchiveForStartupRecovery(item)) { + logger.info(`revalidateCompleted: ${item.fileName} Quelle fehlt, belasse fuer Startup-Recovery`); + continue; + } + logger.warn(`revalidateCompleted: ${item.fileName} Datei nicht gefunden, setze auf queued`); + item.status = "queued"; + item.fullStatus = "Wartet"; + item.downloadedBytes = 0; + item.progressPercent = 0; + item.speedBps = 0; + fixed += 1; + touchedPackageIds.add(item.packageId); + } + } + if (fixed > 0) { + for (const packageId of touchedPackageIds) { + const pkg = this.session.packages[packageId]; + if (pkg) { + this.refreshPackageStatus(pkg); + } + } + logger.info(`revalidateCompletedItems: ${fixed} Items korrigiert`); + this.persistSoon(); + } + } + + private shouldPreserveMissingCompletedArchiveForStartupRecovery(item: DownloadItem): boolean { + if (!this.settings.autoExtract) { + return false; + } + const pkg = this.session.packages[item.packageId]; + if (!pkg || pkg.cancelled || pkg.enabled === false) { + return false; + } + const statusText = String(item.fullStatus || "").trim(); + if (isExtractedLabel(statusText) || isExtractErrorLabel(statusText)) { + return false; + } + return /^Fertig\b/i.test(statusText) + || shouldPreserveExtractionResumeLabel(statusText) + || shouldAutoRetryExtraction(statusText); + } + + private tryFinalizeItemFromDisk( + pkg: PackageEntry, + item: DownloadItem, + source: string, + errorText = "" + ): boolean { + const diskState = inspectPackageItemDiskState(pkg, item); + const normalizedError = compactErrorText(errorText).replace(/^Error:\s*/i, ""); + const knownShortfall = item.totalBytes != null && item.totalBytes > 0 + ? Math.max(0, item.totalBytes - diskState.size) + : 0; + const underflowIndicated = normalizedError.includes("download_underflow") + || normalizedError.includes("resume_download_underflow"); + const archiveLikeTarget = String(item.fileName || diskState.diskPath || "").toLowerCase(); + const archiveLike = /(?:\.part\d+\.rar|\.rar|\.r\d{2,3}|\.zip(?:\.\d+)?|\.7z(?:\.\d+)?|\.(?:tar(?:\.(?:gz|bz2|xz))?|tgz|tbz2|txz)|\.\d{3})$/i.test(archiveLikeTarget); + const expectedMinSize = expectedMinBytes(item.totalBytes, isLargeBinaryLikePath(item.fileName || diskState.diskPath || "")); + const looksComplete = diskState.exists + && diskState.fullOnDisk + && ( + diskState.reason === "ok" + || item.progressPercent >= 100 + || item.downloadedBytes >= diskState.minBytes + || (item.totalBytes != null && item.totalBytes > 0 && diskState.size >= expectedMinSize) + ); + if (!looksComplete || (knownShortfall > 0 && (underflowIndicated || archiveLike))) { + return false; + } + + logger.info( + `${source}: ${item.fileName || item.id} ist bereits vollstaendig auf Disk ` + + `(${humanSize(diskState.size)}, erwartet mind. ${humanSize(diskState.minBytes)})` + ); + this.logPackageForItem(item, "INFO", `${source}: Datei bereits vollstaendig`, { + fileSize: diskState.size, + expectedMin: diskState.minBytes, + diskReason: diskState.reason, + error: errorText || undefined + }); + + item.status = "completed"; + item.fullStatus = this.settings.autoExtract + ? "Entpacken - Ausstehend" + : `Fertig (${humanSize(diskState.size)})`; + item.downloadedBytes = diskState.size; + if (!item.totalBytes || item.totalBytes < diskState.size) { + item.totalBytes = diskState.size; + } + item.progressPercent = 100; + item.speedBps = 0; + const finalizedAt = nowMs(); + item.updatedAt = finalizedAt; + this.notePackageDownloadCompleted(pkg, finalizedAt); + pkg.updatedAt = finalizedAt; + this.recordRunOutcome(item.id, "completed"); + + if (this.session.running) { + void this.runPackagePostProcessing(pkg.id).catch((err) => { + logger.warn(`runPackagePostProcessing Fehler (${source}): ${compactErrorText(err)}`); + }).finally(() => { + this.applyCompletedCleanupPolicy(pkg.id, item.id); + this.persistSoon(); + this.emitState(); + }); + } + + this.persistSoon(); + this.emitState(); + this.retryStateByItem.delete(item.id); + return true; + } + + private areAllPackageItemRefsFinished(pkg: PackageEntry): boolean { + return pkg.itemIds.every((itemId) => { + const item = this.session.items[itemId]; + return item != null && isFinishedStatus(item.status); + }); + } + + private shouldCollapseQuickPostProcessRequeue(packageId: string): boolean { + const pkg = this.session.packages[packageId]; + if (!pkg) { + return true; + } + return !this.areAllPackageItemRefsFinished(pkg); + } + + private async findFullExtractArchiveSet(pkg: PackageEntry, completedItems: DownloadItem[]): Promise<Set<string>> { + const relevant = new Set<string>(); + if (!pkg.outputDir || completedItems.length === 0) { + return relevant; + } + + const candidates = await findArchiveCandidates(pkg.outputDir); + for (const candidate of candidates) { + const archiveItems = resolveArchiveItemsFromList(path.basename(candidate), completedItems); + if (archiveItems.length === 0) { + continue; + } + const hasPendingExtract = archiveItems.some((item) => !isExtractedLabel(item.fullStatus || "")); + if (!hasPendingExtract) { + continue; + } + relevant.add(pathKey(candidate)); + } + + return relevant; + } + + private clearHybridArchiveState(packageId: string, archiveKey?: string): void { + if (!archiveKey) { + this.hybridExtractedPaths.delete(packageId); + this.hybridFailedArchives.delete(packageId); + for (const key of this.autoRecoveredForRedownload) { + if (key.startsWith(`${packageId}::`)) this.autoRecoveredForRedownload.delete(key); + } + return; + } + + const normalizedKey = pathKey(archiveKey); + const attempted = this.hybridExtractedPaths.get(packageId); + if (attempted) { + attempted.delete(normalizedKey); + if (attempted.size === 0) { + this.hybridExtractedPaths.delete(packageId); + } + } + + const failed = this.hybridFailedArchives.get(packageId); + if (failed) { + failed.delete(normalizedKey); + if (failed.size === 0) { + this.hybridFailedArchives.delete(packageId); + } + } + } + + private buildHybridArchiveRetryMarker(pkg: PackageEntry, items: DownloadItem[], archiveKey: string): string { + const archiveName = path.basename(archiveKey); + const archiveItems = resolveArchiveItemsFromList(archiveName, items) + .slice() + .sort((left, right) => { + const leftName = (left.fileName || left.targetPath || left.id || "").toLowerCase(); + const rightName = (right.fileName || right.targetPath || right.id || "").toLowerCase(); + return leftName.localeCompare(rightName); + }); + + const itemStates = archiveItems.map((item) => { + const diskState = inspectPackageItemDiskState(pkg, item); + return [ + (item.fileName || item.id || "").toLowerCase(), + item.status, + item.downloadedBytes || 0, + item.totalBytes || 0, + diskState.reason, + diskState.size + ].join("|"); + }); + + return JSON.stringify({ + archiveName: archiveName.toLowerCase(), + passwordList: String(this.settings.archivePasswordList || "").replace(/\r\n|\r/g, "\n").trim(), + itemStates + }); + } + + private autoRecoverArchiveCrcFailure( + pkg: PackageEntry, + items: DownloadItem[], + failure: ExtractArchiveFailureInfo, + scope: "hybrid" | "full" + ): number { + if (!failure.suggestRedownload || (failure.category !== "crc_error" && failure.category !== "wrong_password")) { + return 0; + } + + const archiveItems = resolveArchiveItemsFromList(failure.archiveName, items) + .filter((item) => item.status === "completed"); + if (archiveItems.length === 0) { + logger.warn(`Auto-Recovery (${scope}): Keine completed Items für ${failure.archiveName} gefunden, überspringe`); + return 0; + } + + const inspectedArchiveItems = archiveItems + .map((item) => ({ item, state: inspectPackageItemDiskState(pkg, item) })); + const corruptArchiveItems = inspectedArchiveItems + .filter(({ state }) => state.reason !== "ok"); + + if (corruptArchiveItems.length === 0) { + const firstPart = inspectedArchiveItems.find(({ state }) => state.diskPath); + let hasValidSignature = false; + if (firstPart?.state.diskPath) { + try { + const fd = fs.openSync(firstPart.state.diskPath, "r"); + try { + const header = Buffer.alloc(8); + fs.readSync(fd, header, 0, 8, 0); + hasValidSignature = + (header[0] === 0x52 && header[1] === 0x61 && header[2] === 0x72 && header[3] === 0x21 && header[4] === 0x1a && header[5] === 0x07) || + (header[0] === 0x37 && header[1] === 0x7a && header[2] === 0xbc && header[3] === 0xaf) || + (header[0] === 0x50 && header[1] === 0x4b && header[2] === 0x03 && header[3] === 0x04); + } finally { + fs.closeSync(fd); + } + } catch { } + } + + if (hasValidSignature) { + logger.warn( + `Auto-Recovery (${scope}): ${failure.archiveName} uebersprungen - ` + + `Dateien haben korrekte Groesse und gueltige Archiv-Signatur, ` + + `wahrscheinlicher Passwort-/Extractor-Fall statt defektem Download` + ); + return 0; + } + + logger.warn( + `Auto-Recovery (${scope}): ${failure.archiveName} - Dateien korrekte Groesse aber ungueltige Archiv-Signatur, ` + + `erzwinge Re-Download aller ${archiveItems.length} Parts` + ); + corruptArchiveItems.push(...inspectedArchiveItems); + } + + const queuedAt = nowMs(); + const reason = "Wartet (Auto-Recovery: Archiv beschädigt/unvollständig)"; + let changed = 0; + for (const { item } of corruptArchiveItems) { + const claimedTargetPath = String(item.targetPath || "").trim(); + if (claimedTargetPath) { + try { + fs.rmSync(claimedTargetPath, { force: true }); + } catch { + } + } + this.releaseTargetPath(item.id); + this.dropItemContribution(item.id); + item.targetPath = ""; + item.status = "queued"; + item.attempts = 0; + item.downloadedBytes = 0; + item.progressPercent = 0; + item.speedBps = 0; + item.lastError = failure.errorText; + item.fullStatus = reason; + item.updatedAt = queuedAt; + changed += 1; + } + + if (changed > 0) { + this.clearHybridArchiveState(pkg.id); + pkg.status = (pkg.enabled && this.session.running && !this.session.paused) ? "downloading" : "queued"; + pkg.updatedAt = queuedAt; + const evidence = corruptArchiveItems + .slice(0, 3) + .map(({ item, state }) => `${item.fileName}:${state.reason}`) + .join(", "); + const suffix = corruptArchiveItems.length > 3 ? ` (+${corruptArchiveItems.length - 3} weitere)` : ""; + logger.warn( + `Auto-Recovery (${scope}): ${failure.archiveName} auf queued gesetzt (${changed} Items), ` + + `evidence=${evidence}${suffix}, cause=${compactErrorText(failure.jvmFailureReason || failure.errorText)}` + ); + this.persistSoon(); + this.emitState(); + } + return changed; + } + + private applyPackageExtractFailureStatuses( + completedItems: DownloadItem[], + resolveArchiveItems: (archiveName: string) => DownloadItem[], + failedArchiveErrors: Map<string, string>, + fallbackReason: string, + previousStatuses: Map<string, string>, + appliedAt = nowMs() + ): void { + const affectedItemIds = new Set<string>(); + + for (const [archiveName, errorText] of failedArchiveErrors.entries()) { + const reason = compactErrorText(errorText || fallbackReason || "Entpacken fehlgeschlagen"); + for (const entry of resolveArchiveItems(archiveName)) { + if (entry.status !== "completed" || isExtractedLabel(entry.fullStatus)) { + continue; + } + entry.fullStatus = formatExtractFailureLabel(reason, archiveName); + entry.updatedAt = appliedAt; + affectedItemIds.add(entry.id); + } + } + + let appliedSpecificFailure = affectedItemIds.size > 0; + for (const entry of completedItems) { + if (entry.status !== "completed" || isExtractedLabel(entry.fullStatus)) { + continue; + } + if (affectedItemIds.has(entry.id)) { + continue; + } + + const currentStatus = String(entry.fullStatus || "").trim(); + if (currentStatus === "Entpacken - Error") { + entry.fullStatus = formatExtractFailureLabel(fallbackReason); + entry.updatedAt = appliedAt; + appliedSpecificFailure = true; + continue; + } + + if (isTransientExtractStatus(currentStatus)) { + const previousStatus = String(previousStatuses.get(entry.id) || "").trim(); + entry.fullStatus = isTransientExtractStatus(previousStatus) + ? `Fertig (${humanSize(entry.downloadedBytes)})` + : previousStatus || `Fertig (${humanSize(entry.downloadedBytes)})`; + entry.updatedAt = appliedAt; + } + } + + if (appliedSpecificFailure) { + return; + } + + for (const entry of completedItems) { + if (entry.status === "completed" && !isExtractedLabel(entry.fullStatus)) { + entry.fullStatus = formatExtractFailureLabel(fallbackReason); + entry.updatedAt = appliedAt; + } + } + } + + private async deobfuscateArchiveFiles( + pkg: PackageEntry, + completedItems: DownloadItem[], + signal?: AbortSignal + ): Promise<number> { + const KNOWN_ARCHIVE_EXTS = new Set([ + ".rar", ".zip", ".7z", ".gz", ".bz2", ".xz", ".tgz", ".tbz2", ".txz", + ".tar", ".001", ".002", ".003", ".004", ".005", ".006", ".007", ".008", ".009", + ]); + const isArchiveExt = (ext: string): boolean => { + const lower = ext.toLowerCase(); + if (KNOWN_ARCHIVE_EXTS.has(lower)) return true; + if (/^\.r\d{2}$/.test(lower)) return true; + if (/^\.part\d+\.rar$/.test(lower)) return true; + return false; + }; + + const itemByPath = new Map<string, DownloadItem>(); + for (const item of completedItems) { + if (item.targetPath) { + itemByPath.set(item.targetPath.toLowerCase(), item); + } + } + + const referenceRars: string[] = []; + const suspectFiles: Array<{ item: DownloadItem; filePath: string }> = []; + + for (const item of completedItems) { + if (!item.targetPath || !item.fileName) continue; + if (signal?.aborted) return 0; + const ext = path.extname(item.fileName).toLowerCase(); + const doubleExt = item.fileName.match(/(\.\w+\.\w+)$/)?.[1]?.toLowerCase() || ""; + if (isArchiveExt(ext) || isArchiveExt(doubleExt)) { + if (ext === ".rar") { + referenceRars.push(item.targetPath); + } + continue; + } + suspectFiles.push({ item, filePath: item.targetPath }); + } + + if (suspectFiles.length === 0) return 0; + + let referenceBase = ""; + const partBaseRe = /^(.+?)\.part\d{1,3}\.rar$/i; + for (const rarPath of referenceRars) { + const match = path.basename(rarPath).match(partBaseRe); + if (match) { + referenceBase = match[1]; + break; + } + } + + let fixedCount = 0; + const SIG_TO_EXT: Record<string, string> = { rar: ".rar", "7z": ".7z", zip: ".zip" }; + + for (const { item, filePath } of suspectFiles) { + if (signal?.aborted) break; + try { + const exists = await fs.promises.stat(filePath).then(() => true, () => false); + if (!exists) continue; + + const sig = await detectArchiveSignature(filePath); + if (!sig || !SIG_TO_EXT[sig]) continue; + + const correctExt = SIG_TO_EXT[sig]; + const oldName = path.basename(filePath); + let newName: string; + + const partMatch = oldName.match(/[._-](?:[a-z]*?)part(\d{1,3})\b/i) + || oldName.match(/[._-]r?part(\d{1,3})\b/i); + const partNum = partMatch?.[1]; + + if (referenceBase && partNum) { + const paddedPart = partNum.padStart(2, "0"); + newName = `${referenceBase}.part${paddedPart}${correctExt}`; + } else { + const stem = oldName.replace(/\.[^.]+$/, ""); + newName = `${stem}${correctExt}`; + } + + if (newName === oldName) continue; + + const newPath = path.join(path.dirname(filePath), newName); + const targetExists = await fs.promises.stat(newPath).then(() => true, () => false); + if (targetExists) { + logger.warn(`Deobfuskation: Ziel existiert bereits, ueberspringe: ${newPath}`); + continue; + } + + await fs.promises.rename(filePath, newPath); + this.logVerifiedRenameSync("deobfuskation", filePath, newPath, { paket: pkg.name, signatur: sig }); + item.fileName = newName; + item.targetPath = newPath; + this.releaseTargetPath(item.id); + this.claimTargetPath(item.id, newPath); + fixedCount += 1; + logger.info(`Deobfuskation: ${oldName} -> ${newName} (${sig} erkannt)`); + this.logPackageForPackage(pkg, "INFO", "Archiv-Deobfuskation", { + oldName, + newName, + signature: sig + }); + } catch (err) { + logDesktopRename("ERROR", "deobfuskation: Rename fehlgeschlagen", { + source: path.basename(filePath), + paket: pkg.name, + error: compactErrorText(err as Error) + }); + logger.warn(`Deobfuskation fehlgeschlagen: ${filePath}: ${compactErrorText(err as Error)}`); + } + } + + if (fixedCount > 0) { + logger.info(`Deobfuskation abgeschlossen: pkg=${pkg.name}, ${fixedCount} Datei(en) korrigiert`); + this.persistSoon(); + this.emitState(); + } + return fixedCount; + } + + private async waitForCompletedArchiveFilesToSettle( + pkg: PackageEntry, + items: DownloadItem[], + signal: AbortSignal | undefined, + scope: "hybrid" | "full" + ): Promise<void> { + const archiveItems = items.filter((item) => + item.status === "completed" && isArchiveLikePath(item.targetPath || item.fileName || "") + ); + if (archiveItems.length === 0) { + return; + } + + const startedAt = nowMs(); + const newestCompletionAt = archiveItems.reduce((maxTs, item) => Math.max(maxTs, Number(item.updatedAt || 0)), 0); + const minDelayMs = newestCompletionAt > 0 + ? Math.max(0, ARCHIVE_SETTLE_MIN_DELAY_MS - Math.max(0, startedAt - newestCompletionAt)) + : 0; + + if (minDelayMs > 0) { + logger.info( + `Extract-Settle (${scope}): warte ${minDelayMs}ms nach letztem Downloadabschluss ` + + `vor Entpacken: pkg=${pkg.name}, archiveItems=${archiveItems.length}` + ); + this.logPackageForPackage(pkg, "INFO", "Archiv-Stabilisierung wartet", { + scope, + waitMs: minDelayMs, + archiveItems: archiveItems.length, + reason: "recent_completion" + }); + pkg.postProcessLabel = "Archive stabilisieren..."; + this.emitState(); + let remainingMs = minDelayMs; + while (remainingMs > 0) { + if (signal?.aborted) { + return; + } + const sleepMs = Math.min(ARCHIVE_SETTLE_POLL_MS, remainingMs); + await sleep(sleepMs); + remainingMs -= sleepMs; + } + } + + const deadlineAt = nowMs() + ARCHIVE_SETTLE_MAX_WAIT_MS; + const requiredStableRounds = minDelayMs > 0 ? 2 : 1; + let stableRounds = 0; + let lastSnapshot = ""; + let pollCount = 0; + let lastPending = ""; + + while (stableRounds < requiredStableRounds && nowMs() < deadlineAt) { + if (signal?.aborted) { + return; + } + + const snapshotParts: string[] = []; + const pending: string[] = []; + for (const item of archiveItems) { + const state = inspectPackageItemDiskState(pkg, item); + const label = item.fileName || item.id; + snapshotParts.push(`${item.id}:${state.reason}:${state.size}`); + if (state.reason !== "ok" || !state.diskPath) { + pending.push(`${label}:${state.reason}`); + continue; + } + try { + const fd = await fs.promises.open(state.diskPath, "r"); + await fd.close(); + } catch { + pending.push(`${label}:open_failed`); + } + } + + pollCount += 1; + lastPending = pending.join(", "); + const snapshot = snapshotParts.join("|"); + if (pending.length === 0) { + stableRounds = snapshot === lastSnapshot ? stableRounds + 1 : 1; + } else { + stableRounds = 0; + } + lastSnapshot = snapshot; + + if (stableRounds >= requiredStableRounds) { + break; + } + + await sleep(ARCHIVE_SETTLE_POLL_MS); + } + + const settleMs = nowMs() - startedAt; + if (stableRounds >= requiredStableRounds) { + if (pollCount > 1 || minDelayMs > 0) { + logger.info( + `Extract-Settle (${scope}) abgeschlossen: pkg=${pkg.name}, archiveItems=${archiveItems.length}, ` + + `waitMs=${settleMs}, polls=${pollCount}` + ); + this.logPackageForPackage(pkg, "INFO", "Archiv-Stabilisierung abgeschlossen", { + scope, + archiveItems: archiveItems.length, + waitMs: settleMs, + polls: pollCount + }); + } + return; + } + + logger.warn( + `Extract-Settle (${scope}) Timeout: pkg=${pkg.name}, archiveItems=${archiveItems.length}, ` + + `waitMs=${settleMs}, pending=${lastPending || "none"}` + ); + this.logPackageForPackage(pkg, "WARN", "Archiv-Stabilisierung Timeout", { + scope, + archiveItems: archiveItems.length, + waitMs: settleMs, + pending: lastPending || "none" + }); + } + + private fixDuplicateSuffixFiles(): void { + const SUFFIX_RE = /^(.+) \(\d+\)(\.[^.]+)$/; + let fixed = 0; + for (const item of Object.values(this.session.items)) { + const tp = String(item.targetPath || "").trim(); + if (!tp) continue; + const parsed = path.parse(tp); + const fullName = parsed.name + parsed.ext; + const match = SUFFIX_RE.exec(fullName); + if (!match) continue; + const originalName = match[1] + match[2]; + const originalPath = path.join(parsed.dir, originalName); + const originalKey = pathKey(originalPath); + const originalOwner = this.reservedTargetPaths.get(originalKey); + if (originalOwner && originalOwner !== item.id) continue; + if (!originalOwner && fs.existsSync(originalPath)) continue; + if (!fs.existsSync(tp)) { + this.reservedTargetPaths.delete(pathKey(tp)); + this.reservedTargetPaths.set(originalKey, item.id); + this.claimedTargetPathByItem.set(item.id, originalPath); + item.targetPath = originalPath; + item.fileName = originalName; + fixed += 1; + continue; + } + try { + fs.renameSync(tp, originalPath); + this.logVerifiedRenameSync("suffix-fix", tp, originalPath); + this.reservedTargetPaths.delete(pathKey(tp)); + this.reservedTargetPaths.set(originalKey, item.id); + this.claimedTargetPathByItem.set(item.id, originalPath); + item.targetPath = originalPath; + item.fileName = originalName; + fixed += 1; + logger.info(`fixDuplicateSuffix: ${path.basename(tp)} → ${originalName}`); + } catch (err) { + logDesktopRename("ERROR", "suffix-fix: Rename fehlgeschlagen", { + source: path.basename(tp), + target: originalName, + error: compactErrorText(err) + }); + logger.warn(`fixDuplicateSuffix: Umbenennung fehlgeschlagen ${tp}: ${compactErrorText(err)}`); + } + } + if (fixed > 0) { + logger.info(`fixDuplicateSuffixFiles: ${fixed} Dateien korrigiert`); + this.persistSoon(); + } + } + + private assignItemTargetPath(item: DownloadItem, targetPath: string): string { + const rawTargetPath = String(targetPath || "").trim(); + if (!rawTargetPath) { + this.releaseTargetPath(item.id); + item.targetPath = ""; + return ""; + } + const normalizedTargetPath = path.resolve(rawTargetPath); + const claimed = this.claimTargetPath(item.id, normalizedTargetPath); + item.targetPath = claimed; + return claimed; + } + + private abortPostProcessing(reason: string): void { + for (const [packageId, controller] of this.packagePostProcessAbortControllers.entries()) { + if (!controller.signal.aborted) { + controller.abort(reason); + } + + const pkg = this.session.packages[packageId]; + if (!pkg) { + continue; + } + + if (pkg.status === "extracting" || pkg.status === "integrity_check") { + pkg.status = (pkg.enabled && !this.session.paused) ? "queued" : "paused"; + pkg.updatedAt = nowMs(); + } + + for (const itemId of pkg.itemIds) { + const item = this.session.items[itemId]; + if (!item || item.status !== "completed") { + continue; + } + const ft = (item.fullStatus || "").trim(); + if (/^Entpacken/i.test(ft)) { + if (ft !== "Entpacken - Ausstehend" && ft !== "Entpacken - Warten auf Parts") { + item.fullStatus = "Entpacken abgebrochen (wird fortgesetzt)"; + item.updatedAt = nowMs(); + } + } + } + } + + for (const controller of this.packageDeferredPostProcessAbortControllers.values()) { + if (!controller.signal.aborted) { + controller.abort(reason); + } + } + for (const hybridSet of this.packageHybridPostProcessControllers.values()) { + for (const controller of hybridSet) { + if (!controller.signal.aborted) { + controller.abort(reason); + } + } + } + } + + private async acquirePostProcessSlot(packageId: string): Promise<void> { + const maxConcurrent = Math.max(1, Math.min(8, this.settings.maxParallelExtract || 1)); + if (this.packagePostProcessActive < maxConcurrent) { + this.packagePostProcessActive += 1; + return; + } + await new Promise<void>((resolve) => { + this.packagePostProcessWaiters.push({ packageId, resolve }); + }); + if (this.packagePostProcessActive < maxConcurrent) { + this.packagePostProcessActive += 1; + } + } + + private releasePostProcessSlot(): void { + if (this.packagePostProcessActive <= 0) { + this.packagePostProcessActive = 0; + return; + } + this.packagePostProcessActive -= 1; + if (this.packagePostProcessWaiters.length === 0) return; + const order = this.session.packageOrder; + let bestIdx = 0; + let bestOrder = order.indexOf(this.packagePostProcessWaiters[0].packageId); + if (bestOrder === -1) bestOrder = Infinity; + for (let i = 1; i < this.packagePostProcessWaiters.length; i++) { + let pos = order.indexOf(this.packagePostProcessWaiters[i].packageId); + if (pos === -1) pos = Infinity; + if (pos < bestOrder) { + bestOrder = pos; + bestIdx = i; + } + } + const [next] = this.packagePostProcessWaiters.splice(bestIdx, 1); + next.resolve(); + } + + private runPackagePostProcessing(packageId: string): Promise<void> { + const existing = this.packagePostProcessTasks.get(packageId); + if (existing) { + this.hybridExtractRequeue.add(packageId); + return existing; + } + + const abortController = new AbortController(); + this.packagePostProcessAbortControllers.set(packageId, abortController); + + // Holder so the task's own finally can identity-check itself (the task Promise + // cannot reference its own const inside its initializer). Assigned right after. + const handle: { task?: Promise<void> } = {}; + const task = (async () => { + const slotWaitStart = nowMs(); + await this.acquirePostProcessSlot(packageId); + const slotWaitMs = nowMs() - slotWaitStart; + if (slotWaitMs > 100) { + logger.info(`Post-Process Slot erhalten nach ${(slotWaitMs / 1000).toFixed(1)}s Wartezeit: pkg=${packageId.slice(0, 8)}`); + const pkg = this.session.packages[packageId]; + if (pkg) { + this.logPackageForPackage(pkg, "INFO", "Post-Process-Slot erhalten", { + slotWaitMs + }); + } + } + try { + let round = 0; + do { + round += 1; + const hadRequeue = this.hybridExtractRequeue.has(packageId); + this.hybridExtractRequeue.delete(packageId); + const roundStart = nowMs(); + try { + await this.handlePackagePostProcessing(packageId, abortController.signal); + } catch (error) { + logger.warn(`Post-Processing für Paket fehlgeschlagen: ${compactErrorText(error)}`); + } + const roundMs = nowMs() - roundStart; + logger.info(`Post-Process Runde ${round} fertig in ${(roundMs / 1000).toFixed(1)}s (requeue=${hadRequeue}, nextRequeue=${this.hybridExtractRequeue.has(packageId)}): pkg=${packageId.slice(0, 8)}`); + const pkg = this.session.packages[packageId]; + if (pkg) { + this.logPackageForPackage(pkg, "INFO", "Post-Process-Runde abgeschlossen", { + round, + roundMs, + hadRequeue, + nextRequeue: this.hybridExtractRequeue.has(packageId) + }); + } + this.persistSoon(); + this.emitState(); + if (roundMs < 2000 && this.hybridExtractRequeue.has(packageId)) { + if (this.shouldCollapseQuickPostProcessRequeue(packageId)) { + this.hybridExtractRequeue.delete(packageId); + } + } + } while (this.hybridExtractRequeue.has(packageId)); + } finally { + this.releasePostProcessSlot(); + // Identity guard: only clear the map entries if they still point to THIS + // task/controller. After an abort deletes our handle a new run can install + // a fresh task+controller for the same packageId; a blind delete here would + // orphan that newer task (uncancellable) and allow a duplicate concurrent run. + if (this.packagePostProcessTasks.get(packageId) === handle.task) { + this.packagePostProcessTasks.delete(packageId); + } + if (this.packagePostProcessAbortControllers.get(packageId) === abortController) { + this.packagePostProcessAbortControllers.delete(packageId); + } + this.persistSoon(); + this.emitState(); + if (this.hybridExtractRequeue.delete(packageId)) { + void this.runPackagePostProcessing(packageId).catch((err) => + logger.warn(`runPackagePostProcessing Fehler (hybridRequeue): ${compactErrorText(err)}`) + ); + } + } + })(); + + handle.task = task; + this.packagePostProcessTasks.set(packageId, task); + return task; + } + + private shouldRecoverDeferredPostProcessingOnStartup(pkg: PackageEntry, items: DownloadItem[]): boolean { + if (!this.settings.autoExtract) { + return false; + } + if (this.packagePostProcessTasks.has(pkg.id) || this.hasDeferredPostProcessPending(pkg.id)) { + return false; + } + const hasExtractedCompletedItem = items.some((item) => + item.status === "completed" && isExtractedLabel(item.fullStatus || "") + ); + if (!hasExtractedCompletedItem) { + return false; + } + return this.settings.autoRename4sf4sj + || this.settings.keepGermanAudioOnly + || this.settings.collectMkvToLibrary + || this.settings.removeLinkFilesAfterExtract + || this.settings.removeSamplesAfterExtract + || this.settings.cleanupMode !== "none" + || this.settings.completedCleanupPolicy === "package_done" + || this.settings.completedCleanupPolicy === "immediate"; + } + + private recoverPostProcessingOnStartup(): void { + const packageIds = [...this.session.packageOrder]; + if (packageIds.length === 0) { + return; + } + + let changed = false; + for (const packageId of packageIds) { + const pkg = this.session.packages[packageId]; + if (!pkg || pkg.cancelled || !pkg.enabled) { + continue; + } + + const items = pkg.itemIds.map((id) => this.session.items[id]).filter(Boolean) as DownloadItem[]; + if (items.length === 0) { + continue; + } + + const success = items.filter((item) => item.status === "completed").length; + const failed = items.filter((item) => item.status === "failed").length; + const cancelled = items.filter((item) => item.status === "cancelled").length; + const allDone = this.areAllPackageItemRefsFinished(pkg); + if (!allDone && success + failed + cancelled >= items.length) { + logger.warn( + `Post-Processing wartet trotz gefiltert fertiger Items: ` + + `pkg=${pkg.name}, tracked=${pkg.itemIds.length}, resolved=${items.length}, ` + + `success=${success}, failed=${failed}, cancelled=${cancelled}` + ); + } + + if (!allDone && this.settings.autoExtract && this.settings.hybridExtract && success > 0 && failed === 0) { + const needsExtraction = items.some((item) => item.status === "completed" && shouldAutoRetryExtraction(item.fullStatus)); + if (needsExtraction) { + pkg.status = "queued"; + pkg.updatedAt = nowMs(); + for (const item of items) { + if (item.status === "completed" && shouldAutoRetryExtraction(item.fullStatus)) { + if (!shouldPreserveExtractionResumeLabel(item.fullStatus)) { + item.fullStatus = "Entpacken - Ausstehend"; + } + item.updatedAt = nowMs(); + } + } + changed = true; + } + } + + if (!allDone) { + continue; + } + + if (this.settings.autoExtract && failed === 0 && success > 0) { + const needsExtraction = items.some((item) => item.status === "completed" && shouldAutoRetryExtraction(item.fullStatus)); + if (needsExtraction) { + pkg.status = "queued"; + pkg.updatedAt = nowMs(); + for (const item of items) { + if (item.status === "completed" && shouldAutoRetryExtraction(item.fullStatus)) { + if (!shouldPreserveExtractionResumeLabel(item.fullStatus)) { + item.fullStatus = "Entpacken - Ausstehend"; + } + item.updatedAt = nowMs(); + } + } + changed = true; + void this.runPackagePostProcessing(packageId).catch((err) => logger.warn(`runPackagePostProcessing Fehler (recoverPostProcessing): ${compactErrorText(err)}`)); + } else if (pkg.status !== "completed") { + pkg.status = "completed"; + pkg.updatedAt = nowMs(); + changed = true; + } + if (!needsExtraction && this.shouldRecoverDeferredPostProcessingOnStartup(pkg, items)) { + logger.info(`Deferred Post-Processing via Startup ausgelöst: pkg=${pkg.name}`); + void this.runDeferredPostExtraction(packageId, pkg, success, failed, true, 0).catch((err) => + logger.warn(`runDeferredPostExtraction Fehler (recoverPostProcessing): ${compactErrorText(err)}`) + ); + } + continue; + } + + const targetStatus = failed > 0 + ? "failed" + : cancelled > 0 + ? (success > 0 ? "completed" : "cancelled") + : "completed"; + if (pkg.status !== targetStatus) { + pkg.status = targetStatus; + pkg.updatedAt = nowMs(); + changed = true; + } + if (this.shouldRecoverDeferredPostProcessingOnStartup(pkg, items)) { + logger.info(`Deferred Post-Processing via Startup ausgelöst: pkg=${pkg.name}`); + void this.runDeferredPostExtraction(packageId, pkg, success, failed, true, 0).catch((err) => + logger.warn(`runDeferredPostExtraction Fehler (recoverPostProcessing): ${compactErrorText(err)}`) + ); + } + } + + if (changed) { + this.persistSoon(); + this.emitState(); + } + } + + private triggerPendingExtractions(): void { + if (!this.settings.autoExtract) { + return; + } + for (const packageId of this.session.packageOrder) { + const pkg = this.session.packages[packageId]; + if (!pkg || pkg.cancelled || !pkg.enabled) { + continue; + } + if (this.packagePostProcessTasks.has(packageId)) { + continue; + } + const items = pkg.itemIds.map((id) => this.session.items[id]).filter(Boolean) as DownloadItem[]; + if (items.length === 0) { + continue; + } + const success = items.filter((item) => item.status === "completed").length; + const failed = items.filter((item) => item.status === "failed").length; + const cancelled = items.filter((item) => item.status === "cancelled").length; + const allDone = this.areAllPackageItemRefsFinished(pkg); + if (!allDone && success + failed + cancelled >= items.length) { + logger.warn( + `Post-Processing wartet trotz gefiltert fertiger Items: ` + + `pkg=${pkg.name}, tracked=${pkg.itemIds.length}, resolved=${items.length}, ` + + `success=${success}, failed=${failed}, cancelled=${cancelled}` + ); + } + + if (allDone && failed === 0 && success > 0) { + const needsExtraction = items.some((item) => + item.status === "completed" && shouldAutoRetryExtraction(item.fullStatus) + ); + if (needsExtraction) { + pkg.status = "queued"; + pkg.updatedAt = nowMs(); + for (const item of items) { + if (item.status === "completed" && shouldAutoRetryExtraction(item.fullStatus)) { + item.fullStatus = "Entpacken - Ausstehend"; + item.updatedAt = nowMs(); + } + } + logger.info(`Entpacken via Start ausgelöst: pkg=${pkg.name}`); + void this.runPackagePostProcessing(packageId).catch((err) => logger.warn(`runPackagePostProcessing Fehler (triggerPending): ${compactErrorText(err)}`)); + } + continue; + } + + if (!allDone && this.settings.hybridExtract && success > 0 && failed === 0) { + const needsExtraction = items.some((item) => + item.status === "completed" && shouldAutoRetryExtraction(item.fullStatus) + ); + if (needsExtraction) { + pkg.status = "queued"; + pkg.updatedAt = nowMs(); + for (const item of items) { + if (item.status === "completed" && shouldAutoRetryExtraction(item.fullStatus)) { + item.fullStatus = "Entpacken - Ausstehend"; + item.updatedAt = nowMs(); + } + } + logger.info(`Hybrid-Entpacken via Start ausgelöst: pkg=${pkg.name}, completed=${success}/${items.length}`); + void this.runPackagePostProcessing(packageId).catch((err) => logger.warn(`runPackagePostProcessing Fehler (triggerPendingHybrid): ${compactErrorText(err)}`)); + } + } + } + } + + public retryExtraction(packageId: string): void { + const pkg = this.session.packages[packageId]; + if (!pkg) return; + if (this.packagePostProcessTasks.has(packageId)) return; + this.clearHybridArchiveState(packageId); + const items = pkg.itemIds.map((id) => this.session.items[id]).filter(Boolean) as DownloadItem[]; + const completedItems = items.filter((item) => item.status === "completed"); + const targetItems = completedItems.filter((item) => !isExtractedLabel(item.fullStatus)); + if (targetItems.length === 0) return; + pkg.status = "queued"; + pkg.updatedAt = nowMs(); + for (const item of targetItems) { + if (!isExtractedLabel(item.fullStatus)) { + item.fullStatus = "Entpacken - Ausstehend"; + item.updatedAt = nowMs(); + } + } + logger.info(`Extraktion manuell wiederholt: pkg=${pkg.name}`); + this.logPackageForPackage(pkg, "INFO", "Extraktion manuell wiederholt", { + completedItems: completedItems.length, + targetedItems: targetItems.length + }); + // Fresh outcome must notify again (the corrective checkmark after a notified + // extraction failure); unconditional run-membership so the notify guard also + // passes when the retry happens after the run already ended. + this.notifiedPackages.delete(packageId); + this.runPackageIds.add(packageId); + this.persistSoon(); + this.emitState(true); + void this.runPackagePostProcessing(packageId).catch((err) => logger.warn(`runPackagePostProcessing Fehler (retryExtraction): ${compactErrorText(err)}`)); + } + + public extractNow(packageId: string): void { + const pkg = this.session.packages[packageId]; + if (!pkg || pkg.cancelled) return; + if (this.packagePostProcessTasks.has(packageId)) return; + this.clearHybridArchiveState(packageId); + if (!pkg.enabled) { + pkg.enabled = true; + } + const items = pkg.itemIds.map((id) => this.session.items[id]).filter(Boolean) as DownloadItem[]; + const completedItems = items.filter((item) => item.status === "completed"); + const targetItems = completedItems.filter((item) => !isExtractedLabel(item.fullStatus)); + if (targetItems.length === 0) return; + pkg.status = "queued"; + pkg.updatedAt = nowMs(); + for (const item of targetItems) { + item.fullStatus = "Entpacken - Ausstehend"; + item.updatedAt = nowMs(); + } + logger.info(`Jetzt entpacken: pkg=${pkg.name}, completed=${completedItems.length}, targeted=${targetItems.length}`); + this.logPackageForPackage(pkg, "INFO", "Jetzt entpacken ausgelöst", { + completedItems: completedItems.length, + targetedItems: targetItems.length + }); + this.notifiedPackages.delete(packageId); + this.runPackageIds.add(packageId); + this.persistSoon(); + this.emitState(true); + void this.runPackagePostProcessing(packageId).catch((err) => logger.warn(`runPackagePostProcessing Fehler (extractNow): ${compactErrorText(err)}`)); + } + + private notePackageDownloadStarted(pkg: PackageEntry, startedAt = nowMs()): void { + if ((pkg.downloadStartedAt || 0) <= 0) { + pkg.downloadStartedAt = startedAt; + } + } + + private notePackageDownloadCompleted(pkg: PackageEntry, completedAt = nowMs()): void { + this.notePackageDownloadStarted(pkg, completedAt); + pkg.downloadCompletedAt = Math.max(pkg.downloadCompletedAt || 0, completedAt); + } + + private getPackageHistoryDurationSeconds(pkg: PackageEntry): number { + const startedAt = (pkg.downloadStartedAt || 0) > 0 ? (pkg.downloadStartedAt || 0) : pkg.createdAt; + const finishedAtCandidate = (pkg.downloadCompletedAt || 0) > 0 ? (pkg.downloadCompletedAt || 0) : nowMs(); + const finishedAt = Math.max(startedAt || 0, finishedAtCandidate || 0); + if (startedAt <= 0 || finishedAt <= 0) { + return 1; + } + return Math.max(1, Math.floor((finishedAt - startedAt) / 1000)); + } + + private recordPackageHistory(packageId: string, pkg: PackageEntry, items: DownloadItem[]): void { + if (!this.onHistoryEntryCallback || this.historyRecordedPackages.has(packageId)) { + return; + } + const completedItems = items.filter(item => item.status === "completed"); + if (completedItems.length === 0) { + return; + } + this.historyRecordedPackages.add(packageId); + const totalBytes = completedItems.reduce((sum, item) => sum + (item.downloadedBytes || 0), 0); + const durationSeconds = this.getPackageHistoryDurationSeconds(pkg); + const providers = new Set(completedItems.map(item => item.provider).filter(Boolean)); + const provider = providers.size === 1 ? [...providers][0] : null; + const entry: HistoryEntry = { + id: generateHistoryId(), + name: pkg.name, + totalBytes, + downloadedBytes: totalBytes, + fileCount: completedItems.length, + provider, + completedAt: nowMs(), + durationSeconds, + status: "completed", + outputDir: pkg.outputDir, + urls: completedItems.map(item => item.url).filter(Boolean), + }; + this.onHistoryEntryCallback(entry); + } + + private removePackageFromSession(packageId: string, itemIds: string[], reason: "completed" | "deleted" = "deleted"): void { + const pkg = this.session.packages[packageId]; + if (pkg) { + this.logPackageForPackage(pkg, "INFO", "Paket aus Session entfernt", { + reason, + removedItemCount: itemIds.length + }); + } + if (pkg && this.onHistoryEntryCallback && reason === "deleted" && !this.historyRecordedPackages.has(packageId)) { + const allItems = itemIds.map(id => this.session.items[id]).filter(Boolean) as DownloadItem[]; + const completedItems = allItems.filter(item => item.status === "completed"); + const completedCount = completedItems.length; + if (completedCount > 0) { + const totalBytes = completedItems.reduce((sum, item) => sum + (item.downloadedBytes || 0), 0); + const durationSeconds = this.getPackageHistoryDurationSeconds(pkg); + const providers = new Set(completedItems.map(item => item.provider).filter(Boolean)); + const provider = providers.size === 1 ? [...providers][0] : null; + const entry: HistoryEntry = { + id: generateHistoryId(), + name: pkg.name, + totalBytes, + downloadedBytes: totalBytes, + fileCount: completedCount, + provider, + completedAt: nowMs(), + durationSeconds, + status: "deleted", + outputDir: pkg.outputDir, + urls: completedItems.map(item => item.url).filter(Boolean), + }; + this.onHistoryEntryCallback(entry); + } + } + this.historyRecordedPackages.delete(packageId); + this.notifiedPackages.delete(packageId); + this.abortPackagePostProcessing(packageId, "package_removed"); + for (const itemId of itemIds) { + this.retryAfterByItem.delete(itemId); + this.retryStateByItem.delete(itemId); + this.releaseTargetPath(itemId); + this.dropItemContribution(itemId); + delete this.session.items[itemId]; + this.itemCount = Math.max(0, this.itemCount - 1); + } + delete this.session.packages[packageId]; + this.session.packageOrder = this.session.packageOrder.filter((id) => id !== packageId); + this.runCompletedPackages.delete(packageId); + this.resetSessionTotalsIfQueueEmpty(); + } + + private recordProviderFailure(provider: string): void { + const key = String(provider || "").trim() || "unknown"; + const now = nowMs(); + const entry = this.providerFailures.get(key) || { count: 0, lastFailAt: 0, cooldownUntil: 0 }; + if (entry.lastFailAt > 0 && now - entry.lastFailAt > 120000) { + entry.count = 0; + } + if (entry.lastFailAt > 0 && now - entry.lastFailAt < 2000) { + entry.lastFailAt = now; + this.providerFailures.set(key, entry); + return; + } + entry.count += 1; + entry.lastFailAt = now; + if (entry.count >= 20) { + const tier = entry.count >= 80 ? 3 : entry.count >= 50 ? 2 : entry.count >= 35 ? 1 : 0; + const cooldownMs = [30000, 60000, 120000, 300000][tier]; + entry.cooldownUntil = now + cooldownMs; + logger.warn(`Provider Circuit-Breaker: ${key} ${entry.count} konsekutive Fehler, Cooldown ${cooldownMs / 1000}s`); + if ((key === "megadebrid" || key === "megadebrid-api" || key === "megadebrid-web") && this.invalidateMegaSessionFn) { + try { + this.invalidateMegaSessionFn(); + } catch { } + } + } + this.providerFailures.set(key, entry); + } + + private recordProviderSuccess(provider: string): void { + const key = String(provider || "").trim() || "unknown"; + if (this.providerFailures.has(key)) { + this.providerFailures.delete(key); + } + } + + private applyProviderBusyBackoff(provider: string, cooldownMs: number): void { + const key = String(provider || "").trim() || "unknown"; + const now = nowMs(); + const entry = this.providerFailures.get(key) || { count: 0, lastFailAt: 0, cooldownUntil: 0 }; + entry.lastFailAt = now; + entry.cooldownUntil = Math.max(entry.cooldownUntil, now + Math.max(0, Math.floor(cooldownMs))); + this.providerFailures.set(key, entry); + } + + private getProviderCooldownRemaining(provider: string): number { + const key = String(provider || "").trim() || "unknown"; + const entry = this.providerFailures.get(key); + if (!entry || entry.cooldownUntil <= 0) { + return 0; + } + const remaining = entry.cooldownUntil - nowMs(); + if (remaining <= 0) { + entry.count = 0; + entry.cooldownUntil = 0; + return 0; + } + return remaining; + } + + private ensureProviderDailyUsageFresh(now = nowMs(), persist = false): void { + const currentDay = getProviderUsageDayKey(now); + if (this.settings.providerDailyUsageDay === currentDay) { + return; + } + this.settings.providerDailyUsageDay = currentDay; + this.settings.providerDailyUsageBytes = {}; + this.settings.debridLinkApiKeyDailyUsageBytes = {}; + this.settings.megaDebridAccountDailyUsageBytes = {}; + this.statsCache = null; + this.statsCacheAt = 0; + if (persist) { + this.lastSettingsPersistAt = now; + void saveSettingsAsync(this.storagePaths, this.settings).catch((err) => logger.warn(`saveSettingsAsync Fehler: ${compactErrorText(err as Error)}`)); + } + } + + private recordProviderDownloadedBytes(provider: DownloadItem["provider"], byteDelta: number, providerAccountId?: string): void { + if (!provider) { + return; + } + const effectiveProvider = resolveMegaDebridProvider(this.settings, provider) || provider; + const nextUsage = addProviderDailyUsageBytes(this.settings, effectiveProvider, byteDelta); + const nextTotalUsage = addProviderTotalUsageBytes(this.settings, effectiveProvider, byteDelta); + this.settings.providerDailyUsageDay = nextUsage.providerDailyUsageDay; + this.settings.providerDailyUsageBytes = nextUsage.providerDailyUsageBytes; + this.settings.providerTotalUsageBytes = nextTotalUsage.providerTotalUsageBytes; + if (effectiveProvider === "debridlink" && providerAccountId) { + const nextKeyUsage = addDebridLinkApiKeyDailyUsageBytes(this.settings, providerAccountId, byteDelta); + const nextKeyTotalUsage = addDebridLinkApiKeyTotalUsageBytes(this.settings, providerAccountId, byteDelta); + this.settings.providerDailyUsageDay = nextKeyUsage.providerDailyUsageDay; + this.settings.debridLinkApiKeyDailyUsageBytes = nextKeyUsage.debridLinkApiKeyDailyUsageBytes; + this.settings.debridLinkApiKeyTotalUsageBytes = nextKeyTotalUsage.debridLinkApiKeyTotalUsageBytes; + } + if ((effectiveProvider === "megadebrid-api" || effectiveProvider === "megadebrid-web") && providerAccountId) { + const nextAcctUsage = addMegaDebridAccountDailyUsageBytes(this.settings, providerAccountId, byteDelta); + const nextAcctTotalUsage = addMegaDebridAccountTotalUsageBytes(this.settings, providerAccountId, byteDelta); + this.settings.providerDailyUsageDay = nextAcctUsage.providerDailyUsageDay; + this.settings.megaDebridAccountDailyUsageBytes = nextAcctUsage.megaDebridAccountDailyUsageBytes; + this.settings.megaDebridAccountTotalUsageBytes = nextAcctTotalUsage.megaDebridAccountTotalUsageBytes; + } + } + + private isProviderConfigured(provider: DebridProvider): boolean { + this.ensureProviderDailyUsageFresh(nowMs()); + const effectiveProvider = resolveMegaDebridProvider(this.settings, provider) || provider; + if ((this.settings.disabledProviders || []).includes(provider) || (this.settings.disabledProviders || []).includes(effectiveProvider)) { + return false; + } + if (isProviderDailyLimitReached(this.settings, effectiveProvider)) { + return false; + } + if (effectiveProvider === "realdebrid") { + return Boolean(this.settings.realDebridUseWebLogin || this.settings.token.trim()); + } + if (effectiveProvider === "megadebrid-api") { + const hasMegaCreds = Boolean(this.settings.megaCredentials.trim() || (this.settings.megaLogin.trim() && this.settings.megaPassword.trim())); + return Boolean(hasMegaCreds && (resolveMegaDebridProvider(this.settings, "megadebrid") === "megadebrid-api" || this.settings.megaDebridApiEnabled)); + } + if (effectiveProvider === "megadebrid-web") { + const hasMegaCreds = Boolean(this.settings.megaCredentials.trim() || (this.settings.megaLogin.trim() && this.settings.megaPassword.trim())); + return Boolean(hasMegaCreds && (resolveMegaDebridProvider(this.settings, "megadebrid") === "megadebrid-web" || this.settings.megaDebridWebEnabled)); + } + if (effectiveProvider === "bestdebrid") { + return Boolean(this.settings.bestDebridUseWebLogin || this.settings.bestToken.trim()); + } + if (effectiveProvider === "alldebrid") { + return Boolean(this.settings.allDebridUseWebLogin || this.settings.allDebridToken.trim()); + } + if (effectiveProvider === "ddownload") { + return Boolean(this.settings.ddownloadLogin.trim() && this.settings.ddownloadPassword.trim()); + } + if (effectiveProvider === "onefichier") { + return Boolean(this.settings.oneFichierApiKey.trim()); + } + if (effectiveProvider === "debridlink") { + const configuredKeys = parseDebridLinkApiKeys(this.settings.debridLinkApiKeys); + return configuredKeys.length > 0 && getAvailableDebridLinkApiKeys(this.settings).length > 0; + } + if (provider === "linksnappy") { + return Boolean(this.settings.linkSnappyLogin.trim() && this.settings.linkSnappyPassword.trim()); + } + return false; + } + + private getProviderOrder(): DebridProvider[] { + if (this.settings.providerOrder && this.settings.providerOrder.length > 0) { + return [...this.settings.providerOrder]; + } + return [ + this.settings.providerPrimary, + this.settings.providerSecondary !== "none" ? this.settings.providerSecondary : null, + this.settings.providerTertiary !== "none" ? this.settings.providerTertiary : null + ].filter(Boolean) as DebridProvider[]; + } + + private findFallbackProviderNotInCooldown(item: DownloadItem): DebridProvider | null { + const hosterKey = extractHosterKey(item.url); + for (const provider of this.getProviderOrder()) { + if (!this.isProviderConfigured(provider)) continue; + const key = hosterKey && provider === "alldebrid" ? `${provider}:${hosterKey}` : provider; + if (this.getProviderCooldownRemaining(key) === 0) return provider; + } + return null; + } + + private getExpectedProviderForItem(item: DownloadItem): DebridProvider | null { + if (item.provider) { + const resolvedProvider = resolveMegaDebridProvider(this.settings, item.provider); + if (resolvedProvider && this.isProviderConfigured(resolvedProvider)) { + return resolvedProvider; + } + } + + const hosterKey = extractHosterKey(item.url); + const routing = this.settings.hosterRouting || {}; + const routedProvider = hosterKey ? routing[hosterKey] : undefined; + if (routedProvider && this.isProviderConfigured(routedProvider)) { + return routedProvider; + } + + const seen = new Set<DebridProvider>(); + for (const provider of this.getProviderOrder()) { + if (seen.has(provider)) { + continue; + } + seen.add(provider); + if (this.isProviderConfigured(provider)) { + return provider; + } + } + + return null; + } + + private getProviderFailureKeyForItem(item: DownloadItem, providerOverride?: DebridProvider | string | null): string { + const provider = String(providerOverride || item.provider || this.getExpectedProviderForItem(item) || "unknown").trim() || "unknown"; + const hosterKey = extractHosterKey(item.url); + if (provider === "alldebrid" && hosterKey) { + return `${provider}:${hosterKey}`; + } + return provider; + } + + private getActiveTaskCountForFailureKey(failureKey: string, excludeItemId?: string): number { + let count = 0; + for (const active of this.activeTasks.values()) { + if (excludeItemId && active.itemId === excludeItemId) { + continue; + } + const activeItem = this.session.items[active.itemId]; + if (!activeItem) { + continue; + } + if (this.getProviderFailureKeyForItem(activeItem) === failureKey) { + count += 1; + } + } + return count; + } + + private getProviderActiveTaskCount(provider: DebridProvider): number { + let count = 0; + for (const active of this.activeTasks.values()) { + const activeItem = this.session.items[active.itemId]; + if (!activeItem) { + continue; + } + if (this.getExpectedProviderForItem(activeItem) === provider) { + count += 1; + } + } + return count; + } + + private getPacedStartKeyForItem(item: DownloadItem): string | null { + const provider = this.getExpectedProviderForItem(item); + if (provider !== "alldebrid") { + return null; + } + return provider; + } + + private countFuturePacedStarts(paceKey: string, now: number, excludeItemId?: string): number { + let count = 0; + for (const [itemId, reservedAt] of this.pacedStartReservationByItem.entries()) { + if (excludeItemId && itemId === excludeItemId) { + continue; + } + if (reservedAt <= now) { + continue; + } + const item = this.session.items[itemId]; + if (!item) { + continue; + } + if (this.getPacedStartKeyForItem(item) !== paceKey) { + continue; + } + if (item.status !== "queued" && item.status !== "reconnect_wait") { + continue; + } + count += 1; + } + return count; + } + + private getProviderValidatingTaskCount(provider: DebridProvider, excludeItemId?: string): number { + let count = 0; + for (const active of this.activeTasks.values()) { + if (excludeItemId && active.itemId === excludeItemId) { + continue; + } + const activeItem = this.session.items[active.itemId]; + if (!activeItem || activeItem.status !== "validating") { + continue; + } + const expectedProvider = resolveMegaDebridProvider(this.settings, this.getExpectedProviderForItem(activeItem)); + if (expectedProvider === provider) { + count += 1; + } + } + return count; + } + + private describeSlotOccupancy(): string { + let converting = 0; + let downloading = 0; + for (const active of this.activeTasks.values()) { + const activeItem = this.session.items[active.itemId]; + if (!activeItem) { + continue; + } + if (activeItem.status === "validating") { + converting += 1; + } else if (activeItem.status === "downloading") { + downloading += 1; + } + } + return `conv${converting}/dl${downloading}/active${this.activeTasks.size}/max${this.settings.maxParallel}`; + } + + private getSerializedValidatingLimit(provider: DebridProvider | null): number { + if (provider === "megadebrid-web" || provider === "megadebrid-api") { + const mode = provider === "megadebrid-web" ? "web" : "api"; + const usableAccounts = getAvailableMegaDebridAccounts(this.settings) + .filter((account) => !getMegaDebridAccountCooldownState(`${account.id}:${mode}`)) + .length; + return Math.max(1, usableAccounts); + } + return Number.MAX_SAFE_INTEGER; + } + + private delayPacedStartForItem(item: DownloadItem, now: number): boolean { + const paceKey = this.getPacedStartKeyForItem(item); + if (!paceKey) { + return false; + } + + const existingReadyAt = this.retryAfterByItem.get(item.id) || 0; + const existingPacedAt = this.pacedStartReservationByItem.get(item.id) || 0; + if (existingPacedAt > 0 && existingPacedAt <= now) { + this.pacedStartReservationByItem.delete(item.id); + return false; + } + if (existingPacedAt > now) { + const scheduledAt = Math.max(existingReadyAt, existingPacedAt); + this.retryAfterByItem.set(item.id, scheduledAt); + item.status = "queued"; + item.speedBps = 0; + item.fullStatus = `AllDebrid Start in ${Math.max(1, Math.ceil((scheduledAt - now) / 1000))}s`; + item.updatedAt = now; + return true; + } + + const failureKey = this.getProviderFailureKeyForItem(item, "alldebrid"); + const startLimit = this.getAllDebridStartLimit(extractHosterKey(item.url)); + const activeProviderTasks = this.activeTasks.size; + const activeHosterTasks = this.getActiveTaskCountForFailureKey(failureKey); + const futureReservations = this.countFuturePacedStarts(paceKey, now, item.id); + const remainingGlobalSlots = Math.max(0, Math.max(1, Number(this.settings.maxParallel) || 1) - activeProviderTasks - futureReservations); + const remainingHosterSlots = Number.isFinite(startLimit) + ? Math.max(0, startLimit - activeHosterTasks - futureReservations) + : Number.MAX_SAFE_INTEGER; + const availableReservationSlots = Math.min(remainingGlobalSlots, remainingHosterSlots); + if (availableReservationSlots <= 0) { + this.pacedStartReservationByItem.delete(item.id); + if ((item.fullStatus || "").startsWith("AllDebrid Start in ")) { + item.fullStatus = "Wartet"; + item.updatedAt = now; + } + return true; + } + + const scheduledAt = Math.max(existingReadyAt, now + ALLDEBRID_START_STAGGER_MS); + if (scheduledAt <= now) { + this.pacedStartReservationByItem.delete(item.id); + return false; + } + this.retryAfterByItem.set(item.id, scheduledAt); + this.pacedStartReservationByItem.set(item.id, scheduledAt); + item.status = "queued"; + item.speedBps = 0; + item.fullStatus = `AllDebrid Start in ${Math.max(1, Math.ceil((scheduledAt - now) / 1000))}s`; + item.updatedAt = now; + return true; + } + + private notePacedStartForItem(item: DownloadItem, now: number): void { + const paceKey = this.getPacedStartKeyForItem(item); + if (!paceKey) { + return; + } + + const reservedAt = this.pacedStartReservationByItem.get(item.id) || 0; + if (reservedAt > 0) { + this.pacedStartReservationByItem.delete(item.id); + } + if (this.countFuturePacedStarts(paceKey, now) <= 0) { + this.providerStartReservations.delete(paceKey); + } + } + + private getConfiguredAllDebridStartLimit(): number { + const configured = Math.floor(Number(this.settings.maxParallel || 1)); + if (Number.isFinite(configured) && configured > 0) { + return configured; + } + return 1; + } + + private getAllDebridStartLimit(hosterKey: string): number { + if (hosterKey !== "rapidgator") { + return Number.MAX_SAFE_INTEGER; + } + const configuredLimit = this.getConfiguredAllDebridStartLimit(); + const cached = this.allDebridHostInfoCache.get(hosterKey); + const apiLimit = cached?.info.limitSimuDl; + if (Number.isFinite(apiLimit) && (apiLimit as number) > 0) { + return Math.max(1, Math.min(configuredLimit, Math.floor(apiLimit as number))); + } + return configuredLimit; + } + + private shouldDelayStartForItem(item: DownloadItem): boolean { + const provider = resolveMegaDebridProvider(this.settings, this.getExpectedProviderForItem(item)); + const serializedValidatingLimit = this.getSerializedValidatingLimit(provider); + if (provider && Number.isFinite(serializedValidatingLimit) && serializedValidatingLimit < Number.MAX_SAFE_INTEGER) { + const validating = this.getProviderValidatingTaskCount(provider, item.id); + if (provider === "megadebrid-api") { + const webInFlight = getMegaDebridInFlightCountForMode("web"); + const overlapAllowance = Math.min(serializedValidatingLimit, webInFlight); + return validating >= serializedValidatingLimit + overlapAllowance; + } + return validating >= serializedValidatingLimit; + } + if (provider !== "alldebrid") { + return false; + } + const hosterKey = extractHosterKey(item.url); + if (hosterKey !== "rapidgator") { + return false; + } + const failureKey = this.getProviderFailureKeyForItem(item, provider); + const startLimit = this.getAllDebridStartLimit(hosterKey); + return this.getActiveTaskCountForFailureKey(failureKey) >= startLimit; + } + + private async getAllDebridHostInfoCached(hosterKey: string, signal?: AbortSignal, forceRefresh = false): Promise<AllDebridHostInfo | null> { + const normalizedHost = String(hosterKey || "").trim().toLowerCase(); + if (!normalizedHost || this.settings.allDebridUseWebLogin) { + return null; + } + const token = this.settings.allDebridToken.trim(); + if (!token) { + return null; + } + + const cached = this.allDebridHostInfoCache.get(normalizedHost); + if (!forceRefresh && cached && nowMs() - cached.cachedAt <= ALLDEBRID_HOST_INFO_TTL_MS) { + return cached.info; + } + + try { + const info = await fetchAllDebridHostInfo(token, normalizedHost, signal); + this.allDebridHostInfoCache.set(normalizedHost, { info, cachedAt: nowMs() }); + return info; + } catch (error) { + const errorText = compactErrorText(error); + logger.warn(`AllDebrid Host-Info Fehler für ${normalizedHost}: ${errorText}`); + return cached?.info || null; + } + } + + private async maybeApplyAllDebridRapidgatorBackoff(item: DownloadItem, active: ActiveTask): Promise<boolean> { + const provider = this.getExpectedProviderForItem(item); + if (provider !== "alldebrid") { + return false; + } + + const hosterKey = extractHosterKey(item.url); + if (hosterKey !== "rapidgator") { + return false; + } + + const failureKey = this.getProviderFailureKeyForItem(item, provider); + const activePeers = this.getActiveTaskCountForFailureKey(failureKey, item.id); + const info = await this.getAllDebridHostInfoCached(hosterKey, active.abortController.signal, activePeers <= 0); + const startLimit = this.getAllDebridStartLimit(hosterKey); + + if (activePeers >= startLimit) { + const delayMs = Math.min(45000, 5000 + activePeers * 3000); + this.queueRetry(item, active, delayMs, `AllDebrid ${hosterKey}: Slot belegt (${activePeers}/${startLimit})`); + return true; + } + + if (!info) { + return false; + } + + if (info.state === "down") { + const delayMs = 60000; + this.applyProviderBusyBackoff(failureKey, delayMs); + this.queueRetry(item, active, delayMs + 1000, `AllDebrid ${info.host}: ${info.statusLabel}`); + return true; + } + + if (info.limitSimuDl !== null && info.limitSimuDl <= 0) { + const delayMs = 45000; + this.applyProviderBusyBackoff(failureKey, delayMs); + this.queueRetry(item, active, delayMs + 1000, `AllDebrid ${info.host}: keine freien Slots`); + return true; + } + + if (info.quota !== null && info.quota <= 0) { + const delayMs = 120000; + this.applyProviderBusyBackoff(failureKey, delayMs); + this.queueRetry(item, active, delayMs + 1000, `AllDebrid ${info.host}: Quota aufgebraucht`); + return true; + } + + return false; + } + + private resetStaleRetryState(): void { + const now = nowMs(); + for (const [itemId, retryState] of this.retryStateByItem) { + const item = this.session.items[itemId]; + if (!item || item.status !== "queued") { + continue; + } + if (this.activeTasks.has(itemId)) { + continue; + } + const retryAfter = this.retryAfterByItem.get(itemId) || 0; + if (retryAfter > now) { + continue; + } + const staleMs = now - item.updatedAt; + if (staleMs > 600000) { + retryState.stallRetries = 0; + retryState.unrestrictRetries = 0; + retryState.genericErrorRetries = 0; + retryState.freshRetryUsed = false; + retryState.resumeHardResetUsed = false; + logger.info(`Soft-Reset: Retry-Counter zurückgesetzt für ${item.fileName || itemId} (${Math.floor(staleMs / 60000)} min stale)`); + } + } + for (const [provider, entry] of this.providerFailures) { + if (now - entry.lastFailAt > 900000) { + this.providerFailures.delete(provider); + logger.info(`Soft-Reset: Provider-Failures zurückgesetzt für ${provider}`); + } + } + let allDebridPruned = 0; + for (const [host, entry] of this.allDebridHostInfoCache) { + if (now - entry.cachedAt > 5 * 60 * 1000) { + this.allDebridHostInfoCache.delete(host); + allDebridPruned += 1; + } + } + const dlPruned = pruneExpiredDebridLinkRuntimeState(now); + const mdPruned = pruneExpiredMegaDebridRuntimeState(now); + if (allDebridPruned > 0 || dlPruned > 0 || mdPruned > 0) { + logger.info(`Soft-Reset: pruned ${allDebridPruned} AllDebrid host entries, ${dlPruned} Debrid-Link entries, ${mdPruned} Mega-Debrid entries`); + } + } + + private async ensureScheduler(): Promise<void> { + if (this.scheduleRunning) { + return; + } + this.scheduleRunning = true; + const myGeneration = this.schedulerGeneration; + logger.info(`Scheduler gestartet (gen=${myGeneration})`); + try { + while (this.session.running && this.schedulerGeneration === myGeneration) { + const now = nowMs(); + if (now - this.lastSchedulerHeartbeatAt >= 60000) { + this.lastSchedulerHeartbeatAt = now; + logger.info(`Scheduler Heartbeat: active=${this.activeTasks.size}, queued=${this.countQueuedItems()}, reconnect=${this.reconnectActive()}, paused=${this.session.paused}, postProcess=${this.packagePostProcessTasks.size}`); + } + if (now - this.lastStaleResetAt >= 600000) { + this.lastStaleResetAt = now; + this.resetStaleRetryState(); + } + + if (this.session.paused) { + await sleep(120); + continue; + } + + if (this.reconnectActive()) { + const markNow = nowMs(); + if (markNow - this.lastReconnectMarkAt >= 900) { + this.lastReconnectMarkAt = markNow; + const changed = this.markQueuedAsReconnectWait(); + if (!changed) { + this.emitState(); + } + } + await sleep(220); + continue; + } + + while (this.activeTasks.size < Math.max(1, this.settings.maxParallel)) { + const next = this.findNextQueuedItem(); + if (!next) { + break; + } + this.startItem(next.packageId, next.itemId); + } + + this.runGlobalStallWatchdog(now); + + const queuePresence = this.activeTasks.size === 0 ? this.getQueuePresence(now) : { hasImmediate: true, hasDelayed: false }; + const downloadsComplete = this.activeTasks.size === 0 && !queuePresence.hasImmediate && !queuePresence.hasDelayed; + const postProcessComplete = this.packagePostProcessTasks.size === 0 && !this.hasAnyDeferredPostProcessPending(); + if (downloadsComplete && (postProcessComplete || this.settings.autoExtractWhenStopped)) { + this.finishRun(); + break; + } + + const maxParallel = Math.max(1, this.settings.maxParallel); + const schedulerSleepMs = this.activeTasks.size >= maxParallel ? 170 : 120; + await sleep(schedulerSleepMs); + } + } finally { + this.scheduleRunning = false; + logger.info(`Scheduler beendet (gen=${myGeneration})`); + // Stop->Start race: a new run can begin while this loop sleeps (start()'s + // ensureScheduler early-returns on scheduleRunning, then this loop exits on + // the generation mismatch). Without a respawn the run sits leaderless + // forever: running=true, but nothing schedules and finishRun never comes. + if (this.session.running && this.schedulerGeneration !== myGeneration) { + logger.warn(`Scheduler-Respawn: Run aktiv, alte Generation ${myGeneration} beendet (Stop->Start-Race)`); + void this.ensureScheduler().catch((error) => logger.error(`Scheduler-Respawn fehlgeschlagen: ${compactErrorText(error)}`)); + } + } + } + + private reconnectActive(): boolean { + if (this.session.reconnectUntil <= 0) { + return false; + } + const now = nowMs(); + const maxWaitMs = this.settings.reconnectWaitSeconds * 2 * 1000; + if (this.session.reconnectUntil - now > maxWaitMs) { + this.session.reconnectUntil = now + maxWaitMs; + } + return this.session.reconnectUntil > now; + } + + private runGlobalStallWatchdog(now: number): void { + const timeoutMs = getGlobalStallWatchdogTimeoutMs(); + if (timeoutMs <= 0) { + return; + } + + if (!this.session.running || this.session.paused || this.reconnectActive()) { + this.lastGlobalProgressBytes = this.session.totalDownloadedBytes; + this.lastGlobalProgressAt = now; + return; + } + + const VALIDATING_STUCK_MS = getUnrestrictTimeoutMs() + 15000; + for (const active of this.activeTasks.values()) { + if (active.abortController.signal.aborted) { + continue; + } + const item = this.session.items[active.itemId]; + if (!item || item.status !== "validating") { + continue; + } + const ageMs = item.updatedAt > 0 ? now - item.updatedAt : 0; + if (ageMs > VALIDATING_STUCK_MS) { + logger.warn(`Validating-Stuck erkannt: item=${item.fileName || active.itemId}, ${Math.floor(ageMs / 1000)}s ohne Fortschritt`); + active.abortReason = "stall"; + active.abortController.abort("stall"); + } + } + + if (this.session.totalDownloadedBytes !== this.lastGlobalProgressBytes) { + this.lastGlobalProgressBytes = this.session.totalDownloadedBytes; + this.lastGlobalProgressAt = now; + return; + } + + if (now - this.lastGlobalProgressAt < timeoutMs) { + return; + } + + let stalledCount = 0; + let diskBlockedCount = 0; + for (const active of this.activeTasks.values()) { + if (active.abortController.signal.aborted) { + continue; + } + if (active.blockedOnDiskWrite) { + diskBlockedCount += 1; + continue; + } + const item = this.session.items[active.itemId]; + if (item && item.status === "downloading") { + stalledCount += 1; + } + } + if (stalledCount === 0) { + this.lastGlobalProgressAt = now; + return; + } + + logger.warn(`Globaler Download-Stall erkannt (${Math.floor((now - this.lastGlobalProgressAt) / 1000)}s ohne Fortschritt), ${stalledCount} Task(s) neu starten, diskBlocked=${diskBlockedCount}`); + for (const active of this.activeTasks.values()) { + if (active.abortController.signal.aborted) { + continue; + } + if (active.blockedOnDiskWrite) { + continue; + } + const item = this.session.items[active.itemId]; + if (item && item.status === "downloading") { + active.abortReason = "stall"; + active.abortController.abort("stall"); + } + } + this.lastGlobalProgressAt = now; + } + + private requestReconnect(reason: string): void { + if (!this.settings.autoReconnect) { + return; + } + + if (this.session.reconnectUntil <= nowMs()) { + this.consecutiveReconnects += 1; + } + const backoffMultiplier = Math.min(this.consecutiveReconnects, 5); + const waitMs = this.settings.reconnectWaitSeconds * 1000 * backoffMultiplier; + const maxWaitMs = this.settings.reconnectWaitSeconds * 2 * 1000; + const cappedWaitMs = Math.min(waitMs, maxWaitMs); + const until = nowMs() + cappedWaitMs; + this.session.reconnectUntil = Math.max(this.session.reconnectUntil, until); + const absoluteMax = nowMs() + maxWaitMs; + if (this.session.reconnectUntil > absoluteMax) { + this.session.reconnectUntil = absoluteMax; + } + this.session.reconnectReason = reason; + this.lastReconnectMarkAt = 0; + + for (const active of this.activeTasks.values()) { + active.abortReason = "reconnect"; + active.abortController.abort("reconnect"); + } + + logger.warn(`Reconnect angefordert: ${reason} (consecutive=${this.consecutiveReconnects}, wait=${Math.ceil(cappedWaitMs / 1000)}s)`); + this.emitState(); + } + + private markQueuedAsReconnectWait(): boolean { + let changed = false; + const waitSeconds = Math.max(0, Math.ceil((this.session.reconnectUntil - nowMs()) / 1000)); + const waitText = `Reconnect-Wait (${waitSeconds}s)`; + const updateItem = (itemId: string): void => { + const item = this.session.items[itemId]; + if (!item) return; + const pkg = this.session.packages[item.packageId]; + if (!pkg || pkg.cancelled || !pkg.enabled) return; + if (item.status === "queued") { + item.status = "reconnect_wait"; + item.fullStatus = waitText; + item.updatedAt = nowMs(); + changed = true; + } + }; + if (this.runItemIds.size > 0) { + for (const itemId of this.runItemIds) updateItem(itemId); + } else { + for (const itemId in this.session.items) updateItem(itemId); + } + if (changed) { + this.emitState(); + } + return changed; + } + + private findNextQueuedItem(): { packageId: string; itemId: string } | null { + const now = nowMs(); + let normalCandidate: { packageId: string; itemId: string } | null = null; + let lowCandidate: { packageId: string; itemId: string } | null = null; + + for (const packageId of this.session.packageOrder) { + const pkg = this.session.packages[packageId]; + if (!pkg || pkg.cancelled || !pkg.enabled) continue; + if (this.runPackageIds.size > 0 && !this.runPackageIds.has(packageId)) continue; + const pkgPrio = pkg.priority || "normal"; + if (normalCandidate && pkgPrio === "low") continue; + if (normalCandidate && pkgPrio === "normal") continue; + + for (const itemId of pkg.itemIds) { + const item = this.session.items[itemId]; + if (!item) continue; + const retryAfter = this.retryAfterByItem.get(itemId) || 0; + if (retryAfter > now) continue; + if (item.status !== "queued" && item.status !== "reconnect_wait") continue; + if (this.activeTasks.has(itemId)) continue; + if (this.delayPacedStartForItem(item, now)) continue; + if (this.shouldDelayStartForItem(item)) continue; + + const candidate = { packageId, itemId }; + if (pkgPrio === "high") { + if (retryAfter > 0) this.retryAfterByItem.delete(itemId); + return candidate; + } + if (pkgPrio === "normal") { + normalCandidate = candidate; + } else if (!lowCandidate) { + lowCandidate = candidate; + } + break; + } + } + + const chosen = normalCandidate || lowCandidate; + if (chosen) { + const retryAfter = this.retryAfterByItem.get(chosen.itemId) || 0; + if (retryAfter > 0) this.retryAfterByItem.delete(chosen.itemId); + } + return chosen; + } + + private getQueuePresence(now = nowMs()): { hasImmediate: boolean; hasDelayed: boolean } { + let hasImmediate = false; + let hasDelayed = false; + for (const packageId of this.session.packageOrder) { + const pkg = this.session.packages[packageId]; + if (!pkg || pkg.cancelled || !pkg.enabled) continue; + if (this.runPackageIds.size > 0 && !this.runPackageIds.has(packageId)) continue; + for (const itemId of pkg.itemIds) { + const item = this.session.items[itemId]; + if (!item) continue; + if (item.status !== "queued" && item.status !== "reconnect_wait") continue; + const retryAfter = this.retryAfterByItem.get(itemId) || 0; + if (retryAfter > now) { + hasDelayed = true; + } else { + hasImmediate = true; + } + if (hasImmediate && hasDelayed) return { hasImmediate, hasDelayed }; + } + } + return { hasImmediate, hasDelayed }; + } + + private hasQueuedItems(): boolean { + return this.getQueuePresence().hasImmediate; + } + + private hasDelayedQueuedItems(): boolean { + return this.getQueuePresence().hasDelayed; + } + + private countQueuedItems(): number { + let count = 0; + for (const packageId of this.session.packageOrder) { + const pkg = this.session.packages[packageId]; + if (!pkg || pkg.cancelled || !pkg.enabled) { + continue; + } + if (this.runPackageIds.size > 0 && !this.runPackageIds.has(packageId)) { + continue; + } + for (const itemId of pkg.itemIds) { + const item = this.session.items[itemId]; + if (!item) { + continue; + } + if (item.status === "queued" || item.status === "reconnect_wait") { + count += 1; + } + } + } + return count; + } + + private queueRetry(item: DownloadItem, active: ActiveTask, delayMs: number, statusText: string): void { + const waitMs = Math.max(0, Math.floor(delayMs)); + item.status = "queued"; + item.speedBps = 0; + item.fullStatus = statusText; + item.updatedAt = nowMs(); + item.attempts = 0; + active.abortController = new AbortController(); + active.abortReason = "none"; + this.retryStateByItem.set(item.id, { + freshRetryUsed: Boolean(active.freshRetryUsed), + resumeHardResetUsed: Boolean(active.resumeHardResetUsed), + stallRetries: Number(active.stallRetries || 0), + genericErrorRetries: Number(active.genericErrorRetries || 0), + unrestrictRetries: Number(active.unrestrictRetries || 0) + }); + this.logPackageForItem(item, "WARN", "Retry eingeplant", { + delayMs: waitMs, + statusText, + stallRetries: Number(active.stallRetries || 0), + unrestrictRetries: Number(active.unrestrictRetries || 0), + genericRetries: Number(active.genericErrorRetries || 0), + freshRetryUsed: Boolean(active.freshRetryUsed), + resumeHardResetUsed: Boolean(active.resumeHardResetUsed) + }); + this.retryAfterByItem.set(item.id, nowMs() + waitMs); + } + + private scheduleHttp416Retry( + item: DownloadItem, + active: ActiveTask, + retryDisplayLimit: string, + errorText: string, + claimedTargetPath: string + ): void { + active.genericErrorRetries = Number(active.genericErrorRetries || 0) + 1; + item.retries += 1; + if (claimedTargetPath) { + try { + fs.rmSync(claimedTargetPath, { force: true }); + } catch { + } + } + this.releaseTargetPath(item.id); + this.dropItemContribution(item.id); + item.lastError = errorText; + item.downloadedBytes = 0; + item.totalBytes = null; + item.progressPercent = 0; + item.speedBps = 0; + const delayMs = retryDelayWithJitter(active.genericErrorRetries, 200); + logger.warn( + `HTTP 416 erkannt: item=${item.fileName || item.id}, ` + + `retry=${active.genericErrorRetries}/${retryDisplayLimit}, error=${errorText}, provider=${item.provider || "?"}` + ); + this.queueRetry(item, active, delayMs, `HTTP 416 erkannt, Retry ${active.genericErrorRetries}/${retryDisplayLimit}`); + } + + private escalateHttp416OrFail(item: DownloadItem, active: ActiveTask, claimedTargetPath: string, errorText: string): void { + const freshRestarts = this.http416FreshRestartByItem.get(item.id) || 0; + if (freshRestarts < MAX_HTTP416_FRESH_RESTARTS) { + this.http416FreshRestartByItem.set(item.id, freshRestarts + 1); + const resetTargetPath = claimedTargetPath || String(item.targetPath || "").trim(); + if (resetTargetPath) { + try { + fs.rmSync(resetTargetPath, { force: true }); + } catch { + } + } + this.releaseTargetPath(item.id); + this.dropItemContribution(item.id); + item.retries += 1; + item.downloadedBytes = 0; + item.totalBytes = null; + item.progressPercent = 0; + item.speedBps = 0; + item.lastError = ""; + active.genericErrorRetries = 0; + active.freshRetryUsed = false; + active.resumeHardResetUsed = false; + logger.warn( + `HTTP 416 Budget erschöpft: item=${item.fileName || item.id}, ` + + `kompletter Neu-Download ${freshRestarts + 1}/${MAX_HTTP416_FRESH_RESTARTS} (Partial verworfen, kein Resume), provider=${item.provider || "?"}` + ); + this.queueRetry(item, active, getHttp416FreshRestartDelayMs(), `Range-Konflikt (HTTP 416): Neu-Download ${freshRestarts + 1}/${MAX_HTTP416_FRESH_RESTARTS}`); + this.persistSoon(); + this.emitState(); + return; + } + this.http416FreshRestartByItem.delete(item.id); + item.status = "failed"; + this.recordRunOutcome(item.id, "failed"); + item.lastError = errorText; + item.fullStatus = `Fehler: ${item.lastError}`; + item.speedBps = 0; + item.updatedAt = nowMs(); + const failPkg = this.session.packages[item.packageId]; + if (failPkg) { + this.refreshPackageStatus(failPkg); + } + this.persistSoon(); + this.emitState(); + this.retryStateByItem.delete(item.id); + } + + private startItem(packageId: string, itemId: string): void { + const item = this.session.items[itemId]; + const pkg = this.session.packages[packageId]; + if (!item || !pkg || pkg.cancelled || !pkg.enabled) { + return; + } + if (item.status !== "queued" && item.status !== "reconnect_wait") { + return; + } + if (this.activeTasks.has(itemId)) { + return; + } + const maxParallel = Math.max(1, Number(this.settings.maxParallel) || 1); + if (this.activeTasks.size >= maxParallel) { + logger.warn(`startItem übersprungen (Parallel-Limit): active=${this.activeTasks.size}, max=${maxParallel}, item=${item.fileName || item.id}`); + return; + } + + this.retryAfterByItem.delete(itemId); + + const preflightReason = `${item.fullStatus || ""} ${item.lastError || ""}`.trim(); + if (shouldPreflightFinalizeItemFromDisk(item) + && this.tryFinalizeItemFromDisk(pkg, item, "Start-Preflight", preflightReason)) { + this.retryStateByItem.delete(item.id); + this.refreshPackageStatus(pkg); + this.persistSoon(); + return; + } + + this.notePackageDownloadStarted(pkg); + item.status = "validating"; + item.fullStatus = "Link wird umgewandelt"; + item.speedBps = 0; + if (item.downloadedBytes === 0) { + item.progressPercent = 0; + } + item.updatedAt = nowMs(); + pkg.status = "downloading"; + pkg.updatedAt = nowMs(); + this.logPackageForItem(item, "INFO", "Download-Slot gestartet", { + packageId, + maxParallel: Math.max(1, Number(this.settings.maxParallel) || 1) + }); + + const active: ActiveTask = { + itemId, + packageId, + abortController: new AbortController(), + abortReason: "none", + resumable: true, + nonResumableCounted: false, + blockedOnDiskWrite: false, + blockedOnDiskSince: 0 + }; + this.activeTasks.set(itemId, active); + this.notePacedStartForItem(item, nowMs()); + this.emitState(); + + void this.processItem(active).catch((err) => { + logger.warn(`processItem unbehandelt (${itemId}): ${compactErrorText(err)}`); + }).finally(() => { + if (!this.retryAfterByItem.has(item.id)) { + this.releaseTargetPath(item.id); + } + if (active.nonResumableCounted) { + this.nonResumableActive = Math.max(0, this.nonResumableActive - 1); + } + this.activeTasks.delete(itemId); + this.persistSoon(); + this.emitState(); + }); + } + + private async processItem(active: ActiveTask): Promise<void> { + const item = this.session.items[active.itemId]; + const pkg = this.session.packages[active.packageId]; + if (!item || !pkg) { + return; + } + + const retryState = this.retryStateByItem.get(item.id) || { + freshRetryUsed: false, + resumeHardResetUsed: false, + stallRetries: 0, + genericErrorRetries: 0, + unrestrictRetries: 0 + }; + this.retryStateByItem.set(item.id, retryState); + active.freshRetryUsed = retryState.freshRetryUsed; + active.resumeHardResetUsed = retryState.resumeHardResetUsed; + active.stallRetries = retryState.stallRetries; + active.genericErrorRetries = retryState.genericErrorRetries; + active.unrestrictRetries = retryState.unrestrictRetries; + const configuredRetryLimit = normalizeRetryLimit(this.settings.retryLimit); + const retryDisplayLimit = retryLimitLabel(configuredRetryLimit); + const maxItemRetries = retryLimitToMaxRetries(configuredRetryLimit); + const maxItemAttempts = configuredRetryLimit <= 0 ? Number.MAX_SAFE_INTEGER : maxItemRetries + 1; + const maxGenericErrorRetries = maxItemRetries; + const maxUnrestrictRetries = maxItemRetries; + const maxStallRetries = maxItemRetries; + const maxHttp416Retries = configuredRetryLimit <= 0 ? 3 : Math.max(1, Math.min(maxItemRetries, 3)); + while (true) { + try { + const preflightReason = `${item.fullStatus || ""} ${item.lastError || ""}`.trim(); + if (shouldPreflightFinalizeItemFromDisk(item) + && this.tryFinalizeItemFromDisk(pkg, item, "Process-Preflight", preflightReason)) { + this.retryStateByItem.delete(item.id); + return; + } + this.logPackageForItem(item, "INFO", "Link-Umwandlung gestartet", { + url: item.url, + retryLimit: retryDisplayLimit + }); + while (this.session.paused && this.session.running && !active.abortController.signal.aborted) { + item.status = "paused"; + item.fullStatus = "Pausiert"; + item.speedBps = 0; + this.emitState(); + await sleep(120); + } + if (active.abortController.signal.aborted) { + throw new Error(`aborted:${active.abortReason}`); + } + const cooldownProvider = this.getProviderFailureKeyForItem(item); + const cooldownMs = this.getProviderCooldownRemaining(cooldownProvider); + let preferredLeadProvider: DebridProvider | null = null; + if (cooldownMs > 0) { + if (this.settings.autoProviderFallback) { + const fallback = this.findFallbackProviderNotInCooldown(item); + if (fallback) { + logger.info(`Provider-Cooldown: ${cooldownProvider} noch ${Math.ceil(cooldownMs / 1000)}s, wechsle zu ${fallback} für ${item.fileName || item.url}`); + this.logPackageForItem(item, "WARN", "Provider-Cooldown erkannt, Fallback gewählt", { + provider: cooldownProvider, + remainingMs: cooldownMs, + fallback + }); + item.provider = null; + preferredLeadProvider = fallback; + } else { + this.logPackageForItem(item, "WARN", "Provider-Cooldown blockiert Unrestrict", { + provider: cooldownProvider, + remainingMs: cooldownMs + }); + const delayMs = Math.min(cooldownMs + 1000, 310000); + this.queueRetry(item, active, delayMs, `Provider-Cooldown (${Math.ceil(delayMs / 1000)}s)`); + this.persistSoon(); + this.emitState(); + return; + } + } else { + this.logPackageForItem(item, "WARN", "Provider-Cooldown blockiert Unrestrict", { + provider: cooldownProvider, + remainingMs: cooldownMs + }); + const delayMs = Math.min(cooldownMs + 1000, 310000); + this.queueRetry(item, active, delayMs, `Provider-Cooldown (${Math.ceil(delayMs / 1000)}s)`); + this.persistSoon(); + this.emitState(); + return; + } + } + if (await this.maybeApplyAllDebridRapidgatorBackoff(item, active)) { + this.persistSoon(); + this.emitState(); + return; + } + const unrestrictTimeoutSignal = AbortSignal.timeout(getUnrestrictTimeoutMs()); + const unrestrictedSignal = AbortSignal.any([active.abortController.signal, unrestrictTimeoutSignal]); + let unrestricted; + try { + unrestricted = await runWithConversionTrace( + { + itemId: item.id, + itemName: item.fileName || item.id, + link: item.url, + providerOrder: (this.settings.providerOrder || []).join(",") || String(this.getExpectedProviderForItem(item) || "?") + }, + async () => { + traceConversionNote("slots", this.describeSlotOccupancy()); + traceConversionNote("retry", Number(active.unrestrictRetries || 0)); + try { + return await this.debridService.unrestrictLink(item.url, unrestrictedSignal, undefined, preferredLeadProvider); + } catch (innerError) { + if (!active.abortController.signal.aborted && unrestrictTimeoutSignal.aborted) { + traceConversionPhase({ + phase: "caller-timeout", + outcome: "timeout", + detail: `Caller-Budget ${Math.ceil(getUnrestrictTimeoutMs() / 1000)}s erschoepft (siehe letzte Phase fuer in-flight Provider/Account)` + }); + } + throw innerError; + } + } + ); + } catch (unrestrictError) { + if (!active.abortController.signal.aborted && unrestrictTimeoutSignal.aborted) { + this.recordProviderFailure(cooldownProvider); + throw new Error(`Unrestrict Timeout nach ${Math.ceil(getUnrestrictTimeoutMs() / 1000)}s`); + } + const errText = compactErrorText(unrestrictError); + if (isUnrestrictFailure(errText) && !isHosterUnavailableError(errText)) { + this.recordProviderFailure(cooldownProvider); + if (isProviderBusyUnrestrictError(errText) || isTemporaryUnrestrictError(errText)) { + const busyCooldownMs = isTemporaryUnrestrictError(errText) + ? Math.min(180000, 20000 + Number(active.unrestrictRetries || 0) * 10000) + : Math.min(60000, 12000 + Number(active.unrestrictRetries || 0) * 3000); + this.applyProviderBusyBackoff(cooldownProvider, busyCooldownMs); + } + } + throw unrestrictError; + } + if (active.abortController.signal.aborted) { + throw new Error(`aborted:${active.abortReason}`); + } + this.recordProviderSuccess(this.getProviderFailureKeyForItem(item, unrestricted.provider)); + item.provider = unrestricted.provider; + item.providerLabel = unrestricted.providerLabel; + item.providerAccountId = unrestricted.sourceAccountId; + item.providerAccountLabel = unrestricted.sourceAccountLabel; + item.retries += unrestricted.retriesUsed; + item.fileName = sanitizeFilename(unrestricted.fileName || filenameFromUrl(item.url)); + let directHost = ""; + try { + directHost = new URL(unrestricted.directUrl).host; + } catch { + directHost = ""; + } + try { + fs.mkdirSync(pkg.outputDir, { recursive: true }); + } catch (mkdirError) { + throw new Error(`Zielordner kann nicht erstellt werden: ${compactErrorText(mkdirError)}`); + } + const existingTargetPath = String(item.targetPath || "").trim(); + const canReuseExistingTarget = existingTargetPath + && isPathInsideDir(existingTargetPath, pkg.outputDir) + && (item.downloadedBytes > 0 || fs.existsSync(existingTargetPath)); + const preferredTargetPath = canReuseExistingTarget + ? existingTargetPath + : path.join(pkg.outputDir, item.fileName); + item.targetPath = this.claimTargetPath(item.id, preferredTargetPath, Boolean(canReuseExistingTarget)); + item.totalBytes = unrestricted.fileSize; + item.status = "downloading"; + const pLabel = unrestricted.providerLabel; + const statusLabel = providerLabel(unrestricted.provider) || pLabel; + item.fullStatus = `Starte... (${statusLabel})`; + item.updatedAt = nowMs(); + this.emitState(); + logger.info(`Download Start: ${item.fileName} (${humanSize(unrestricted.fileSize || 0)}) via ${pLabel}, pkg=${pkg.name}`); + this.logPackageForItem(item, "INFO", "Link umgewandelt", { + provider: unrestricted.provider, + providerLabel: unrestricted.providerLabel || "", + accountId: unrestricted.sourceAccountId || "", + accountLabel: unrestricted.sourceAccountLabel || "", + sizeBytes: unrestricted.fileSize, + targetPath: item.targetPath, + directHost, + directUrl: unrestricted.directUrl, + resumableHint: unrestricted.retriesUsed >= 0 + }); + + const maxAttempts = maxItemAttempts; + let done = false; + while (!done && item.attempts < maxAttempts) { + item.attempts += 1; + this.logPackageForItem(item, "INFO", "Download-Versuch startet", { + attempt: item.attempts, + maxAttempts: maxAttempts === Number.MAX_SAFE_INTEGER ? "infinite" : maxAttempts, + provider: unrestricted.provider, + targetPath: item.targetPath, + existingBytes: item.downloadedBytes, + totalBytes: item.totalBytes + }); + if (item.status !== "downloading") { + item.status = "downloading"; + item.fullStatus = `Download läuft (${statusLabel})`; + item.updatedAt = nowMs(); + this.emitState(); + } + const result = await this.downloadToFile(active, unrestricted.directUrl, item.targetPath, item.totalBytes, unrestricted.skipTlsVerify, pLabel); + active.resumable = result.resumable; + if (!active.resumable && !active.nonResumableCounted) { + active.nonResumableCounted = true; + this.nonResumableActive += 1; + } + + if (active.abortController.signal.aborted) { + throw new Error(`aborted:${active.abortReason}`); + } + + if (this.settings.enableIntegrityCheck) { + item.status = "integrity_check"; + item.fullStatus = "CRC-Check läuft"; + item.updatedAt = nowMs(); + this.emitState(); + + const integrityStartedAt = nowMs(); + const validation = await validateFileAgainstManifest(item.targetPath, pkg.outputDir); + if (active.abortController.signal.aborted) { + throw new Error(`aborted:${active.abortReason}`); + } + const integrityElapsedMs = nowMs() - integrityStartedAt; + if (!validation.ok) { + item.lastError = validation.message; + item.fullStatus = `${validation.message}, Neuversuch`; + this.logPackageForItem(item, "WARN", "Integritätsprüfung fehlgeschlagen", { + result: validation.message, + elapsedMs: integrityElapsedMs, + willRetry: item.attempts < maxAttempts + }); + try { + fs.rmSync(item.targetPath, { force: true }); + } catch (rmErr) { + logger.debug(`Integrity-Cleanup rm fehlgeschlagen (${item.fileName}): ${String(rmErr)}`); + } + if (item.attempts < maxAttempts) { + item.status = "integrity_check"; + item.progressPercent = 0; + item.downloadedBytes = 0; + item.totalBytes = unrestricted.fileSize; + this.emitState(); + await sleep(300); + continue; + } + throw new Error(`Integritätsprüfung fehlgeschlagen (${validation.message})`); + } + // Symmetry: a passed check was previously silent in the item log, so a + // reader could not tell whether integrity ran and passed vs was skipped. + this.logPackageForItem(item, "INFO", "Integritätsprüfung bestanden", { + elapsedMs: integrityElapsedMs + }); + } + + if (active.abortController.signal.aborted) { + throw new Error(`aborted:${active.abortReason}`); + } + + const finalTargetPath = String(item.targetPath || "").trim(); + let fileSizeOnDisk = item.downloadedBytes; + if (finalTargetPath) { + try { + const stat = await fs.promises.stat(finalTargetPath); + fileSizeOnDisk = stat.size; + } catch { + } + } + const tooSmall = shouldRejectSuspiciousSmallDownload( + finalTargetPath, + item.fileName, + fileSizeOnDisk, + item.totalBytes + ); + if (tooSmall) { + try { + fs.rmSync(finalTargetPath, { force: true }); + } catch { + } + this.releaseTargetPath(item.id); + item.downloadedBytes = 0; + item.progressPercent = 0; + item.totalBytes = (item.totalBytes || 0) > 0 ? item.totalBytes : null; + item.speedBps = 0; + item.updatedAt = nowMs(); + throw new Error(`Datei zu klein (${humanSize(fileSizeOnDisk)}, erwartet ${item.totalBytes ? humanSize(item.totalBytes) : ">= 100 KB"})`); + } + + done = true; + } + + if (active.abortController.signal.aborted) { + throw new Error(`aborted:${active.abortReason}`); + } + + const completedAt = nowMs(); + item.status = "completed"; + item.fullStatus = this.settings.autoExtract + ? "Entpacken - Ausstehend" + : `Fertig (${humanSize(item.downloadedBytes)})`; + item.progressPercent = 100; + item.speedBps = 0; + item.updatedAt = completedAt; + this.notePackageDownloadCompleted(pkg, completedAt); + pkg.updatedAt = completedAt; + this.recordRunOutcome(item.id, "completed"); + logger.info(`Download fertig: ${item.fileName} (${humanSize(item.downloadedBytes)}), pkg=${pkg.name}`); + this.logPackageForItem(item, "INFO", "Download abgeschlossen", { + downloadedBytes: item.downloadedBytes, + totalBytes: item.totalBytes, + autoExtract: this.settings.autoExtract + }); + + if (this.session.running && !active.abortController.signal.aborted) { + void this.runPackagePostProcessing(pkg.id).catch((err) => { + logger.warn(`runPackagePostProcessing Fehler (processItem): ${compactErrorText(err)}`); + }).finally(() => { + this.applyCompletedCleanupPolicy(pkg.id, item.id); + this.persistSoon(); + this.emitState(); + }); + } + this.persistSoon(); + this.emitState(); + this.retryStateByItem.delete(item.id); + return; + } catch (error) { + if (this.session.items[item.id] !== item) { + if (active.abortReason === "cancel") { + const orphanClaimedPath = this.claimedTargetPathByItem.get(item.id) || item.targetPath || ""; + if (orphanClaimedPath) { + try { + fs.rmSync(orphanClaimedPath, { force: true }); + } catch { + } + } + } + return; + } + const reason = active.abortReason; + const claimedTargetPath = this.claimedTargetPathByItem.get(item.id) || ""; + if (reason === "cancel") { + this.logPackageForItem(item, "WARN", "Download abgebrochen durch Entfernen", { + reason + }); + item.status = "cancelled"; + item.fullStatus = "Entfernt"; + this.recordRunOutcome(item.id, "cancelled"); + if (claimedTargetPath) { + try { + fs.rmSync(claimedTargetPath, { force: true }); + } catch { + } + } + item.downloadedBytes = 0; + item.progressPercent = 0; + item.totalBytes = null; + this.dropItemContribution(item.id); + this.retryStateByItem.delete(item.id); + } else if (reason === "stop") { + this.logPackageForItem(item, "WARN", "Download gestoppt", { + reason + }); + if (!this.session.running) { + item.status = "cancelled"; + item.fullStatus = "Gestoppt"; + this.recordRunOutcome(item.id, "cancelled"); + } + if (!active.resumable && claimedTargetPath && !fs.existsSync(claimedTargetPath)) { + item.downloadedBytes = 0; + item.progressPercent = 0; + item.totalBytes = null; + this.dropItemContribution(item.id); + } + this.retryStateByItem.delete(item.id); + } else if (reason === "shutdown") { + this.logPackageForItem(item, "WARN", "Download für Shutdown geparkt", { + reason + }); + item.status = "queued"; + item.speedBps = 0; + const activePkg = this.session.packages[item.packageId]; + item.fullStatus = activePkg && !activePkg.enabled ? "Paket gestoppt" : "Wartet"; + this.retryStateByItem.delete(item.id); + } else if (reason === "reconnect") { + this.logPackageForItem(item, "WARN", "Download wartet auf Reconnect", { + reason + }); + item.status = "queued"; + item.speedBps = 0; + item.fullStatus = "Wartet auf Reconnect"; + this.retryStateByItem.set(item.id, { + freshRetryUsed: Boolean(active.freshRetryUsed), + resumeHardResetUsed: Boolean(active.resumeHardResetUsed), + stallRetries: Number(active.stallRetries || 0), + genericErrorRetries: Number(active.genericErrorRetries || 0), + unrestrictRetries: Number(active.unrestrictRetries || 0) + }); + } else if (reason === "reset") { + this.retryStateByItem.delete(item.id); + } else if (reason === "package_toggle") { + this.logPackageForItem(item, "WARN", "Download wegen Paket-Toggle pausiert", { + reason + }); + item.status = "queued"; + item.speedBps = 0; + item.fullStatus = "Paket gestoppt"; + this.retryStateByItem.set(item.id, { + freshRetryUsed: Boolean(active.freshRetryUsed), + resumeHardResetUsed: Boolean(active.resumeHardResetUsed), + stallRetries: Number(active.stallRetries || 0), + genericErrorRetries: Number(active.genericErrorRetries || 0), + unrestrictRetries: Number(active.unrestrictRetries || 0) + }); + } else if (reason === "stall") { + const stallErrorText = compactErrorText(error); + this.logPackageForItem(item, "WARN", "Stall erkannt", { + error: stallErrorText, + downloadedBytes: item.downloadedBytes + }); + const isSlowThroughput = stallErrorText.includes("slow_throughput"); + const wasValidating = item.status === "validating"; + active.stallRetries += 1; + logger.warn(`Stall erkannt: item=${item.fileName || item.id}, phase=${wasValidating ? "validating" : "downloading"}, retry=${active.stallRetries}/${retryDisplayLimit}, bytes=${item.downloadedBytes}, error=${stallErrorText || "none"}, provider=${item.provider || "?"}`); + const totalFailures = (active.stallRetries || 0) + (active.unrestrictRetries || 0) + (active.genericErrorRetries || 0); + if (totalFailures >= 15) { + item.retries += 1; + if (configuredRetryLimit > 0 && item.retries >= configuredRetryLimit) { + item.status = "failed"; + item.lastError = stallErrorText || "Wiederholt fehlgeschlagen"; + item.fullStatus = `Fehler: ${item.lastError}`; + item.speedBps = 0; + item.updatedAt = nowMs(); + this.recordRunOutcome(item.id, "failed"); + this.retryStateByItem.delete(item.id); + const shelveFailPkgStall = this.session.packages[item.packageId]; + if (shelveFailPkgStall) this.refreshPackageStatus(shelveFailPkgStall); + this.persistSoon(); + this.emitState(); + return; + } + active.stallRetries = Math.floor((active.stallRetries || 0) / 2); + active.unrestrictRetries = Math.floor((active.unrestrictRetries || 0) / 2); + active.genericErrorRetries = Math.floor((active.genericErrorRetries || 0) / 2); + const oldProvider = item.provider; + item.provider = null; + if (oldProvider) { + this.providerFailures.delete(oldProvider); + } + const shelveDurationMs = 90000; + logger.warn(`Item shelved: ${item.fileName || item.id}, totalFailures=${totalFailures}, oldProvider=${oldProvider || "?"}, provider+circuit-breaker reset, pause=${shelveDurationMs}ms`); + this.queueRetry(item, active, shelveDurationMs, `Viele Fehler (${totalFailures}x), Pause ${Math.ceil(shelveDurationMs / 1000)}s`); + item.lastError = stallErrorText; + this.persistSoon(); + this.emitState(); + return; + } + if (active.stallRetries <= maxStallRetries) { + item.retries += 1; + if (item.downloadedBytes > 0) { + const targetFile = this.claimedTargetPathByItem.get(item.id) || ""; + if (this.tryFinalizeItemFromDisk(pkg, item, "Stall-Recovery", stallErrorText)) { + return; + } + if (targetFile) { + try { fs.rmSync(targetFile, { force: true }); } catch { } + } + this.releaseTargetPath(item.id); + item.downloadedBytes = 0; + item.progressPercent = 0; + item.totalBytes = null; + this.dropItemContribution(item.id); + } + let stallDelayMs = retryDelayWithJitter(active.stallRetries, 200); + const providerCooldownKey = this.getProviderFailureKeyForItem(item); + const providerCooldown = this.getProviderCooldownRemaining(providerCooldownKey); + if (providerCooldown > stallDelayMs) { + stallDelayMs = providerCooldown + 1000; + } + const retryText = wasValidating + ? `Link-Umwandlung hing, Retry ${active.stallRetries}/${retryDisplayLimit}` + : isSlowThroughput + ? `Zu wenig Datenfluss, Retry ${active.stallRetries}/${retryDisplayLimit}` + : `Keine Daten empfangen, Retry ${active.stallRetries}/${retryDisplayLimit}`; + this.queueRetry(item, active, stallDelayMs, retryText); + item.lastError = ""; + this.persistSoon(); + this.emitState(); + return; + } + item.status = "failed"; + item.lastError = wasValidating ? "Link-Umwandlung hing wiederholt" : "Download hing wiederholt"; + item.fullStatus = `Fehler: ${item.lastError}`; + this.recordRunOutcome(item.id, "failed"); + this.retryStateByItem.delete(item.id); + } else { + const errorText = compactErrorText(error); + if (this.tryFinalizeItemFromDisk(pkg, item, "Error-Recovery", errorText)) { + return; + } + this.logPackageForItem(item, "WARN", "Download-Fehlerpfad erreicht", { + error: errorText, + abortReason: reason || "none" + }); + const directLinkRetryMatch = errorText.match(/^(?:Error:\s*)?direct_link_retry_exhausted:(.+)$/); + if (directLinkRetryMatch) { + const exhaustedReason = compactErrorText(directLinkRetryMatch[1] || errorText).replace(/^Error:\s*/i, ""); + if (item.provider === "debridlink") { + await sleep(450); + if (this.tryFinalizeItemFromDisk(pkg, item, "DebridLink-Settle-Recovery", exhaustedReason)) { + return; + } + } + if (isHttp416Text(exhaustedReason)) { + if (active.genericErrorRetries < maxHttp416Retries) { + this.scheduleHttp416Retry(item, active, retryDisplayLimit, exhaustedReason, claimedTargetPath); + this.persistSoon(); + this.emitState(); + return; + } + this.escalateHttp416OrFail(item, active, claimedTargetPath, exhaustedReason); + return; + } + if (isResumeHardResetReason(exhaustedReason) && !active.resumeHardResetUsed) { + active.resumeHardResetUsed = true; + item.retries += 1; + logger.warn(`Resume-Neustart: item=${item.fileName || item.id}, error=${exhaustedReason}, provider=${item.provider || "?"}`); + const resetTargetPath = claimedTargetPath || String(item.targetPath || "").trim(); + if (resetTargetPath) { + try { + fs.rmSync(resetTargetPath, { force: true }); + } catch { + } + } + this.releaseTargetPath(item.id); + this.dropItemContribution(item.id); + item.lastError = exhaustedReason; + item.downloadedBytes = 0; + item.totalBytes = null; + item.progressPercent = 0; + this.queueRetry(item, active, 300, "Resume-Fehler erkannt, kompletter Neuversuch"); + this.persistSoon(); + this.emitState(); + return; + } + } + if (directLinkRetryMatch && active.genericErrorRetries < maxGenericErrorRetries) { + active.genericErrorRetries += 1; + item.retries += 1; + const exhaustedReason = compactErrorText(directLinkRetryMatch[1] || errorText).replace(/^Error:\s*/i, ""); + const refreshDelayMs = retryDelayWithJitter(active.genericErrorRetries, 200); + logger.warn( + `Direktlink erschöpft: item=${item.fileName || item.id}, ` + + `retry=${active.genericErrorRetries}/${retryDisplayLimit}, error=${exhaustedReason}, provider=${item.provider || "?"}` + ); + this.queueRetry( + item, + active, + refreshDelayMs, + exhaustedReason.startsWith("range_ignored_on_resume:") + ? `Resume-Link erneuern, Retry ${active.genericErrorRetries}/${retryDisplayLimit}` + : `Direktlink erneuern, Retry ${active.genericErrorRetries}/${retryDisplayLimit}` + ); + item.lastError = exhaustedReason; + this.persistSoon(); + this.emitState(); + return; + } + const shouldFreshRetry = !active.freshRetryUsed && isFetchFailure(errorText); + const isHttp416 = isHttp416Text(errorText); + if (isHttp416) { + if (active.genericErrorRetries < maxHttp416Retries) { + this.scheduleHttp416Retry(item, active, retryDisplayLimit, errorText, claimedTargetPath); + this.persistSoon(); + this.emitState(); + return; + } + this.escalateHttp416OrFail(item, active, claimedTargetPath, errorText); + return; + } + if (shouldFreshRetry) { + active.freshRetryUsed = true; + item.retries += 1; + logger.warn(`Netzwerkfehler: item=${item.fileName || item.id}, fresh retry, error=${errorText}, provider=${item.provider || "?"}`); + if (claimedTargetPath) { + try { + fs.rmSync(claimedTargetPath, { force: true }); + } catch { + } + } + this.releaseTargetPath(item.id); + this.dropItemContribution(item.id); + this.queueRetry(item, active, 300, "Netzwerkfehler erkannt, frischer Retry"); + item.lastError = ""; + item.downloadedBytes = 0; + item.totalBytes = null; + item.progressPercent = 0; + this.persistSoon(); + this.emitState(); + return; + } + + if (isPermanentLinkError(errorText)) { + logger.error(`Link permanent ungültig: item=${item.fileName || item.id}, error=${errorText}, link=${item.url.slice(0, 80)}`); + item.status = "failed"; + this.recordRunOutcome(item.id, "failed"); + item.lastError = errorText; + item.fullStatus = `Link ungültig: ${errorText}`; + item.speedBps = 0; + item.updatedAt = nowMs(); + this.retryStateByItem.delete(item.id); + const failPkgDead = this.session.packages[item.packageId]; + if (failPkgDead) this.refreshPackageStatus(failPkgDead); + this.persistSoon(); + this.emitState(); + return; + } + + const totalNonStallFailures = (active.stallRetries || 0) + (active.unrestrictRetries || 0) + (active.genericErrorRetries || 0); + if (totalNonStallFailures >= 15) { + item.retries += 1; + if (configuredRetryLimit > 0 && item.retries >= configuredRetryLimit) { + item.status = "failed"; + item.lastError = errorText || "Wiederholt fehlgeschlagen"; + item.fullStatus = `Fehler: ${item.lastError}`; + item.speedBps = 0; + item.updatedAt = nowMs(); + this.recordRunOutcome(item.id, "failed"); + this.retryStateByItem.delete(item.id); + const shelveFailPkgErr = this.session.packages[item.packageId]; + if (shelveFailPkgErr) this.refreshPackageStatus(shelveFailPkgErr); + this.persistSoon(); + this.emitState(); + return; + } + active.stallRetries = Math.floor((active.stallRetries || 0) / 2); + active.unrestrictRetries = Math.floor((active.unrestrictRetries || 0) / 2); + active.genericErrorRetries = Math.floor((active.genericErrorRetries || 0) / 2); + const oldProvider = item.provider; + item.provider = null; + if (oldProvider) { + this.providerFailures.delete(oldProvider); + } + const shelveDurationMs = 90000; + logger.warn(`Item shelved (error path): ${item.fileName || item.id}, totalFailures=${totalNonStallFailures}, error=${errorText}, oldProvider=${oldProvider || "?"}, provider+circuit-breaker reset, pause=${shelveDurationMs}ms`); + this.queueRetry(item, active, shelveDurationMs, `Viele Fehler (${totalNonStallFailures}x), Pause ${Math.ceil(shelveDurationMs / 1000)}s`); + item.lastError = errorText; + this.persistSoon(); + this.emitState(); + return; + } + + if (isHosterUnavailableError(errorText) && active.unrestrictRetries < maxUnrestrictRetries) { + active.unrestrictRetries += 1; + item.retries += 1; + item.provider = null; + const hosterDelayMs = Math.min(30000, Math.floor(5000 * Math.pow(1.5, Math.min(active.unrestrictRetries - 1, 5)))); + logger.warn(`Hoster nicht verfügbar: item=${item.fileName || item.id}, retry=${active.unrestrictRetries}/${retryDisplayLimit}, delay=${hosterDelayMs}ms, link=${item.url.slice(0, 80)}`); + if (item.downloadedBytes > 0) { + const targetFile = this.claimedTargetPathByItem.get(item.id) || ""; + if (targetFile) { + try { fs.rmSync(targetFile, { force: true }); } catch { } + } + this.releaseTargetPath(item.id); + item.downloadedBytes = 0; + item.progressPercent = 0; + item.totalBytes = null; + this.dropItemContribution(item.id); + } + this.queueRetry(item, active, hosterDelayMs, `Hoster nicht verfügbar, Retry ${active.unrestrictRetries}/${retryDisplayLimit} (${Math.ceil(hosterDelayMs / 1000)}s)`); + item.lastError = errorText; + this.persistSoon(); + this.emitState(); + return; + } + + const debridLinkTerminalFailure = parseDebridLinkTerminalFailure(errorText); + if (debridLinkTerminalFailure) { + item.status = "failed"; + this.recordRunOutcome(item.id, "failed"); + item.lastError = debridLinkTerminalFailure.detail; + item.fullStatus = `Debrid-Link: ${debridLinkTerminalFailure.detail}`; + item.speedBps = 0; + item.updatedAt = nowMs(); + this.retryStateByItem.delete(item.id); + const failPkgDl = this.session.packages[item.packageId]; + if (failPkgDl) this.refreshPackageStatus(failPkgDl); + this.persistSoon(); + this.emitState(); + return; + } + + const megaRawError = error instanceof Error ? String(error.message || "") : String(error || ""); + const megaSlowLinkRetry = parseMegaDebridSlowLinkRetry(megaRawError); + if (megaSlowLinkRetry && active.unrestrictRetries < maxUnrestrictRetries) { + active.unrestrictRetries += 1; + item.retries += 1; + item.provider = null; + logger.warn(`Mega-Debrid Link langsam (Timeout): item=${item.fileName || item.id}, retry=${active.unrestrictRetries}/${retryDisplayLimit}, delay=${megaSlowLinkRetry.delayMs}ms, link=${item.url.slice(0, 80)}`); + this.queueRetry( + item, + active, + megaSlowLinkRetry.delayMs, + `Mega-Debrid: Link zu langsam, Einzel-Retry in ${Math.ceil(megaSlowLinkRetry.delayMs / 1000)}s` + ); + item.lastError = megaSlowLinkRetry.detail || errorText; + this.persistSoon(); + this.emitState(); + return; + } + + const megaCooldownRetry = parseMegaDebridCooldownRetry(megaRawError); + if (megaCooldownRetry && active.unrestrictRetries < maxUnrestrictRetries) { + active.unrestrictRetries += 1; + item.retries += 1; + logger.warn(`Mega-Debrid Account-Cooldown: item=${item.fileName || item.id}, retry=${active.unrestrictRetries}/${retryDisplayLimit}, delay=${megaCooldownRetry.delayMs}ms, link=${item.url.slice(0, 80)}`); + this.queueRetry( + item, + active, + megaCooldownRetry.delayMs, + `Mega-Debrid Cooldown, neuer Versuch in ${Math.ceil(megaCooldownRetry.delayMs / 1000)}s` + ); + item.lastError = megaCooldownRetry.detail || errorText; + this.persistSoon(); + this.emitState(); + return; + } + + const megaResetPark = parseMegaDebridResetPark(megaRawError); + if (megaResetPark && active.unrestrictRetries < maxUnrestrictRetries) { + active.unrestrictRetries += 1; + item.retries += 1; + logger.warn(`Mega-Debrid bis Tagesreset gesperrt: item=${item.fileName || item.id}, retry=${active.unrestrictRetries}/${retryDisplayLimit}, delay=${megaResetPark.delayMs}ms, link=${item.url.slice(0, 80)}`); + this.queueRetry( + item, + active, + megaResetPark.delayMs, + `Mega-Debrid bis Tagesreset gesperrt, Pause ${Math.ceil(megaResetPark.delayMs / 1000)}s` + ); + item.lastError = megaResetPark.detail || errorText; + this.persistSoon(); + this.emitState(); + return; + } + + if (isMegaDebridTransientResolveFailure(errorText) && active.unrestrictRetries < maxUnrestrictRetries) { + active.unrestrictRetries += 1; + item.retries += 1; + const transientDelayMs = transientResolveRetryDelayMs(active.unrestrictRetries); + const transientReason = germanMegaDebridResolveReason(errorText); + logger.warn(`Transienter Mega-Debrid-Resolve-Fehler: item=${item.fileName || item.id}, retry=${active.unrestrictRetries}/${retryDisplayLimit}, delay=${transientDelayMs}ms, error=${errorText}, link=${item.url.slice(0, 80)}`); + if (item.downloadedBytes > 0) { + const targetFile = this.claimedTargetPathByItem.get(item.id) || ""; + if (targetFile) { + try { fs.rmSync(targetFile, { force: true }); } catch { } + } + this.releaseTargetPath(item.id); + item.downloadedBytes = 0; + item.progressPercent = 0; + item.totalBytes = null; + this.dropItemContribution(item.id); + } + this.queueRetry( + item, + active, + transientDelayMs, + `${transientReason} — neuer Versuch ${active.unrestrictRetries}/${retryDisplayLimit} (${Math.ceil(transientDelayMs / 1000)}s)` + ); + item.lastError = transientReason; + this.persistSoon(); + this.emitState(); + return; + } + + if (isUnrestrictFailure(errorText) && active.unrestrictRetries < maxUnrestrictRetries) { + const debridLinkCooldown = parseDebridLinkCooldownRetry(errorText); + if (debridLinkCooldown) { + active.unrestrictRetries += 1; + item.retries += 1; + logger.warn( + `Debrid-Link-Cooldown: item=${item.fileName || item.id}, ` + + `retry=${active.unrestrictRetries}/${retryDisplayLimit}, delay=${debridLinkCooldown.delayMs}ms, ` + + `detail=${debridLinkCooldown.detail || errorText}, link=${item.url.slice(0, 80)}` + ); + this.queueRetry( + item, + active, + debridLinkCooldown.delayMs, + `Debrid-Link Cooldown, Retry ${active.unrestrictRetries}/${retryDisplayLimit} (${Math.ceil(debridLinkCooldown.delayMs / 1000)}s)` + ); + item.lastError = debridLinkCooldown.detail || errorText; + this.persistSoon(); + this.emitState(); + return; + } + + active.unrestrictRetries += 1; + item.retries += 1; + const failureProvider = this.getProviderFailureKeyForItem(item); + const isDebridLinkError = /debrid-link|debrid_link/i.test(errorText) || failureProvider === "debridlink"; + if (!isDebridLinkError) { + this.recordProviderFailure(failureProvider); + if (isProviderBusyUnrestrictError(errorText) || isTemporaryUnrestrictError(errorText)) { + const busyCooldownMs = isTemporaryUnrestrictError(errorText) + ? Math.min(180000, 20000 + Number(active.unrestrictRetries || 0) * 10000) + : Math.min(60000, 12000 + Number(active.unrestrictRetries || 0) * 3000); + this.applyProviderBusyBackoff(failureProvider, busyCooldownMs); + } + } + let unrestrictDelayMs = Math.min(120000, Math.floor(5000 * Math.pow(1.5, active.unrestrictRetries - 1))); + const providerCooldown = this.getProviderCooldownRemaining(failureProvider); + if (providerCooldown > unrestrictDelayMs) { + unrestrictDelayMs = providerCooldown + 1000; + } + logger.warn(`Unrestrict-Fehler: item=${item.fileName || item.id}, retry=${active.unrestrictRetries}/${retryDisplayLimit}, delay=${unrestrictDelayMs}ms, error=${errorText}, link=${item.url.slice(0, 80)}`); + if (item.downloadedBytes > 0) { + const targetFile = this.claimedTargetPathByItem.get(item.id) || ""; + if (targetFile) { + try { fs.rmSync(targetFile, { force: true }); } catch { } + } + this.releaseTargetPath(item.id); + item.downloadedBytes = 0; + item.progressPercent = 0; + item.totalBytes = null; + this.dropItemContribution(item.id); + } + this.queueRetry( + item, + active, + unrestrictDelayMs, + `Link-Umwandlung erneut, Versuch ${active.unrestrictRetries}/${retryDisplayLimit} (${Math.ceil(unrestrictDelayMs / 1000)}s)` + ); + item.lastError = errorText; + this.persistSoon(); + this.emitState(); + return; + } + + if (active.genericErrorRetries < maxGenericErrorRetries) { + active.genericErrorRetries += 1; + item.retries += 1; + const genericDelayMs = retryDelayWithJitter(active.genericErrorRetries, 250); + logger.warn(`Generic-Fehler: item=${item.fileName || item.id}, retry=${active.genericErrorRetries}/${retryDisplayLimit}, error=${errorText}, provider=${item.provider || "?"}`); + this.queueRetry(item, active, genericDelayMs, `Fehler erkannt, Auto-Retry ${active.genericErrorRetries}/${retryDisplayLimit}`); + item.lastError = errorText; + this.persistSoon(); + this.emitState(); + return; + } + + item.status = "failed"; + this.recordRunOutcome(item.id, "failed"); + item.lastError = errorText; + item.fullStatus = `Fehler: ${item.lastError}`; + logger.error(`Item endgültig fehlgeschlagen: item=${item.fileName || item.id}, error=${errorText}, provider=${item.provider || "?"}, stallRetries=${active.stallRetries}, unrestrictRetries=${active.unrestrictRetries}, genericRetries=${active.genericErrorRetries}`); + this.retryStateByItem.delete(item.id); + } + item.speedBps = 0; + item.updatedAt = nowMs(); + const failPkg = this.session.packages[item.packageId]; + if (failPkg) this.refreshPackageStatus(failPkg); + this.persistSoon(); + this.emitState(); + return; + } + } + } + + private async downloadToFile( + active: ActiveTask, + directUrl: string, + targetPath: string, + knownTotal: number | null, + skipTlsVerify?: boolean, + pLabel?: string + ): Promise<{ resumable: boolean }> { + const label = providerLabel(this.session.items[active.itemId]?.provider) || pLabel || "Debrid"; + const item = this.session.items[active.itemId]; + if (!item) { + throw new Error("Download-Item fehlt"); + } + const logAttemptEvent = (level: "INFO" | "WARN" | "ERROR", message: string, fields?: Record<string, unknown>): void => { + this.logPackageForItem(item, level, message, fields); + }; + + const configuredRetryLimit = normalizeRetryLimit(this.settings.retryLimit); + const retryDisplayLimit = retryLimitLabel(configuredRetryLimit); + const maxAttemptsBySetting = configuredRetryLimit <= 0 ? Number.MAX_SAFE_INTEGER : configuredRetryLimit + 1; + const maxAttempts = Math.max(1, Math.min(MAX_SAME_DIRECT_URL_ATTEMPTS, maxAttemptsBySetting)); + + let lastError = ""; + let effectiveTargetPath = targetPath; + let resumeRewindBytesNextAttempt = 0; + for (let attempt = 1; attempt <= maxAttempts; attempt += 1) { + let existingBytes = 0; + try { + const stat = await fs.promises.stat(effectiveTargetPath); + existingBytes = stat.size; + } catch { + } + if (existingBytes > 0 && resumeRewindBytesNextAttempt > 0) { + const previousBytes = existingBytes; + const rewindBytes = Math.min(existingBytes, resumeRewindBytesNextAttempt); + const resumeStart = Math.max(0, existingBytes - rewindBytes); + try { + await fs.promises.truncate(effectiveTargetPath, resumeStart); + existingBytes = resumeStart; + item.downloadedBytes = Math.min(item.downloadedBytes, existingBytes); + resumeRewindBytesNextAttempt = 0; + logAttemptEvent("WARN", "Resume-Schutz aktiv: Teil-Datei vor Retry zurueckgespult", { + attempt, + previousBytes, + rewindBytes, + resumeStart + }); + } catch (rewindError) { + logAttemptEvent("WARN", "Resume-Schutz: Rueckspulen der Teil-Datei fehlgeschlagen", { + attempt, + previousBytes, + rewindBytes, + error: compactErrorText(rewindError) + }); + } + } else if (resumeRewindBytesNextAttempt > 0) { + resumeRewindBytesNextAttempt = 0; + } + const persistedBytes = Math.max(0, Math.floor(Number(item.downloadedBytes) || 0)); + const preallocMismatchThreshold = resolvePreallocResumeMismatchThreshold(item.fileName || effectiveTargetPath || ""); + if (existingBytes > 0 && existingBytes > persistedBytes + preallocMismatchThreshold) { + try { + const previousBytes = existingBytes; + await fs.promises.truncate(effectiveTargetPath, persistedBytes); + existingBytes = persistedBytes; + logAttemptEvent("WARN", "Pre-alloc-Rest erkannt, Teil-Datei auf persistierte Bytes gekuerzt", { + attempt, + previousBytes, + persistedBytes + }); + } catch { + if (persistedBytes === 0) { + try { + await fs.promises.rm(effectiveTargetPath, { force: true }); + existingBytes = 0; + } catch { + } + } + } + } + const suspiciousResumeFootprint = existingBytes > 0 + && existingBytes > persistedBytes + preallocMismatchThreshold; + const headers: Record<string, string> = {}; + if (existingBytes > 0) { + headers.Range = `bytes=${existingBytes}-`; + } + logAttemptEvent("INFO", "HTTP-Download-Versuch vorbereitet", { + attempt, + maxAttempts: maxAttempts === Number.MAX_SAFE_INTEGER ? "infinite" : maxAttempts, + directUrl, + targetPath: effectiveTargetPath, + knownTotal, + existingBytes, + rangeHeader: headers.Range || "" + }); + + while (this.reconnectActive()) { + if (active.abortController.signal.aborted) { + throw new Error(`aborted:${active.abortReason}`); + } + await sleep(250); + } + + let response: Response; + const connectTimeoutMs = getDownloadConnectTimeoutMs(); + let connectTimer: NodeJS.Timeout | null = null; + const connectAbortController = new AbortController(); + if (skipTlsVerify) acquireTlsSkip(); + try { + if (connectTimeoutMs > 0) { + connectTimer = setTimeout(() => { + connectAbortController.abort("connect_timeout"); + }, connectTimeoutMs); + } + response = await fetch(directUrl, { + method: "GET", + headers, + signal: AbortSignal.any([active.abortController.signal, connectAbortController.signal]) + }); + } catch (error) { + if (active.abortController.signal.aborted || String(error).includes("aborted:")) { + throw error; + } + lastError = compactErrorText(error); + logAttemptEvent("WARN", "HTTP-Verbindung fehlgeschlagen", { + attempt, + error: lastError + }); + if (attempt < maxAttempts) { + item.retries += 1; + item.fullStatus = `Verbindungsfehler, retry ${attempt}/${retryDisplayLimit}`; + this.emitState(); + await sleep(retryDelayWithJitter(attempt, 200)); + continue; + } + throw error; + } finally { + if (skipTlsVerify) releaseTlsSkip(); + if (connectTimer) { + clearTimeout(connectTimer); + } + } + + if (!response.ok) { + logAttemptEvent(response.status >= 500 ? "WARN" : "ERROR", "HTTP-Antwort nicht erfolgreich", { + attempt, + status: response.status, + statusText: response.statusText, + existingBytes + }); + if (response.status === 416 && existingBytes > 0) { + await response.arrayBuffer().catch(() => undefined); + const rangeTotal = parseContentRangeTotal(response.headers.get("content-range")); + const expectedTotal = rangeTotal && rangeTotal > 0 + ? rangeTotal + : (knownTotal && knownTotal > 0 ? knownTotal : null); + const sizeToleranceBytes = isLargeBinaryLikePath(item.fileName || effectiveTargetPath) ? 0 : ALLOCATION_UNIT_SIZE; + const closeEnoughToExpected = expectedTotal != null + && Math.abs(existingBytes - expectedTotal) <= sizeToleranceBytes; + if (expectedTotal != null && closeEnoughToExpected && !suspiciousResumeFootprint) { + const finalizedTotal = Math.max(existingBytes, expectedTotal); + item.totalBytes = finalizedTotal; + item.downloadedBytes = existingBytes; + item.progressPercent = 100; + item.speedBps = 0; + item.updatedAt = nowMs(); + logAttemptEvent("INFO", "HTTP 416 als vollständig behandelt", { + existingBytes, + expectedTotal: finalizedTotal + }); + return { resumable: true }; + } + if (expectedTotal != null && closeEnoughToExpected && suspiciousResumeFootprint) { + logAttemptEvent("WARN", "HTTP 416 trotz Vollgroesse nicht als fertig gewertet (vermutlich pre-alloc)", { + attempt, + existingBytes, + persistedBytes, + expectedTotal + }); + } + + try { + await fs.promises.rm(effectiveTargetPath, { force: true }); + } catch { + } + this.dropItemContribution(active.itemId); + item.downloadedBytes = 0; + item.totalBytes = knownTotal && knownTotal > 0 ? knownTotal : null; + item.progressPercent = 0; + item.speedBps = 0; + item.fullStatus = `Range-Konflikt (HTTP 416), starte neu ${attempt}/${retryDisplayLimit}`; + item.updatedAt = nowMs(); + this.emitState(); + if (item.provider === "debridlink") { + logAttemptEvent("WARN", "Debrid-Link HTTP 416: Direktlink sofort verwerfen", { + attempt, + existingBytes, + expectedTotal: expectedTotal || null + }); + throw new Error("direct_link_retry_exhausted:HTTP 416"); + } + if (attempt < maxAttempts) { + item.retries += 1; + await sleep(retryDelayWithJitter(attempt, 200)); + continue; + } + lastError = "HTTP 416"; + throw new Error(lastError); + } + const text = await response.text(); + lastError = `HTTP ${response.status}`; + const responseText = compactErrorText(text || ""); + if (responseText && responseText !== "Unbekannter Fehler" && !/(^|\b)http\s*\d{3}\b/i.test(responseText)) { + lastError = `HTTP ${response.status}: ${responseText}`; + } + if (attempt < maxAttempts) { + item.retries += 1; + item.fullStatus = `Serverfehler ${response.status}, retry ${attempt}/${retryDisplayLimit}`; + this.emitState(); + await sleep(retryDelayWithJitter(attempt, 250)); + continue; + } + if (this.settings.autoReconnect && [429, 503].includes(response.status)) { + this.requestReconnect(`HTTP ${response.status}`); + logAttemptEvent("WARN", "Reconnect angefordert wegen HTTP-Status", { + status: response.status + }); + throw new Error(lastError); + } + throw new Error(lastError); + } + + const acceptRanges = (response.headers.get("accept-ranges") || "").toLowerCase().includes("bytes"); + let preAllocated = false; + let written = 0; + try { + if (existingBytes === 0) { + const rawHeaderName = parseContentDispositionFilename(response.headers.get("content-disposition")).trim(); + const fromHeader = rawHeaderName ? sanitizeFilename(rawHeaderName) : ""; + if (fromHeader && !looksLikeOpaqueFilename(fromHeader) && fromHeader !== item.fileName) { + const pkg = this.session.packages[item.packageId]; + if (pkg) { + this.releaseTargetPath(item.id); + effectiveTargetPath = this.claimTargetPath(item.id, path.join(pkg.outputDir, fromHeader)); + item.fileName = fromHeader; + item.targetPath = effectiveTargetPath; + item.updatedAt = nowMs(); + this.emitState(); + logAttemptEvent("INFO", "Dateiname aus Content-Disposition übernommen", { + headerFileName: fromHeader, + newTargetPath: effectiveTargetPath + }); + } + } + } + + const resumable = response.status === 206 || acceptRanges; + active.resumable = resumable; + + const rawContentLength = Number(response.headers.get("content-length") || 0); + const contentLength = Number.isFinite(rawContentLength) && rawContentLength > 0 ? rawContentLength : 0; + const parsedContentRange = parseContentRange(response.headers.get("content-range")); + const totalFromRange = parsedContentRange?.total ?? null; + const serverIgnoredRange = existingBytes > 0 && response.status === 200; + const allowFreshOverwriteAfterResumeReset = serverIgnoredRange + && active.resumeHardResetUsed + && isRealDebridProvider(item.provider); + if (serverIgnoredRange && !allowFreshOverwriteAfterResumeReset) { + logger.warn(`Server ignorierte Range-Header (HTTP 200 statt 206), verwerfe Direktlink und behalte Teil-Datei: ${item.fileName}`); + logAttemptEvent("WARN", "Server ignorierte Range-Header beim Resume", { + attempt, + existingBytes, + contentLength, + directUrl + }); + try { + await response.body?.cancel(); + } catch { + } + throw new Error(`range_ignored_on_resume:${existingBytes}/${contentLength || 0}`); + } + if (allowFreshOverwriteAfterResumeReset) { + logger.warn( + `Server ignorierte Range-Header nach Resume-Reset, ueberschreibe alte Teil-Datei frisch: ${item.fileName}` + ); + logAttemptEvent("WARN", "Range ignoriert nach Resume-Reset, frischer Vollstart erlaubt", { + attempt, + existingBytes, + contentLength, + directUrl + }); + } + if (existingBytes > 0 && response.status === 206) { + if (!parsedContentRange) { + logAttemptEvent("WARN", "Resume-Range-Header ungueltig oder fehlt", { + attempt, + existingBytes, + contentRange: response.headers.get("content-range") || "" + }); + try { + await response.body?.cancel(); + } catch { + } + throw new Error(`range_mismatch_on_resume:${existingBytes}/invalid`); + } + if (parsedContentRange.start !== existingBytes) { + const sizeToleranceBytes = isLargeBinaryLikePath(item.fileName || effectiveTargetPath) ? 0 : ALLOCATION_UNIT_SIZE; + const canTreatAsAlreadyComplete = contentLength === 0 + && parsedContentRange.start === 0 + && parsedContentRange.total != null + && Math.abs(existingBytes - parsedContentRange.total) <= sizeToleranceBytes; + if (canTreatAsAlreadyComplete) { + item.totalBytes = parsedContentRange.total; + item.downloadedBytes = existingBytes; + item.progressPercent = 100; + item.speedBps = 0; + item.updatedAt = nowMs(); + logAttemptEvent("WARN", "Resume-Range-Start abweichend, Datei aber bereits vollstaendig", { + attempt, + existingBytes, + totalFromRange: parsedContentRange.total, + contentLength + }); + return { resumable: true }; + } + logAttemptEvent("WARN", "Resume-Range-Start stimmt nicht mit lokaler Dateigroesse ueberein", { + attempt, + expectedStart: existingBytes, + actualStart: parsedContentRange.start, + actualEnd: parsedContentRange.end, + totalFromRange, + directUrl + }); + try { + await response.body?.cancel(); + } catch { + } + throw new Error(`range_mismatch_on_resume:${existingBytes}/${parsedContentRange.start}`); + } + } + + const correctedRealDebridTotal = getAuthoritativeRealDebridTotal( + item.provider, + knownTotal || 0, + existingBytes, + response.status, + contentLength, + totalFromRange, + Boolean(active.resumeHardResetUsed) + ); + if (correctedRealDebridTotal) { + item.totalBytes = correctedRealDebridTotal.totalBytes; + logger.warn( + `Real-Debrid-Zielgroesse korrigiert: ${item.fileName} ` + + `known=${knownTotal}, corrected=${correctedRealDebridTotal.totalBytes}, ` + + `source=${correctedRealDebridTotal.source}` + ); + logAttemptEvent("WARN", "Real-Debrid-Zielgroesse aus HTTP korrigiert", { + attempt, + source: correctedRealDebridTotal.source, + knownTotal, + correctedTotal: correctedRealDebridTotal.totalBytes, + mismatchBytes: correctedRealDebridTotal.mismatchBytes, + existingBytes, + contentLength, + totalFromRange + }); + } else if (knownTotal && knownTotal > 0) { + item.totalBytes = knownTotal; + } else if (totalFromRange) { + item.totalBytes = totalFromRange; + } else if (contentLength > 0) { + item.totalBytes = response.status === 206 ? existingBytes + contentLength : contentLength; + } + const completionPlan = planDownloadCompletion({ + existingBytes, + responseStatus: response.status, + contentLength, + totalFromRange, + knownTotal, + correctedTotal: correctedRealDebridTotal?.totalBytes || null + }); + + const writeMode = existingBytes > 0 && response.status === 206 ? "a" : "w"; + logAttemptEvent("INFO", "HTTP-Antwort akzeptiert", { + attempt, + status: response.status, + acceptRanges, + resumable, + contentLength, + totalFromRange, + totalBytes: item.totalBytes, + writeMode + }); + if (writeMode === "w") { + const previouslyContributed = this.itemContributedBytes.get(active.itemId) || 0; + if (previouslyContributed > 0) { + this.session.totalDownloadedBytes = Math.max(0, this.session.totalDownloadedBytes - previouslyContributed); + this.sessionDownloadedBytes = Math.max(0, this.sessionDownloadedBytes - previouslyContributed); + this.settings.totalDownloadedAllTime = Math.max(0, Number(this.settings.totalDownloadedAllTime || 0) - previouslyContributed); + this.itemContributedBytes.set(active.itemId, 0); + } + if (existingBytes > 0) { + await fs.promises.rm(effectiveTargetPath, { force: true }); + } + } + + await fs.promises.mkdir(path.dirname(effectiveTargetPath), { recursive: true }); + + if (writeMode === "w" && item.totalBytes && item.totalBytes > 0 && process.platform === "win32") { + try { + const fd = await fs.promises.open(effectiveTargetPath, "w"); + try { + await fd.truncate(item.totalBytes); + preAllocated = true; + } finally { + await fd.close(); + } + } catch { } + } + + const stream = fs.createWriteStream(effectiveTargetPath, { + flags: preAllocated ? "r+" : writeMode, + start: preAllocated ? 0 : undefined, + highWaterMark: STREAM_HIGH_WATER_MARK + }); + written = writeMode === "a" ? existingBytes : 0; + let windowBytes = 0; + let windowStarted = nowMs(); + let lastPackageLogAt = 0; + let lastLoggedPercent = -1; + const itemCount = this.itemCount; + const uiUpdateIntervalMs = itemCount >= 1500 + ? 500 + : itemCount >= 700 + ? 350 + : itemCount >= 250 + ? 220 + : 120; + let lastUiEmitAt = 0; + const stallTimeoutMs = getDownloadStallTimeoutMs(); + const drainTimeoutMs = Math.max(30000, Math.min(300000, stallTimeoutMs > 0 ? stallTimeoutMs * 12 : 120000)); + let lastDiskBusyEmitAt = 0; + let diskBusySince = 0; + const diskBusyStatusVisible = (nowTick: number): boolean => { + const blockedSince = active.blockedOnDiskSince || 0; + if (blockedSince > 0 && nowTick - blockedSince >= DISK_BUSY_STATUS_THRESHOLD_MS) { + return true; + } + return diskBusySince > 0 && nowTick - diskBusySince >= DISK_BUSY_STATUS_THRESHOLD_MS; + }; + + const waitDrain = (): Promise<void> => new Promise((resolve, reject) => { + if (active.abortController.signal.aborted) { + reject(new Error(`aborted:${active.abortReason}`)); + return; + } + + active.blockedOnDiskWrite = true; + active.blockedOnDiskSince = nowMs(); + if (item.status !== "paused" && !this.session.paused) { + const nowTick = nowMs(); + if (diskBusyStatusVisible(nowTick) && nowTick - lastDiskBusyEmitAt >= 1200) { + item.status = "downloading"; + item.speedBps = 0; + item.fullStatus = `Warte auf Festplatte (${label})`; + item.updatedAt = nowTick; + this.emitState(); + lastDiskBusyEmitAt = nowTick; + logAttemptEvent("WARN", "Schreibtask wartet auf Festplatte", { + attempt, + writableLength: stream.writableLength + }); + } + } + + let settled = false; + let timeoutId: NodeJS.Timeout | null = setTimeout(() => { + if (settled) { + return; + } + settled = true; + cleanup(); + reject(new Error("write_drain_timeout")); + }, drainTimeoutMs); + + const cleanup = (): void => { + if (timeoutId) { + clearTimeout(timeoutId); + timeoutId = null; + } + active.blockedOnDiskWrite = false; + active.blockedOnDiskSince = 0; + stream.off("drain", onDrain); + stream.off("error", onError); + active.abortController.signal.removeEventListener("abort", onAbort); + }; + + const onDrain = (): void => { + if (settled) { + return; + } + settled = true; + cleanup(); + resolve(); + }; + const onError = (streamError: Error): void => { + if (settled) { + return; + } + settled = true; + cleanup(); + reject(streamError); + }; + const onAbort = (): void => { + if (settled) { + return; + } + settled = true; + cleanup(); + reject(new Error(`aborted:${active.abortReason}`)); + }; + + stream.once("drain", onDrain); + stream.once("error", onError); + active.abortController.signal.addEventListener("abort", onAbort, { once: true }); + }); + + const writeBuf = Buffer.allocUnsafe(WRITE_BUFFER_SIZE); + let writeBufPos = 0; + let lastFlushAt = nowMs(); + + const alignedFlush = async (final = false): Promise<void> => { + if (writeBufPos === 0) return; + let toWrite = writeBufPos; + if (!final && toWrite > ALLOCATION_UNIT_SIZE) { + toWrite = toWrite - (toWrite % ALLOCATION_UNIT_SIZE); + } + const slice = Buffer.from(writeBuf.subarray(0, toWrite)); + if (!stream.write(slice)) { + await waitDrain(); + } + if (toWrite < writeBufPos) { + writeBuf.copy(writeBuf, 0, toWrite, writeBufPos); + } + writeBufPos -= toWrite; + lastFlushAt = nowMs(); + }; + + let bodyError: unknown = null; + try { + const body = response.body; + if (!body) { + throw new Error("Leerer Response-Body"); + } + const reader = body.getReader(); + let lastDataAt = nowMs(); + let lastIdleEmitAt = 0; + const lowThroughputTimeoutMs = getLowThroughputTimeoutMs(); + const lowThroughputMinBytes = getLowThroughputMinBytes(); + let throughputWindowStartedAt = nowMs(); + let throughputWindowBytes = 0; + const idlePulseMs = Math.max(1500, Math.min(3500, Math.floor(stallTimeoutMs / 4) || 2000)); + const idleTimer = setInterval(() => { + if (active.abortController.signal.aborted) { + return; + } + const nowTick = nowMs(); + if (active.blockedOnDiskWrite) { + if (item.status === "paused" || this.session.paused) { + return; + } + if (diskBusyStatusVisible(nowTick) && nowTick - lastIdleEmitAt >= idlePulseMs) { + item.status = "downloading"; + item.speedBps = 0; + item.fullStatus = `Warte auf Festplatte (${label})`; + item.updatedAt = nowTick; + this.emitState(); + lastIdleEmitAt = nowTick; + lastDiskBusyEmitAt = nowTick; + } + return; + } + if (nowTick - lastDataAt < idlePulseMs) { + return; + } + if (item.status === "paused" || this.session.paused) { + return; + } + item.status = "downloading"; + item.speedBps = 0; + item.fullStatus = `Warte auf Daten (${label})`; + if (nowTick - lastIdleEmitAt >= idlePulseMs) { + item.updatedAt = nowTick; + this.emitState(); + lastIdleEmitAt = nowTick; + logAttemptEvent("WARN", "Download wartet auf Daten", { + attempt, + idleForMs: nowTick - lastDataAt + }); + } + }, idlePulseMs); + const readWithTimeout = async (): Promise<ReadableStreamReadResult<Uint8Array>> => { + if (stallTimeoutMs <= 0) { + return reader.read(); + } + return new Promise<ReadableStreamReadResult<Uint8Array>>((resolve, reject) => { + let settled = false; + const timer = setTimeout(() => { + if (settled) { + return; + } + settled = true; + active.abortReason = "stall"; + active.abortController.abort("stall"); + reject(new Error("stall_timeout")); + }, stallTimeoutMs); + + reader.read().then((result) => { + if (settled) { + return; + } + settled = true; + clearTimeout(timer); + resolve(result); + }).catch((error) => { + if (settled) { + return; + } + settled = true; + clearTimeout(timer); + reject(error); + }); + }); + }; + + try { + while (true) { + const { done, value } = await readWithTimeout(); + if (done) { + break; + } + const chunk = value; + lastDataAt = nowMs(); + if (active.abortController.signal.aborted) { + throw new Error(`aborted:${active.abortReason}`); + } + let pausedDuringWait = false; + while (this.session.paused && this.session.running && !active.abortController.signal.aborted) { + pausedDuringWait = true; + item.status = "paused"; + item.fullStatus = "Pausiert"; + this.emitState(); + await sleep(120); + } + if (pausedDuringWait) { + throughputWindowStartedAt = nowMs(); + throughputWindowBytes = 0; + } + if (active.abortController.signal.aborted) { + throw new Error(`aborted:${active.abortReason}`); + } + if (this.reconnectActive()) { + active.abortReason = "reconnect"; + active.abortController.abort("reconnect"); + throw new Error("aborted:reconnect"); + } + + const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk.buffer, chunk.byteOffset, chunk.byteLength); + await this.applySpeedLimit(buffer.length, windowBytes, windowStarted, active.abortController.signal); + if (active.abortController.signal.aborted) { + throw new Error(`aborted:${active.abortReason}`); + } + + let srcOffset = 0; + while (srcOffset < buffer.length) { + const space = WRITE_BUFFER_SIZE - writeBufPos; + const toCopy = Math.min(space, buffer.length - srcOffset); + buffer.copy(writeBuf, writeBufPos, srcOffset, srcOffset + toCopy); + writeBufPos += toCopy; + srcOffset += toCopy; + if (writeBufPos >= Math.floor(WRITE_BUFFER_SIZE * 0.80)) { + await alignedFlush(false); + } + } + if (writeBufPos > 0 && nowMs() - lastFlushAt >= WRITE_FLUSH_TIMEOUT_MS) { + await alignedFlush(false); + } + + if (stream.writableLength > 0) { + if (diskBusySince === 0) diskBusySince = nowMs(); + const busyMs = nowMs() - diskBusySince; + if (busyMs >= DISK_BUSY_STATUS_THRESHOLD_MS && item.status !== "paused" && !this.session.paused) { + const nowTick = nowMs(); + if (nowTick - lastDiskBusyEmitAt >= 1200) { + item.status = "downloading"; + item.speedBps = 0; + item.fullStatus = `Warte auf Festplatte (${label})`; + item.updatedAt = nowTick; + this.emitState(); + lastDiskBusyEmitAt = nowTick; + logAttemptEvent("WARN", "Festplatten-Backpressure erkannt", { + attempt, + busyMs + }); + } + } + } else { + diskBusySince = 0; + } + + written += buffer.length; + windowBytes += buffer.length; + this.session.totalDownloadedBytes += buffer.length; + this.sessionDownloadedBytes += buffer.length; + this.settings.totalDownloadedAllTime += buffer.length; + this.recordProviderDownloadedBytes(item.provider, buffer.length, item.providerAccountId); + this.itemContributedBytes.set(active.itemId, (this.itemContributedBytes.get(active.itemId) || 0) + buffer.length); + this.recordSpeed(buffer.length, item.packageId); + throughputWindowBytes += buffer.length; + + if (completionPlan.canFinishEarly && completionPlan.expectedTotal && written >= completionPlan.expectedTotal) { + break; + } + + const throughputNow = nowMs(); + if (lowThroughputTimeoutMs > 0 && throughputNow - throughputWindowStartedAt >= lowThroughputTimeoutMs) { + if (throughputWindowBytes < lowThroughputMinBytes) { + active.abortReason = "stall"; + active.abortController.abort("stall"); + throw new Error(`slow_throughput:${throughputWindowBytes}/${lowThroughputMinBytes}`); + } + throughputWindowStartedAt = throughputNow; + throughputWindowBytes = 0; + } + + const elapsed = Math.max((nowMs() - windowStarted) / 1000, 0.2); + const speed = windowBytes / elapsed; + if (elapsed >= 0.5) { + windowStarted = nowMs(); + windowBytes = 0; + } + + item.status = "downloading"; + item.downloadedBytes = written; + item.progressPercent = item.totalBytes ? Math.max(0, Math.min(100, Math.floor((written / item.totalBytes) * 100))) : 0; + const diskBusy = diskBusyStatusVisible(nowMs()); + if (diskBusy) { + item.speedBps = 0; + item.fullStatus = `Warte auf Festplatte (${label})`; + } else { + item.speedBps = Math.max(0, Math.floor(speed)); + item.fullStatus = `Download läuft (${label})`; + } + const progressNow = nowMs(); + const currentPercent = item.totalBytes ? Math.max(0, Math.min(100, Math.floor((written / item.totalBytes) * 100))) : 0; + const shouldLogProgress = currentPercent >= lastLoggedPercent + 10 + || progressNow - lastPackageLogAt >= 5000 + || (item.totalBytes ? written >= item.totalBytes : false); + if (shouldLogProgress) { + lastPackageLogAt = progressNow; + lastLoggedPercent = currentPercent; + logAttemptEvent("INFO", "Download-Fortschritt", { + attempt, + written, + totalBytes: item.totalBytes, + percent: currentPercent, + speedBps: item.speedBps, + diskBusy + }); + } + const nowTick = nowMs(); + if (nowTick - lastUiEmitAt >= uiUpdateIntervalMs) { + item.updatedAt = nowTick; + this.emitState(); + lastUiEmitAt = nowTick; + } + } + } finally { + clearInterval(idleTimer); + try { + await reader.cancel().catch(() => {}); + reader.releaseLock(); + } catch { + } + } + } catch (error) { + bodyError = error; + logAttemptEvent("WARN", "Download-Body fehlgeschlagen", { + attempt, + error: compactErrorText(error) + }); + throw error; + } finally { + try { + await alignedFlush(true); + } catch (flushError) { + if (!bodyError) { + bodyError = flushError; + } + } + try { + await new Promise<void>((resolve, reject) => { + if (stream.closed || stream.destroyed) { + resolve(); + return; + } + const onDone = (): void => { + stream.off("error", onError); + stream.off("finish", onDone); + stream.off("close", onDone); + resolve(); + }; + const onError = (streamError: Error): void => { + stream.off("finish", onDone); + stream.off("close", onDone); + reject(streamError); + }; + stream.once("finish", onDone); + stream.once("close", onDone); + stream.once("error", onError); + stream.end(); + }); + } catch (streamCloseError) { + if (!stream.destroyed) { + stream.destroy(); + } + if (!bodyError) { + throw streamCloseError; + } + logger.warn(`Stream-Abschlussfehler unterdrückt: ${compactErrorText(streamCloseError)}`); + } + if (!stream.destroyed) { + stream.destroy(); + } + if (!bodyError && preAllocated) { + try { + const syncFd = await fs.promises.open(effectiveTargetPath, "r"); + try { + await syncFd.datasync(); + } finally { + await syncFd.close(); + } + } catch { } + } + if (bodyError) { + throw bodyError; + } + } + + try { + const finalizedStat = await fs.promises.stat(effectiveTargetPath); + const reconciledSize = reconcileFinalizedSize(written, finalizedStat.size, preAllocated); + if (reconciledSize !== written) { + logAttemptEvent("WARN", "Dateigroesse nach Stream-Abschluss korrigiert", { + attempt, + previousWritten: written, + statSize: finalizedStat.size, + reconciledSize + }); + written = reconciledSize; + } + } catch { + } + + if (written > 0 && written < 512) { + const knownSmallFile = KNOWN_SMALL_FILE_RE.test(item.fileName || effectiveTargetPath); + if (knownSmallFile && ((!item.totalBytes || item.totalBytes <= 0) || written >= item.totalBytes)) { + logger.info(`Kleine Metadaten-Datei akzeptiert (${written} B): ${item.fileName || effectiveTargetPath}`); + } else { + let snippet = ""; + try { + snippet = await fs.promises.readFile(effectiveTargetPath, "utf8"); + snippet = snippet.slice(0, 200).replace(/[\r\n]+/g, " ").trim(); + } catch { } + const exactTinyBinary = Boolean( + item.totalBytes + && item.totalBytes > 0 + && written >= item.totalBytes + && isLargeBinaryLikePath(item.fileName || effectiveTargetPath) + ); + const snippetSuggestsError = /<(?:!doctype|html|body)\b|\b(?:forbidden|access denied|error|not found|expired|unavailable)\b/i.test(snippet); + if (exactTinyBinary && !snippetSuggestsError) { + logger.info(`Tiny Binary akzeptiert (${written} B): ${item.fileName || effectiveTargetPath}`); + } else { + logger.warn(`Tiny download erkannt (${written} B): "${snippet}"`); + try { + await fs.promises.rm(effectiveTargetPath, { force: true }); + } catch { } + this.releaseTargetPath(active.itemId); + item.downloadedBytes = 0; + item.progressPercent = 0; + throw new Error(`Download zu klein (${written} B) – Hoster-Fehlerseite?${snippet ? ` Inhalt: "${snippet}"` : ""}`); + } + } + } + + const completionValidation = validateDownloadedFileCompletion({ + actualBytes: written, + plan: completionPlan, + toleranceBytes: isLargeBinaryLikePath(item.fileName || effectiveTargetPath) ? 0 : ALLOCATION_UNIT_SIZE + }); + if (!completionValidation.ok) { + const shortfall = Math.max(0, completionValidation.totalBytes - written); + if (preAllocated) { + try { + await fs.promises.truncate(effectiveTargetPath, written); + } catch { } + } + logger.warn(`Download-Underflow: erwartet=${completionValidation.totalBytes}, erhalten=${written}, shortfall=${shortfall} fuer ${item.fileName}`); + item.downloadedBytes = written; + item.progressPercent = completionValidation.totalBytes > 0 + ? Math.max(0, Math.min(99, Math.floor((written / completionValidation.totalBytes) * 100))) + : 0; + item.speedBps = 0; + throw new Error(completionValidation.error || `download_underflow:${written}/${completionValidation.totalBytes}`); + } + + if (completionValidation.acceptedMetadataMismatch) { + logger.warn( + `Provider-Groesseninfo verworfen, HTTP-EOF als vollstaendig akzeptiert: ` + + `${item.fileName} erwartet=${completionPlan.expectedTotal}, erhalten=${written}` + ); + logAttemptEvent("WARN", "Provider-Groesseninfo weicht von finaler Dateigroesse ab", { + attempt, + expectedTotal: completionPlan.expectedTotal, + actualBytes: written, + source: completionPlan.source + }); + } + + if (preAllocated && item.totalBytes && written < item.totalBytes) { + try { + await fs.promises.truncate(effectiveTargetPath, written); + } catch { } + logger.warn(`Pre-alloc underflow: erwartet=${item.totalBytes}, erhalten=${written} für ${item.fileName}`); + } + + item.downloadedBytes = written; + item.totalBytes = completionValidation.totalBytes > 0 ? completionValidation.totalBytes : item.totalBytes; + item.progressPercent = item.totalBytes ? Math.max(0, Math.min(100, Math.floor((written / item.totalBytes) * 100))) : 100; + item.speedBps = 0; + item.fullStatus = "Finalisierend..."; + item.updatedAt = nowMs(); + this.emitState(); + logAttemptEvent("INFO", "HTTP-Download-Versuch abgeschlossen", { + attempt, + resumable, + written, + finalBytes: item.downloadedBytes, + totalBytes: item.totalBytes, + targetPath: effectiveTargetPath + }); + return { resumable }; + } catch (error) { + if (preAllocated && item.totalBytes && written < item.totalBytes) { + try { await fs.promises.truncate(effectiveTargetPath, written); } catch { } + } + if (active.abortController.signal.aborted || String(error).includes("aborted:")) { + throw error; + } + lastError = compactErrorText(error); + const normalizedLastError = lastError.replace(/^Error:\s*/i, ""); + const diskCause = classifyDiskError(error); + logAttemptEvent("WARN", "HTTP-Download-Versuch fehlgeschlagen", { + attempt, + error: lastError, + targetPath: effectiveTargetPath, + ...(diskCause ? { diskCause } : {}) + }); + if (diskCause) { + // Surface the concrete OS cause (disk full, permission, ...) prominently + // instead of leaving only a generic write/stream error in the log. + logger.error(`Schreibfehler beim Download: ${diskCause} - ${item.fileName} (ziel=${effectiveTargetPath})`); + } + if ( + normalizedLastError.startsWith("range_ignored_on_resume:") + || normalizedLastError.startsWith("range_mismatch_on_resume:") + ) { + throw new Error(`direct_link_retry_exhausted:${normalizedLastError}`); + } + if (attempt < maxAttempts && written > existingBytes && shouldRewindResumeTail(normalizedLastError)) { + resumeRewindBytesNextAttempt = Math.max(resumeRewindBytesNextAttempt, RESUME_REWIND_BYTES); + logAttemptEvent("WARN", "Resume-Schutz vorgemerkt: naechster Retry startet mit Rewind", { + attempt, + existingBytes, + written, + appendedBytes: Math.max(0, written - existingBytes), + rewindBytes: resumeRewindBytesNextAttempt, + error: normalizedLastError + }); + } + if (attempt < maxAttempts) { + item.retries += 1; + item.fullStatus = `Downloadfehler, retry ${attempt}/${maxAttempts} (Direktlink)`; + this.emitState(); + await sleep(retryDelayWithJitter(attempt, 250)); + continue; + } + if ( + item.totalBytes != null && item.totalBytes > 0 + && written > existingBytes + && shouldRewindResumeTail(normalizedLastError) + ) { + const rewindTarget = Math.max(0, written - RESUME_REWIND_BYTES); + item.downloadedBytes = Math.min(item.downloadedBytes, rewindTarget); + try { + await fs.promises.truncate(effectiveTargetPath, rewindTarget); + logAttemptEvent("WARN", "Resume-Schutz: letzter Versuch vor Linkerneuerung zurueckgespult", { + attempt, + written, + rewindTarget + }); + } catch (rewindError) { + try { + fs.rmSync(effectiveTargetPath, { force: true }); + item.downloadedBytes = 0; + } catch { + } + logAttemptEvent("WARN", "Resume-Schutz: finales Rueckspulen fehlgeschlagen, Teil-Datei verworfen", { + attempt, + written, + rewindTarget, + error: compactErrorText(rewindError) + }); + } + } + if (maxAttemptsBySetting > maxAttempts) { + const exhaustedError = existingBytes > 0 && normalizedLastError.startsWith("download_underflow:") + ? `resume_download_underflow:${normalizedLastError.slice("download_underflow:".length)}` + : (normalizedLastError || lastError || "Download fehlgeschlagen"); + throw new Error(`direct_link_retry_exhausted:${exhaustedError}`); + } + throw new Error(normalizedLastError || lastError || "Download fehlgeschlagen"); + } + } + + if (maxAttemptsBySetting > maxAttempts) { + throw new Error(`direct_link_retry_exhausted:${lastError || "Download fehlgeschlagen"}`); + } + throw new Error(lastError || "Download fehlgeschlagen"); + } + + private async recoverRetryableItems(trigger: "startup" | "start"): Promise<number> { + let recovered = 0; + let finalized = 0; + const touchedPackages = new Set<string>(); + const configuredRetryLimit = normalizeRetryLimit(this.settings.retryLimit); + const maxAutoRetryFailures = retryLimitToMaxRetries(configuredRetryLimit); + + for (const packageId of this.session.packageOrder) { + const pkg = this.session.packages[packageId]; + if (!pkg || pkg.cancelled) { + continue; + } + + for (const itemId of pkg.itemIds) { + const item = this.session.items[itemId]; + if (!item || this.activeTasks.has(itemId)) { + continue; + } + const canFinalizeFromDisk = item.status === "failed" + || item.status === "completed" + || item.status === "queued" + || item.status === "reconnect_wait"; + if (canFinalizeFromDisk) { + const recoveryReason = `${item.fullStatus || ""} ${item.lastError || ""}`.trim(); + if (shouldPreflightFinalizeItemFromDisk(item) + && this.tryFinalizeItemFromDisk(pkg, item, `Recovery-${trigger}`, recoveryReason)) { + finalized += 1; + touchedPackages.add(pkg.id); + this.retryAfterByItem.delete(item.id); + this.retryStateByItem.delete(item.id); + continue; + } + } + if (item.status !== "failed" && item.status !== "completed") { + continue; + } + + const is416Failure = item.status === "failed" && this.isHttp416Failure(item); + const hasZeroByteArchive = await this.hasZeroByteArchiveArtifact(item); + + if (item.status === "failed") { + if (!is416Failure && !hasZeroByteArchive && item.retries >= maxAutoRetryFailures) { + continue; + } + this.queueItemForRetry(item, { + hardReset: is416Failure || hasZeroByteArchive, + reason: is416Failure + ? "Wartet (Auto-Retry: HTTP 416)" + : hasZeroByteArchive + ? "Wartet (Auto-Retry: 0B-Datei)" + : "Wartet (Auto-Retry)" + }); + recovered += 1; + touchedPackages.add(pkg.id); + continue; + } + + if (item.status === "completed" && hasZeroByteArchive) { + const maxCompletedZeroByteAutoRetries = retryLimitToMaxRetries(configuredRetryLimit); + if (item.retries >= maxCompletedZeroByteAutoRetries) { + continue; + } + item.retries += 1; + this.queueItemForRetry(item, { + hardReset: true, + reason: "Wartet (Auto-Retry: 0B-Datei)" + }); + recovered += 1; + touchedPackages.add(pkg.id); + } + } + } + + if (recovered > 0 || finalized > 0) { + for (const packageId of touchedPackages) { + const pkg = this.session.packages[packageId]; + if (!pkg) { + continue; + } + // The package gets a fresh chance — its next outcome must notify again + // (a recovery success after a notified failure is the message the user + // most wants). History dedup stays untouched (separate semantics). + this.notifiedPackages.delete(packageId); + this.refreshPackageStatus(pkg); + } + logger.warn( + `Auto-Retry-Recovery (${trigger}): ${recovered} Item(s) wieder in Queue gesetzt, ` + + `${finalized} Item(s) direkt von Disk vervollstaendigt` + ); + this.persistSoon(); + this.emitState(); + } + + return recovered + finalized; + } + + private queueItemForRetry(item: DownloadItem, options: { hardReset: boolean; reason: string }): void { + this.retryStateByItem.delete(item.id); + const targetPath = String(item.targetPath || "").trim(); + if (options.hardReset && targetPath) { + try { + fs.rmSync(targetPath, { force: true }); + } catch { + } + this.releaseTargetPath(item.id); + item.downloadedBytes = 0; + item.totalBytes = null; + item.progressPercent = 0; + this.dropItemContribution(item.id); + } + + item.status = "queued"; + item.speedBps = 0; + item.attempts = 0; + item.lastError = ""; + item.resumable = true; + item.fullStatus = options.reason; + item.updatedAt = nowMs(); + } + + private isHttp416Failure(item: DownloadItem): boolean { + const text = `${item.lastError} ${item.fullStatus}`; + return /(^|\D)416(\D|$)/.test(text); + } + + private async hasZeroByteArchiveArtifact(item: DownloadItem): Promise<boolean> { + const targetPath = String(item.targetPath || "").trim(); + const archiveCandidate = isArchiveLikePath(targetPath || item.fileName); + if (!archiveCandidate) { + return false; + } + + if (targetPath) { + try { + const stat = await fs.promises.stat(targetPath); + return stat.size <= 0; + } catch { + } + } + + if (item.downloadedBytes <= 0 && item.progressPercent >= 100) { + return true; + } + + return /\b0\s*B\b/i.test(item.fullStatus || ""); + } + + // Once per package and run; trailing post-processing after run-end still + // notifies (runPackageIds keeps the id), startup recovery does not (set empty). + private notifyPackageOutcome(pkg: PackageEntry, kind: "completed" | "failed", detail: string): void { + const url = String(this.settings.notifyUrl || "").trim(); + if (!url) { + return; + } + if (kind === "completed" && !this.settings.notifyOnPackageCompleted) { + return; + } + if (kind === "failed" && !this.settings.notifyOnPackageFailed) { + return; + } + if (!this.session.running && !this.runPackageIds.has(pkg.id)) { + return; + } + if (this.notifiedPackages.has(pkg.id)) { + return; + } + this.notifiedPackages.add(pkg.id); + // Release the dedup marker if the delivery ultimately failed (after the + // sender's own retries), so a later manual re-run can notify again instead + // of a transient outage permanently consuming the once-per-package slot. + void sendNotification(url, { + title: kind === "completed" ? "✅ Paket fertig" : "❌ Paket fehlgeschlagen", + message: `${pkg.name}\n${detail}`, + mention: this.settings.notifyMention + }).then((ok) => { + if (!ok) { + this.notifiedPackages.delete(pkg.id); + } + }); + } + + private refreshPackageStatus(pkg: PackageEntry): void { + let pending = 0; + let success = 0; + let failed = 0; + let cancelled = 0; + let total = 0; + for (const itemId of pkg.itemIds) { + const item = this.session.items[itemId]; + if (!item) { + continue; + } + total += 1; + const s = item.status; + if (s === "completed") { + success += 1; + } else if (s === "failed") { + failed += 1; + } else if (s === "cancelled") { + cancelled += 1; + } else { + pending += 1; + } + } + if (total === 0) { + return; + } + + if (pending > 0) { + pkg.status = pkg.enabled ? "queued" : "paused"; + pkg.updatedAt = nowMs(); + return; + } + + const prevStatus = pkg.status; + if (failed > 0) { + pkg.status = "failed"; + } else if (cancelled > 0) { + pkg.status = success > 0 ? "completed" : "cancelled"; + } else if (success > 0) { + pkg.status = "completed"; + } + pkg.updatedAt = nowMs(); + // A package whose LAST terminal event is a failure never (re-)enters + // post-processing (its trigger sits only on completion paths), so the + // post-process notify hook can't fire — cover every failed-transition here. + // That includes mixed packages (success > 0): the dedup set prevents a + // double-fire if post-processing does run later. + if (pkg.status === "failed" && prevStatus !== "failed") { + this.notifyPackageOutcome(pkg, "failed", `${failed} von ${total} Datei(en) fehlgeschlagen`); + if (success > 0) { + const items = pkg.itemIds.map((id) => this.session.items[id]).filter(Boolean) as DownloadItem[]; + this.recordPackageHistory(pkg.id, pkg, items); + } + } + } + + private cachedSpeedLimitKbps = 0; + + private cachedSpeedLimitAt = 0; + + private globalSpeedLimitQueue: Promise<void> = Promise.resolve(); + + private globalSpeedLimitNextAt = 0; + + private getEffectiveSpeedLimitKbps(): number { + const now = nowMs(); + if (now - this.cachedSpeedLimitAt < 2000) { + return this.cachedSpeedLimitKbps; + } + this.cachedSpeedLimitAt = now; + const schedules = this.settings.bandwidthSchedules; + if (schedules.length > 0) { + const hour = new Date().getHours(); + let allDayLimit: number | null = null; + for (const entry of schedules) { + if (!entry.enabled) { + continue; + } + if (entry.startHour === entry.endHour) { + if (allDayLimit === null) { + allDayLimit = entry.speedLimitKbps; + } + continue; + } + const wraps = entry.startHour > entry.endHour; + const inRange = wraps + ? hour >= entry.startHour || hour < entry.endHour + : hour >= entry.startHour && hour < entry.endHour; + if (inRange) { + this.cachedSpeedLimitKbps = entry.speedLimitKbps; + return this.cachedSpeedLimitKbps; + } + } + if (allDayLimit !== null) { + this.cachedSpeedLimitKbps = allDayLimit; + return this.cachedSpeedLimitKbps; + } + } + if (this.settings.speedLimitEnabled && this.settings.speedLimitKbps > 0) { + this.cachedSpeedLimitKbps = this.settings.speedLimitKbps; + return this.cachedSpeedLimitKbps; + } + this.cachedSpeedLimitKbps = 0; + return 0; + } + + private async applyGlobalSpeedLimit(chunkBytes: number, bytesPerSecond: number, signal?: AbortSignal): Promise<void> { + const task = this.globalSpeedLimitQueue + .catch(() => undefined) + .then(async () => { + if (signal?.aborted) { + throw new Error("aborted:speed_limit"); + } + const now = nowMs(); + const waitMs = Math.max(0, this.globalSpeedLimitNextAt - now); + if (waitMs > 0) { + await new Promise<void>((resolve, reject) => { + let timer: NodeJS.Timeout | null = setTimeout(() => { + timer = null; + if (signal) { + signal.removeEventListener("abort", onAbort); + } + resolve(); + }, waitMs); + + const onAbort = (): void => { + if (timer) { + clearTimeout(timer); + timer = null; + } + signal?.removeEventListener("abort", onAbort); + reject(new Error("aborted:speed_limit")); + }; + + if (signal) { + if (signal.aborted) { + onAbort(); + return; + } + signal.addEventListener("abort", onAbort, { once: true }); + } + }); + } + + if (signal?.aborted) { + throw new Error("aborted:speed_limit"); + } + + const startAt = Math.max(nowMs(), this.globalSpeedLimitNextAt); + const durationMs = Math.max(1, Math.ceil((chunkBytes / bytesPerSecond) * 1000)); + this.globalSpeedLimitNextAt = startAt + durationMs; + }); + + this.globalSpeedLimitQueue = task; + await task; + } + + private async applySpeedLimit(chunkBytes: number, localWindowBytes: number, localWindowStarted: number, signal?: AbortSignal): Promise<void> { + const limitKbps = this.getEffectiveSpeedLimitKbps(); + if (limitKbps <= 0) { + return; + } + const bytesPerSecond = limitKbps * 1024; + const now = nowMs(); + const elapsed = Math.max((now - localWindowStarted) / 1000, 0.1); + if (this.settings.speedLimitMode === "per_download") { + const projected = localWindowBytes + chunkBytes; + const allowed = bytesPerSecond * elapsed; + if (projected > allowed) { + const sleepMs = Math.ceil(((projected - allowed) / bytesPerSecond) * 1000); + if (sleepMs > 0) { + await new Promise<void>((resolve, reject) => { + let timer: NodeJS.Timeout | null = setTimeout(() => { + timer = null; + if (signal) { + signal.removeEventListener("abort", onAbort); + } + resolve(); + }, Math.min(300, sleepMs)); + + const onAbort = (): void => { + if (timer) { + clearTimeout(timer); + timer = null; + } + signal?.removeEventListener("abort", onAbort); + reject(new Error("aborted:speed_limit")); + }; + + if (signal) { + if (signal.aborted) { + onAbort(); + return; + } + signal.addEventListener("abort", onAbort, { once: true }); + } + }); + } + } + return; + } + + await this.applyGlobalSpeedLimit(chunkBytes, bytesPerSecond, signal); + } + + private async findReadyArchiveSets(pkg: PackageEntry): Promise<Set<string>> { + const ready = new Set<string>(); + if (!pkg.outputDir) { + return ready; + } + try { + await fs.promises.access(pkg.outputDir); + } catch { + return ready; + } + + const completedPaths = new Set<string>(); + const pendingPaths = new Set<string>(); + for (const itemId of pkg.itemIds) { + const item = this.session.items[itemId]; + if (!item) { + continue; + } + if (item.status === "completed" && item.targetPath) { + completedPaths.add(pathKey(item.targetPath)); + } else { + if (item.targetPath) { + pendingPaths.add(pathKey(item.targetPath)); + } + if (item.fileName && pkg.outputDir) { + pendingPaths.add(pathKey(path.join(pkg.outputDir, item.fileName))); + } + } + } + if (completedPaths.size === 0) { + return ready; + } + + const candidates = await findArchiveCandidates(pkg.outputDir); + if (candidates.length === 0) { + return ready; + } + + let dirFiles: string[] | undefined; + try { + dirFiles = (await fs.promises.readdir(pkg.outputDir, { withFileTypes: true })) + .filter((entry) => entry.isFile()) + .map((entry) => entry.name); + } catch { + return ready; + } + + const packageItems = pkg.itemIds + .map((itemId) => this.session.items[itemId]) + .filter(Boolean) as DownloadItem[]; + + for (const candidate of candidates) { + const partsOnDisk = collectArchiveCleanupTargets(candidate, dirFiles); + const allPartsCompleted = partsOnDisk.every((part) => completedPaths.has(pathKey(part))); + if (allPartsCompleted) { + const hasUnstartedParts = [...pendingPaths].some((pendingPath) => { + const pendingName = path.basename(pendingPath).toLowerCase(); + const candidateStem = path.basename(candidate).toLowerCase(); + return this.looksLikeArchivePart(pendingName, candidateStem); + }); + if (hasUnstartedParts) { + continue; + } + ready.add(pathKey(candidate)); + continue; + } + + const archiveItems = resolveArchiveItemsFromList(path.basename(candidate), packageItems); + if (archiveItems.length === 0) { + continue; + } + const hasActiveArchiveItem = archiveItems.some((item) => + item.status === "downloading" || item.status === "validating" || item.status === "integrity_check" + ); + if (hasActiveArchiveItem) { + continue; + } + const allArchiveItemsReadyOnDisk = archiveItems.every((item) => inspectPackageItemDiskState(pkg, item).reason === "ok"); + if (!allArchiveItemsReadyOnDisk) { + continue; + } + const nonCompletedCount = archiveItems.filter((item) => item.status !== "completed").length; + logger.info(`Hybrid-Extract Disk-Fallback: ${path.basename(candidate)} (${nonCompletedCount} Part(s) laut Session ohne completed-Status)`); + ready.add(pathKey(candidate)); + continue; + } + + return ready; + } + + private findItemByDiskPath(pkg: PackageEntry, diskPath: string): DownloadItem | undefined { + const key = pathKey(diskPath); + for (const itemId of pkg.itemIds) { + const item = this.session.items[itemId]; + if (!item) continue; + if (item.targetPath && pathKey(item.targetPath) === key) return item; + if (item.fileName && pkg.outputDir && pathKey(path.join(pkg.outputDir, item.fileName)) === key) return item; + } + return undefined; + } + + private looksLikeArchivePart(fileName: string, entryPointName: string): boolean { + const multipartMatch = entryPointName.match(/^(.*)\.part0*1\.rar$/i); + if (multipartMatch) { + const prefix = multipartMatch[1].toLowerCase(); + return new RegExp(`^${prefix.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\.part\\d+\\.rar$`, "i").test(fileName); + } + if (/\.rar$/i.test(entryPointName) && !/\.part\d+\.rar$/i.test(entryPointName)) { + const stem = entryPointName.replace(/\.rar$/i, "").toLowerCase(); + const escaped = stem.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + return new RegExp(`^${escaped}\\.r(ar|\\d{2,3})$`, "i").test(fileName); + } + if (/\.zip\.001$/i.test(entryPointName)) { + const stem = entryPointName.replace(/\.zip\.001$/i, "").toLowerCase(); + const escaped = stem.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + return new RegExp(`^${escaped}\\.zip(\\.\\d+)?$`, "i").test(fileName); + } + if (/\.7z\.001$/i.test(entryPointName)) { + const stem = entryPointName.replace(/\.7z\.001$/i, "").toLowerCase(); + const escaped = stem.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + return new RegExp(`^${escaped}\\.7z(\\.\\d+)?$`, "i").test(fileName); + } + if (/\.001$/i.test(entryPointName) && !/\.(zip|7z)\.001$/i.test(entryPointName)) { + const stem = entryPointName.replace(/\.001$/i, "").toLowerCase(); + const escaped = stem.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + return new RegExp(`^${escaped}\\.\\d{3}$`, "i").test(fileName); + } + return false; + } + + private async runHybridExtraction(packageId: string, pkg: PackageEntry, items: DownloadItem[], signal?: AbortSignal): Promise<number> { + const completedForDeobfuscation = items.filter((item) => item.status === "completed"); + await this.deobfuscateArchiveFiles(pkg, completedForDeobfuscation, signal); + if (signal?.aborted) return 0; + + const findReadyStart = nowMs(); + const readyArchives = await this.findReadyArchiveSets(pkg); + const findReadyMs = nowMs() - findReadyStart; + if (findReadyMs > 200) { + logger.info(`findReadyArchiveSets dauerte ${(findReadyMs / 1000).toFixed(1)}s: pkg=${pkg.name}, found=${readyArchives.size}`); + } + + const completedItems = items.filter((item) => item.status === "completed"); + + const alreadyTried = this.hybridExtractedPaths.get(packageId); + if (alreadyTried) { + for (const key of [...readyArchives]) { + if (alreadyTried.has(key)) { + readyArchives.delete(key); + } + } + } + + const failedArchiveStates = this.hybridFailedArchives.get(packageId); + if (failedArchiveStates) { + for (const archiveKey of [...readyArchives]) { + const previousFailure = failedArchiveStates.get(archiveKey); + if (!previousFailure) { + continue; + } + + const archiveItems = resolveArchiveItemsFromList(path.basename(archiveKey), completedItems); + const allItemsStillInError = archiveItems.length > 0 && archiveItems.every((item) => isExtractErrorLabel(item.fullStatus)); + const retryMarker = this.buildHybridArchiveRetryMarker(pkg, items, archiveKey); + if (!allItemsStillInError || previousFailure.marker !== retryMarker) { + continue; + } + + logger.info( + `Hybrid-Extract Skip: ${path.basename(archiveKey)} unveraendert seit letztem Fehler ` + + `(${compactErrorText(previousFailure.lastError)})` + ); + readyArchives.delete(archiveKey); + } + } + + if (readyArchives.size === 0) { + logger.info(`Hybrid-Extract: pkg=${pkg.name}, keine fertigen Archive-Sets`); + return 0; + } + + logger.info(`Hybrid-Extract Start: pkg=${pkg.name}, readyArchives=${readyArchives.size}`); + pkg.status = "extracting"; + this.emitState(); + const hybridExtractStartMs = nowMs(); + + const hybridFileNames = new Set<string>(); + let dirFiles: string[] | undefined; + try { + dirFiles = (await fs.promises.readdir(pkg.outputDir, { withFileTypes: true })) + .filter((entry) => entry.isFile()) + .map((entry) => entry.name); + } catch { } + const archiveStems = new Set<string>(); + for (const archiveKey of readyArchives) { + const parts = collectArchiveCleanupTargets(archiveKey, dirFiles); + for (const part of parts) { + const partName = path.basename(part).toLowerCase(); + hybridFileNames.add(partName); + const stem = partName + .replace(/\.part\d+\.rar$/i, "") + .replace(/\.(rar|r\d{2,3}|zip|z\d{2,3}|7z|tar|gz|bz2|xz|tgz|tbz2|txz|rev)$/i, "") + .replace(/\.(zip|7z)\.\d{3}$/i, "") + .replace(/\.\d{3}$/i, ""); + if (stem && stem !== partName) archiveStems.add(stem); + } + hybridFileNames.add(path.basename(archiveKey).toLowerCase()); + } + if (dirFiles && archiveStems.size > 0) { + for (const fileName of dirFiles) { + const lower = fileName.toLowerCase(); + if (!KNOWN_SMALL_FILE_RE.test(lower)) continue; + const companionStem = lower.replace(/\.[^.]+$/, ""); + if (archiveStems.has(companionStem)) { + hybridFileNames.add(lower); + } + } + } + const isHybridItem = (item: DownloadItem): boolean => { + if (item.targetPath && hybridFileNames.has(path.basename(item.targetPath).toLowerCase())) { + return true; + } + if (item.fileName && hybridFileNames.has(item.fileName.toLowerCase())) { + return true; + } + return false; + }; + const hybridItems = completedItems.filter(isHybridItem); + + if (hybridItems.length > 0 && hybridItems.every((item) => isExtractedLabel(item.fullStatus))) { + logger.info(`Hybrid-Extract: pkg=${pkg.name}, alle ${hybridItems.length} Items bereits entpackt, überspringe`); + return 0; + } + + for (const archiveKey of [...readyArchives]) { + const archiveParts = collectArchiveCleanupTargets(archiveKey, dirFiles); + const archivePartNames = new Set<string>(); + archivePartNames.add(path.basename(archiveKey).toLowerCase()); + for (const part of archiveParts) { + archivePartNames.add(path.basename(part).toLowerCase()); + } + const archiveItems = completedItems.filter((item) => { + const targetName = item.targetPath ? path.basename(item.targetPath).toLowerCase() : ""; + const fileName = (item.fileName || "").toLowerCase(); + return archivePartNames.has(targetName) || archivePartNames.has(fileName); + }); + if (archiveItems.length > 0 && archiveItems.every((item) => isExtractedLabel(item.fullStatus))) { + readyArchives.delete(archiveKey); + } + } + if (readyArchives.size === 0) { + logger.info(`Hybrid-Extract: pkg=${pkg.name}, alle fertigen Archive bereits entpackt`); + return 0; + } + + const resolveArchiveItems = (archiveName: string): DownloadItem[] => + resolveArchiveItemsFromList(archiveName, items); + + const readyArchiveKeyByName = new Map<string, string>(); + const readyArchiveMarkers = new Map<string, string>(); + for (const archiveKey of readyArchives) { + readyArchiveKeyByName.set(path.basename(archiveKey).toLowerCase(), archiveKey); + readyArchiveMarkers.set(archiveKey, this.buildHybridArchiveRetryMarker(pkg, items, archiveKey)); + } + + const autoRecoveredArchives = new Set<string>(); + const failedArchiveErrors = new Map<string, string>(); + const hybridResolvedItems = new Map<string, DownloadItem[]>(); + const hybridStartTimes = new Map<string, number>(); + let hybridLastEmitAt = 0; + let hybridLastProgressCurrent: number | null = null; + + const allDownloaded = completedItems.length >= items.length; + let labelsChanged = false; + for (const entry of completedItems) { + if (isExtractedLabel(entry.fullStatus)) { + continue; + } + if (isExtractErrorLabel(entry.fullStatus)) { + continue; + } + const belongsToReady = allDownloaded + || hybridFileNames.has((entry.fileName || "").toLowerCase()) + || (entry.targetPath && hybridFileNames.has(path.basename(entry.targetPath).toLowerCase())); + const targetLabel = belongsToReady ? "Entpacken - Ausstehend" : "Entpacken - Warten auf Parts"; + if (entry.fullStatus !== targetLabel) { + entry.fullStatus = targetLabel; + entry.updatedAt = nowMs(); + labelsChanged = true; + } + } + if (labelsChanged) { + this.emitState(); + } + + try { + await this.waitForCompletedArchiveFilesToSettle(pkg, hybridItems, signal, "hybrid"); + if (signal?.aborted) { + return 0; + } + + const result = await extractPackageArchives({ + packageDir: pkg.outputDir, + targetDir: pkg.extractDir, + cleanupMode: this.settings.cleanupMode, + conflictMode: this.settings.extractConflictMode, + removeLinks: false, + removeSamples: false, + passwordList: this.settings.archivePasswordList, + signal, + onlyArchives: readyArchives, + skipPostCleanup: true, + packageId, + hybridMode: true, + maxParallel: this.settings.maxParallelExtract || 2, + extractCpuPriority: "high", + onLog: (level, message) => this.logExtractionForItems(pkg, items, "Hybrid-Extractor", level, message), + onArchiveFailure: (failure) => { + const failedArchiveKey = readyArchiveKeyByName.get(String(failure.archiveName || "").toLowerCase()); + if (failedArchiveKey) { + failedArchiveErrors.set(failedArchiveKey, failure.errorText || failure.jvmFailureReason || "Entpacken fehlgeschlagen"); + } + if (autoRecoveredArchives.has(failure.archiveName)) { + return; + } + const changed = this.autoRecoverArchiveCrcFailure(pkg, items, failure, "hybrid"); + if (changed > 0) { + autoRecoveredArchives.add(failure.archiveName); + } + }, + onProgress: (progress) => { + if (progress.phase === "preparing") { + pkg.postProcessLabel = progress.archiveName || "Vorbereiten..."; + this.emitState(); + return; + } + if (progress.phase === "done") { + hybridResolvedItems.clear(); + hybridStartTimes.clear(); + hybridLastProgressCurrent = null; + return; + } + + const currentCount = Math.max(0, Number(progress.current ?? 0)); + const archiveFinished = progress.archiveDone === true + || (hybridLastProgressCurrent !== null && currentCount > hybridLastProgressCurrent); + hybridLastProgressCurrent = currentCount; + + if (progress.archiveName) { + if (!hybridResolvedItems.has(progress.archiveName)) { + const resolved = resolveArchiveItems(progress.archiveName); + hybridResolvedItems.set(progress.archiveName, resolved); + hybridStartTimes.set(progress.archiveName, nowMs()); + if (resolved.length === 0) { + logger.warn(`resolveArchiveItems (hybrid): KEINE Items gefunden für archiveName="${progress.archiveName}", items.length=${items.length}, itemNames=[${items.map((i) => path.basename(i.targetPath || i.fileName || "?")).join(", ")}]`); + } else { + logger.info(`resolveArchiveItems (hybrid): ${resolved.length} Items für archiveName="${progress.archiveName}"`); + const initLabel = `Entpacken 0% · ${progress.archiveName}`; + const initAt = nowMs(); + for (const entry of resolved) { + if (entry.status !== "completed" || isExtractedLabel(entry.fullStatus)) { + continue; + } + if (!isExtractedLabel(entry.fullStatus)) { + entry.fullStatus = initLabel; + entry.updatedAt = initAt; + } + } + hybridLastEmitAt = initAt; + this.emitState(true); + } + } + const archItems = hybridResolvedItems.get(progress.archiveName) || []; + + if (archiveFinished) { + const doneAt = nowMs(); + const startedAt = hybridStartTimes.get(progress.archiveName) || doneAt; + const doneLabel = progress.archiveSuccess === false + ? "Entpacken - Error" + : formatExtractDone(doneAt - startedAt); + const archiveKey = readyArchiveKeyByName.get(progress.archiveName.toLowerCase()); + if (archiveKey && progress.archiveSuccess !== false) { + this.clearHybridArchiveState(packageId, archiveKey); + } + for (const entry of archItems) { + if (entry.status !== "completed" || isExtractedLabel(entry.fullStatus)) continue; + entry.fullStatus = doneLabel; + entry.updatedAt = doneAt; + } + hybridResolvedItems.delete(progress.archiveName); + hybridStartTimes.delete(progress.archiveName); + const done = currentCount; + if (done < progress.total) { + pkg.postProcessLabel = `Entpacken (${done}/${progress.total}) - Nächstes Archiv...`; + this.emitState(); + } + } else { + const archiveLabel = ` · ${progress.archiveName}`; + const elapsed = progress.elapsedMs && progress.elapsedMs >= 1000 + ? ` · ${Math.floor(progress.elapsedMs / 1000)}s` + : ""; + const archivePct = Math.max(0, Math.min(100, Math.floor(Number(progress.archivePercent ?? 0)))); + const isFinalizing = archivePct >= 99; + let label: string; + if (progress.passwordFound) { + label = `Passwort gefunden · ${progress.archiveName}`; + } else if (progress.passwordAttempt && progress.passwordTotal && progress.passwordTotal > 1) { + const pwPct = Math.round((progress.passwordAttempt / progress.passwordTotal) * 100); + label = `Passwort knacken: ${pwPct}% (${progress.passwordAttempt}/${progress.passwordTotal}) · ${progress.archiveName}`; + } else if (isFinalizing) { + label = `Finalisieren${archiveLabel}${elapsed}`; + } else { + label = `Entpacken ${archivePct}%${archiveLabel}${elapsed}`; + } + const updatedAt = nowMs(); + for (const entry of archItems) { + if (entry.status !== "completed" || isExtractedLabel(entry.fullStatus) || entry.fullStatus === label) continue; + entry.fullStatus = label; + entry.updatedAt = updatedAt; + } + } + } + + const activeArchive = !archiveFinished && Number(progress.archivePercent ?? 0) > 0 ? 1 : 0; + const currentDisplay = Math.max(0, Math.min(progress.total, progress.current + activeArchive)); + if (progress.passwordFound) { + pkg.postProcessLabel = `Passwort gefunden · ${progress.archiveName || ""}`; + } else if (progress.passwordAttempt && progress.passwordTotal && progress.passwordTotal > 1) { + const pwPct = Math.round((progress.passwordAttempt / progress.passwordTotal) * 100); + pkg.postProcessLabel = `Passwort knacken: ${pwPct}%`; + } else if (Number(progress.archivePercent ?? 0) >= 99) { + const archive = progress.archiveName ? ` · ${progress.archiveName}` : ""; + const elapsed = progress.elapsedMs && progress.elapsedMs >= 1000 + ? ` · ${Math.floor(progress.elapsedMs / 1000)}s` + : ""; + pkg.postProcessLabel = `Finalisieren (${currentDisplay}/${progress.total})${archive}${elapsed}`; + } else { + pkg.postProcessLabel = `Entpacken ${progress.percent}% (${currentDisplay}/${progress.total})`; + } + + const now = nowMs(); + if (now - hybridLastEmitAt >= EXTRACT_PROGRESS_EMIT_INTERVAL_MS) { + hybridLastEmitAt = now; + for (const entry of items) { + if (entry.status === "completed" && entry.fullStatus === "Entpacken - Warten auf Parts") { + entry.fullStatus = "Entpacken - Ausstehend"; + entry.updatedAt = now; + } + } + this.emitState(); + } + } + }); + + logger.info(`Hybrid-Extract Ende: pkg=${pkg.name}, extracted=${result.extracted}, failed=${result.failed}`); + this.logPackageForPackage(pkg, "INFO", "Hybrid-Extract abgeschlossen", { + extracted: result.extracted, + failed: result.failed + }); + { + let tried = this.hybridExtractedPaths.get(packageId); + if (!tried) { tried = new Set(); this.hybridExtractedPaths.set(packageId, tried); } + for (const key of readyArchives) { tried.add(key); } + } + if (failedArchiveErrors.size > 0) { + let failed = this.hybridFailedArchives.get(packageId); + if (!failed) { + failed = new Map(); + this.hybridFailedArchives.set(packageId, failed); + } + const failedAt = nowMs(); + for (const [archiveKey, errorText] of failedArchiveErrors.entries()) { + const marker = readyArchiveMarkers.get(archiveKey); + if (!marker) { + continue; + } + failed.set(archiveKey, { + marker, + lastError: errorText, + updatedAt: failedAt + }); + } + } + if (result.extracted > 0) { + const hybridController = new AbortController(); + let hybridSet = this.packageHybridPostProcessControllers.get(packageId); + if (!hybridSet) { + hybridSet = new Set<AbortController>(); + this.packageHybridPostProcessControllers.set(packageId, hybridSet); + } + hybridSet.add(hybridController); + const hybridShouldAbort = (): boolean => hybridController.signal.aborted || this.session.packages[packageId] !== pkg; + void (async () => { + try { + await this.chainPackageFileOp(pkg.id, async () => { + await this.autoRenameExtractedVideoFilesImpl(pkg.extractDir, pkg, hybridShouldAbort); + await this.keepGermanAudioOnlyImpl(pkg.extractDir, pkg, hybridShouldAbort, hybridController.signal); + await this.collectMkvFilesToLibrary(packageId, pkg, hybridShouldAbort, true); + }); + } catch (err) { + logger.warn(`Hybrid Post-Extract (Rename+Collect) Fehler: pkg=${pkg.name}, reason=${compactErrorText(err)}`); + } finally { + const set = this.packageHybridPostProcessControllers.get(packageId); + if (set) { + set.delete(hybridController); + if (set.size === 0) { + this.packageHybridPostProcessControllers.delete(packageId); + } + } + } + })(); + } + if (result.failed > 0) { + logger.warn(`Hybrid-Extract: ${result.failed} Archive fehlgeschlagen, werden erst nach echter Aenderung oder manuellem Retry erneut versucht`); + } + + const updatedAt = nowMs(); + for (const entry of hybridItems) { + if (entry.status !== "completed" || isExtractedLabel(entry.fullStatus)) { + continue; + } + const status = entry.fullStatus || ""; + if (/^Entpacken\b/i.test(status) || /^Passwort\b/i.test(status)) { + if (result.failed > 0) { + entry.fullStatus = "Entpacken - Error"; + } else if (result.extracted > 0) { + entry.fullStatus = formatExtractDone(nowMs() - hybridExtractStartMs); + } else if (KNOWN_SMALL_FILE_RE.test(entry.fileName || "")) { + entry.fullStatus = "Entpackt (Metadaten)"; + } + entry.updatedAt = updatedAt; + } + } + return result.extracted; + } catch (error) { + const errorText = String(error || ""); + if (errorText.includes("aborted:extract")) { + logger.info(`Hybrid-Extract abgebrochen: pkg=${pkg.name}`); + const abortAt = nowMs(); + for (const entry of hybridItems) { + if (entry.status !== "completed" || isExtractedLabel(entry.fullStatus || "")) continue; + if (/^Entpacken\b/i.test(entry.fullStatus || "") || /^Passwort\b/i.test(entry.fullStatus || "")) { + entry.fullStatus = "Entpacken abgebrochen (wird fortgesetzt)"; + entry.updatedAt = abortAt; + } + } + return 0; + } + logger.warn(`Hybrid-Extract Fehler: pkg=${pkg.name}, reason=${compactErrorText(error)}`); + const errorAt = nowMs(); + for (const entry of hybridItems) { + if (entry.status !== "completed" || isExtractedLabel(entry.fullStatus || "")) continue; + if (/^Entpacken\b/i.test(entry.fullStatus || "") || /^Passwort\b/i.test(entry.fullStatus || "")) { + entry.fullStatus = `Entpacken - Error`; + entry.updatedAt = errorAt; + } + } + } + return 0; + } + + private async handlePackagePostProcessing(packageId: string, signal?: AbortSignal): Promise<void> { + const handleStart = nowMs(); + const pkg = this.session.packages[packageId]; + if (!pkg || pkg.cancelled) { + return; + } + if (signal?.aborted) { + return; + } + const items = pkg.itemIds.map((id) => this.session.items[id]).filter(Boolean) as DownloadItem[]; + + const recoveryStart = nowMs(); + for (const item of items) { + if (isFinishedStatus(item.status)) { + continue; + } + if (item.status === "downloading" || item.status === "validating" || item.status === "integrity_check") { + continue; + } + if (!item.targetPath) { + continue; + } + if (!isPathInsideDir(item.targetPath, pkg.outputDir)) { + logger.warn(`Item-Recovery: Unsicherer targetPath verworfen (${item.fileName} -> ${item.targetPath})`); + this.releaseTargetPath(item.id); + this.dropItemContribution(item.id); + item.targetPath = ""; + item.status = "queued"; + item.attempts = 0; + item.downloadedBytes = 0; + item.progressPercent = 0; + item.speedBps = 0; + item.fullStatus = "Wartet (ungueltiger Zielpfad)"; + item.updatedAt = nowMs(); + continue; + } + try { + const stat = await fs.promises.stat(item.targetPath); + const totalBytes = item.totalBytes; + const minSize = expectedMinBytes(totalBytes, isLargeBinaryLikePath(item.fileName || item.targetPath)); + const persistedBytes = Math.max(0, Math.floor(Number(item.downloadedBytes) || 0)); + const preallocMismatchThreshold = resolvePreallocResumeMismatchThreshold(item.fileName || item.targetPath || ""); + const suspiciousPreallocFootprint = totalBytes != null + && totalBytes > 0 + && stat.size >= minSize + && stat.size > persistedBytes + preallocMismatchThreshold; + if (stat.size >= minSize) { + const latestItem = this.session.items[item.id]; + if (!latestItem || this.activeTasks.has(item.id) || latestItem.status === "downloading" + || latestItem.status === "validating" || latestItem.status === "integrity_check") { + continue; + } + if (suspiciousPreallocFootprint) { + logger.warn( + `Item-Recovery: ${item.fileName} uebersprungen – pre-alloc-Verdacht ` + + `(stat=${humanSize(stat.size)}, bytes=${humanSize(persistedBytes)}, total=${humanSize(totalBytes)})` + ); + try { + if (persistedBytes > 0) { + fs.truncateSync(item.targetPath, persistedBytes); + } else { + fs.rmSync(item.targetPath, { force: true }); + } + } catch { + } + item.status = "queued"; + item.attempts = 0; + item.downloadedBytes = persistedBytes; + item.progressPercent = totalBytes > 0 + ? Math.max(0, Math.min(99, Math.floor((persistedBytes / totalBytes) * 100))) + : 0; + item.speedBps = 0; + item.fullStatus = "Wartet (Auto-Recovery: pre-alloc)"; + item.updatedAt = nowMs(); + continue; + } + if (item.downloadedBytes > 0 && item.totalBytes && item.totalBytes > 0 + && stat.size >= minSize + && item.downloadedBytes < item.totalBytes * 0.95) { + logger.warn(`Item-Recovery: ${item.fileName} uebersprungen – vermutlich pre-alloc (stat=${humanSize(stat.size)}, bytes=${humanSize(item.downloadedBytes)}, total=${humanSize(item.totalBytes)})`); + continue; + } + logger.info(`Item-Recovery: ${item.fileName} war "${item.status}" aber Datei existiert (${humanSize(stat.size)}), setze auf completed`); + item.status = "completed"; + item.fullStatus = this.settings.autoExtract ? "Entpacken - Ausstehend" : `Fertig (${humanSize(stat.size)})`; + item.downloadedBytes = stat.size; + item.progressPercent = 100; + item.speedBps = 0; + item.updatedAt = nowMs(); + this.recordRunOutcome(item.id, "completed"); + } else if (stat.size > 0) { + logger.warn(`Item-Recovery: ${item.fileName} unvollstaendig (${humanSize(stat.size)}, erwartet mind. ${humanSize(minSize)}), loesche und re-queue`); + try { + fs.rmSync(item.targetPath, { force: true }); + } catch { } + this.releaseTargetPath(item.id); + this.dropItemContribution(item.id); + item.targetPath = ""; + item.status = "queued"; + item.attempts = 0; + item.downloadedBytes = 0; + item.progressPercent = 0; + item.speedBps = 0; + item.fullStatus = "Wartet (unvollständiger Download)"; + item.updatedAt = nowMs(); + } + } catch { + } + } + + const recoveryMs = nowMs() - recoveryStart; + const success = items.filter((item) => item.status === "completed").length; + const failed = items.filter((item) => item.status === "failed").length; + const cancelled = items.filter((item) => item.status === "cancelled").length; + const setupMs = nowMs() - handleStart; + logger.info(`Post-Processing Start: pkg=${pkg.name}, success=${success}, failed=${failed}, cancelled=${cancelled}, autoExtract=${this.settings.autoExtract}, setupMs=${setupMs}, recoveryMs=${recoveryMs}`); + this.logPackageForPackage(pkg, "INFO", "Post-Processing gestartet", { + success, + failed, + cancelled, + autoExtract: this.settings.autoExtract, + setupMs, + recoveryMs + }); + + const allDone = this.areAllPackageItemRefsFinished(pkg); + if (!allDone && success + failed + cancelled >= items.length) { + logger.warn( + `Post-Processing wartet trotz gefiltert fertiger Items: ` + + `pkg=${pkg.name}, tracked=${pkg.itemIds.length}, resolved=${items.length}, ` + + `success=${success}, failed=${failed}, cancelled=${cancelled}` + ); + } + + if (!allDone && this.settings.hybridExtract && this.settings.autoExtract && failed === 0 && success > 0) { + pkg.postProcessLabel = "Entpacken vorbereiten..."; + this.emitState(); + const hybridExtracted = await this.runHybridExtraction(packageId, pkg, items, signal); + if (signal?.aborted) { + pkg.postProcessLabel = undefined; + pkg.status = (pkg.enabled && this.session.running && !this.session.paused) ? "queued" : "paused"; + pkg.updatedAt = nowMs(); + return; + } + if (this.settings.completedCleanupPolicy === "immediate") { + for (const itemId of [...pkg.itemIds]) { + this.applyCompletedCleanupPolicy(packageId, itemId); + } + } + if (!this.session.packages[packageId]) { + return; + } + if (hybridExtracted > 0) { + this.hybridExtractRequeue.add(packageId); + } + pkg.postProcessLabel = undefined; + pkg.status = (pkg.enabled && this.session.running && !this.session.paused) ? "downloading" : "queued"; + pkg.updatedAt = nowMs(); + this.emitState(); + return; + } + + if (!allDone) { + pkg.postProcessLabel = undefined; + pkg.status = (pkg.enabled && this.session.running && !this.session.paused) ? "downloading" : "queued"; + logger.info(`Post-Processing verschoben: pkg=${pkg.name}, noch offene items`); + return; + } + + const completedItems = items.filter((item) => item.status === "completed"); + const alreadyMarkedExtracted = completedItems.length > 0 && completedItems.every((item) => isExtractedLabel(item.fullStatus)); + let extractedCount = 0; + + if (this.settings.autoExtract && failed === 0 && success > 0 && !alreadyMarkedExtracted) { + pkg.postProcessLabel = "Entpacken vorbereiten..."; + pkg.status = "extracting"; + this.emitState(); + + await this.deobfuscateArchiveFiles(pkg, completedItems, signal); + if (signal?.aborted) return; + + const extractionStartMs = nowMs(); + const preExtractStatuses = new Map<string, string>(); + + const resolveArchiveItems = (archiveName: string): DownloadItem[] => + resolveArchiveItemsFromList(archiveName, completedItems); + + let lastExtractEmitAt = 0; + const emitExtractStatus = (text: string, force = false): void => { + const now = nowMs(); + if (!force && now - lastExtractEmitAt < EXTRACT_PROGRESS_EMIT_INTERVAL_MS) { + return; + } + lastExtractEmitAt = now; + pkg.postProcessLabel = text || "Entpacken..."; + this.emitState(); + }; + + const extractTimeoutMs = getPostExtractTimeoutMs(); + const extractAbortController = new AbortController(); + let timedOut = false; + const onParentAbort = (): void => { + if (extractAbortController.signal.aborted) { + return; + } + extractAbortController.abort("aborted:extract"); + }; + if (signal) { + if (signal.aborted) { + onParentAbort(); + } else { + signal.addEventListener("abort", onParentAbort, { once: true }); + } + } + const extractDeadline = setTimeout(() => { + if (signal?.aborted || extractAbortController.signal.aborted) { + return; + } + timedOut = true; + logger.error(`Post-Processing Extraction Timeout nach ${Math.ceil(extractTimeoutMs / 1000)}s: pkg=${pkg.name}`); + if (!extractAbortController.signal.aborted) { + extractAbortController.abort("extract_timeout"); + } + }, extractTimeoutMs); + try { + const autoRecoveredArchives = new Set<string>(); + const fullFailedArchiveErrors = new Map<string, string>(); + const fullResolvedItems = new Map<string, DownloadItem[]>(); + const fullStartTimes = new Map<string, number>(); + let fullLastProgressCurrent: number | null = null; + + await this.waitForCompletedArchiveFilesToSettle( + pkg, + completedItems, + extractAbortController.signal, + "full" + ); + if (extractAbortController.signal.aborted) { + throw new Error(String(extractAbortController.signal.reason || "aborted:extract")); + } + + const fullArchiveSet = await this.findFullExtractArchiveSet(pkg, completedItems); + const fullExtractItemIds = new Set<string>(); + for (const archivePath of fullArchiveSet) { + const archiveItems = resolveArchiveItems(path.basename(archivePath)); + for (const entry of archiveItems) { + fullExtractItemIds.add(entry.id); + } + } + const pendingAt = nowMs(); + for (const entry of completedItems) { + if (!fullExtractItemIds.has(entry.id) || isExtractedLabel(entry.fullStatus)) { + continue; + } + preExtractStatuses.set(entry.id, String(entry.fullStatus || "").trim()); + entry.fullStatus = "Entpacken - Ausstehend"; + entry.updatedAt = pendingAt; + } + this.emitState(); + const result = await extractPackageArchives({ + packageDir: pkg.outputDir, + targetDir: pkg.extractDir, + cleanupMode: this.settings.cleanupMode, + conflictMode: this.settings.extractConflictMode, + removeLinks: this.settings.removeLinkFilesAfterExtract, + removeSamples: this.settings.removeSamplesAfterExtract, + passwordList: this.settings.archivePasswordList, + signal: extractAbortController.signal, + packageId, + onlyArchives: fullArchiveSet, + skipPostCleanup: true, + maxParallel: this.settings.maxParallelExtract || 2, + extractCpuPriority: "high", + onLog: (level, message) => this.logExtractionForItems(pkg, completedItems, "Extractor", level, message), + onArchiveFailure: (failure) => { + if (autoRecoveredArchives.has(failure.archiveName)) { + return; + } + const changed = this.autoRecoverArchiveCrcFailure(pkg, completedItems, failure, "full"); + if (changed > 0) { + autoRecoveredArchives.add(failure.archiveName); + fullFailedArchiveErrors.delete(failure.archiveName); + return; + } + fullFailedArchiveErrors.set( + failure.archiveName, + failure.errorText || failure.jvmFailureReason || "Entpacken fehlgeschlagen" + ); + }, + onProgress: (progress) => { + if (progress.phase === "preparing") { + pkg.postProcessLabel = progress.archiveName || "Vorbereiten..."; + this.emitState(); + return; + } + if (progress.phase === "done") { + fullResolvedItems.clear(); + fullStartTimes.clear(); + fullLastProgressCurrent = null; + emitExtractStatus("Entpacken 100%", true); + return; + } + + const currentCount = Math.max(0, Number(progress.current ?? 0)); + const archiveFinished = progress.archiveDone === true + || (fullLastProgressCurrent !== null && currentCount > fullLastProgressCurrent); + fullLastProgressCurrent = currentCount; + + if (progress.archiveName) { + if (!fullResolvedItems.has(progress.archiveName)) { + const resolved = resolveArchiveItems(progress.archiveName); + fullResolvedItems.set(progress.archiveName, resolved); + fullStartTimes.set(progress.archiveName, nowMs()); + if (resolved.length === 0) { + logger.warn(`resolveArchiveItems (full): KEINE Items für archiveName="${progress.archiveName}", completedItems=${completedItems.length}, names=[${completedItems.map((i) => path.basename(i.targetPath || i.fileName || "?")).join(", ")}]`); + } else { + logger.info(`resolveArchiveItems (full): ${resolved.length} Items für archiveName="${progress.archiveName}"`); + const initLabel = `Entpacken 0% · ${progress.archiveName}`; + const initAt = nowMs(); + for (const entry of resolved) { + if (entry.status !== "completed" || isExtractedLabel(entry.fullStatus)) continue; + entry.fullStatus = initLabel; + entry.updatedAt = initAt; + } + emitExtractStatus(`Entpacken ${progress.percent}% · ${progress.archiveName}`, true); + } + } + const archiveItems = fullResolvedItems.get(progress.archiveName) || []; + + if (archiveFinished) { + const doneAt = nowMs(); + const startedAt = fullStartTimes.get(progress.archiveName) || doneAt; + const doneLabel = progress.archiveSuccess === false + ? "Entpacken - Error" + : formatExtractDone(doneAt - startedAt); + for (const entry of archiveItems) { + if (entry.status !== "completed" || isExtractedLabel(entry.fullStatus)) continue; + entry.fullStatus = doneLabel; + entry.updatedAt = doneAt; + } + fullResolvedItems.delete(progress.archiveName); + fullStartTimes.delete(progress.archiveName); + const done = currentCount; + if (done < progress.total) { + emitExtractStatus(`Entpacken (${done}/${progress.total}) - Nächstes Archiv...`, true); + } + } else { + const archiveTag = progress.archiveName ? ` · ${progress.archiveName}` : ""; + const elapsed = progress.elapsedMs && progress.elapsedMs >= 1000 + ? ` · ${Math.floor(progress.elapsedMs / 1000)}s` + : ""; + const archivePct = Math.max(0, Math.min(100, Math.floor(Number(progress.archivePercent ?? 0)))); + const isFinalizing = archivePct >= 99; + let label: string; + if (progress.passwordFound) { + label = `Passwort gefunden · ${progress.archiveName}`; + } else if (progress.passwordAttempt && progress.passwordTotal && progress.passwordTotal > 1) { + const pwPct = Math.round((progress.passwordAttempt / progress.passwordTotal) * 100); + label = `Passwort knacken: ${pwPct}% (${progress.passwordAttempt}/${progress.passwordTotal}) · ${progress.archiveName}`; + } else if (isFinalizing) { + label = `Finalisieren${archiveTag}${elapsed}`; + } else { + label = `Entpacken ${archivePct}%${archiveTag}${elapsed}`; + } + const updatedAt = nowMs(); + for (const entry of archiveItems) { + if (entry.status !== "completed" || isExtractedLabel(entry.fullStatus) || entry.fullStatus === label) continue; + entry.fullStatus = label; + entry.updatedAt = updatedAt; + } + } + } + + const archive = progress.archiveName ? ` · ${progress.archiveName}` : ""; + const elapsed = progress.elapsedMs && progress.elapsedMs >= 1000 + ? ` · ${Math.floor(progress.elapsedMs / 1000)}s` + : ""; + const activeArchive = !archiveFinished && Number(progress.archivePercent ?? 0) > 0 ? 1 : 0; + const currentDisplay = Math.max(0, Math.min(progress.total, progress.current + activeArchive)); + let overallLabel: string; + if (progress.passwordFound) { + overallLabel = `Passwort gefunden · ${progress.archiveName || ""}`; + } else if (progress.passwordAttempt && progress.passwordTotal && progress.passwordTotal > 1) { + const pwPct = Math.round((progress.passwordAttempt / progress.passwordTotal) * 100); + overallLabel = `Passwort knacken: ${pwPct}% (${progress.passwordAttempt}/${progress.passwordTotal}) · ${progress.archiveName || ""}`; + } else if (Number(progress.archivePercent ?? 0) >= 99) { + overallLabel = `Finalisieren (${currentDisplay}/${progress.total})${archive}${elapsed}`; + } else { + overallLabel = `Entpacken ${progress.percent}% (${currentDisplay}/${progress.total})${archive}${elapsed}`; + } + emitExtractStatus(overallLabel); + } + }); + logger.info(`Post-Processing Entpacken Ende: pkg=${pkg.name}, extracted=${result.extracted}, failed=${result.failed}, lastError=${result.lastError || ""}`); + this.logPackageForPackage(pkg, "INFO", "Post-Processing Entpacken Ende", { + extracted: result.extracted, + failed: result.failed, + lastError: result.lastError || "" + }); + extractedCount = result.extracted; + const autoRecoveredPending = completedItems.some((item) => item.status === "queued"); + + if (autoRecoveredPending) { + pkg.postProcessLabel = undefined; + pkg.status = (pkg.enabled && this.session.running && !this.session.paused) ? "downloading" : "queued"; + pkg.updatedAt = nowMs(); + logger.warn(`Post-Processing: pkg=${pkg.name}, Archivfehler automatisch auf Re-Download umgestellt`); + return; + } + + if (result.failed > 0) { + const reason = compactErrorText(result.lastError || "Entpacken fehlgeschlagen"); + const failAt = nowMs(); + if (fullFailedArchiveErrors.size > 0) { + const archiveSummaries = [...fullFailedArchiveErrors.entries()] + .slice(0, 3) + .map(([archiveName, errorText]) => `${archiveName}: ${summarizeExtractFailureReason(errorText)}`) + .join(" | "); + logger.warn(`Post-Processing Entpacken Fehlerdetails: pkg=${pkg.name}, archives=${archiveSummaries}`); + this.logPackageForPackage(pkg, "WARN", "Post-Processing Entpacken Fehlerdetails", { + failedArchives: [...fullFailedArchiveErrors.keys()], + summary: archiveSummaries + }); + } + this.applyPackageExtractFailureStatuses( + completedItems, + resolveArchiveItems, + fullFailedArchiveErrors, + reason, + preExtractStatuses, + failAt + ); + pkg.status = "failed"; + } else { + const hasExtractedOutput = await this.directoryHasAnyFiles(pkg.extractDir); + const sourceExists = await this.existsAsync(pkg.outputDir); + let finalStatusText = ""; + + if (result.extracted > 0 || hasExtractedOutput) { + finalStatusText = formatExtractDone(nowMs() - extractionStartMs); + } else if (!sourceExists) { + finalStatusText = "Entpackt (Quelle fehlt)"; + logger.warn(`Post-Processing ohne Quellordner: pkg=${pkg.name}, outputDir fehlt`); + } else { + finalStatusText = "Entpackt (keine Archive)"; + } + + const finalAt = nowMs(); + for (const entry of completedItems) { + if (!isExtractedLabel(entry.fullStatus)) { + entry.fullStatus = finalStatusText; + entry.updatedAt = finalAt; + } + } + pkg.status = "completed"; + } + } catch (error) { + const reasonRaw = String(error || ""); + const isExtractAbort = reasonRaw.includes("aborted:extract") || reasonRaw.includes("extract_timeout"); + let timeoutHandled = false; + if (isExtractAbort) { + if (timedOut) { + const timeoutReason = `Entpacken Timeout nach ${Math.ceil(extractTimeoutMs / 1000)}s`; + logger.error(`Post-Processing Entpacken Timeout: pkg=${pkg.name}`); + for (const entry of completedItems) { + if (entry.status === "completed" && !isExtractedLabel(entry.fullStatus)) { + entry.fullStatus = formatExtractFailureLabel(timeoutReason); + entry.updatedAt = nowMs(); + } + } + pkg.status = "failed"; + pkg.updatedAt = nowMs(); + timeoutHandled = true; + } else { + for (const entry of completedItems) { + if (/^Entpacken/i.test(entry.fullStatus || "") || /^Passwort/i.test(entry.fullStatus || "")) { + entry.fullStatus = "Entpacken abgebrochen (wird fortgesetzt)"; + entry.updatedAt = nowMs(); + } + } + pkg.status = (pkg.enabled && !this.session.paused) ? "queued" : "paused"; + pkg.updatedAt = nowMs(); + logger.info(`Post-Processing Entpacken abgebrochen: pkg=${pkg.name}`); + return; + } + } + if (!timeoutHandled) { + const reason = compactErrorText(error); + logger.error(`Post-Processing Entpacken Exception: pkg=${pkg.name}, reason=${reason}`); + for (const entry of completedItems) { + if (entry.status === "completed" && !isExtractedLabel(entry.fullStatus)) { + entry.fullStatus = formatExtractFailureLabel(reason); + entry.updatedAt = nowMs(); + } + } + pkg.status = "failed"; + } + } finally { + clearTimeout(extractDeadline); + if (signal) { + signal.removeEventListener("abort", onParentAbort); + } + } + } else if (failed > 0) { + pkg.status = "failed"; + } else if (cancelled > 0) { + pkg.status = success > 0 ? "completed" : "cancelled"; + } else { + pkg.status = "completed"; + } + + if (pkg.status === "completed") { + this.notifyPackageOutcome(pkg, "completed", `${success} Datei(en)${extractedCount > 0 ? `, ${extractedCount} entpackt` : ""}`); + } else if (pkg.status === "failed") { + this.notifyPackageOutcome(pkg, "failed", `${failed} von ${success + failed + cancelled} Datei(en) fehlgeschlagen`); + } + + this.emitState(); + + if (pkg.status === "completed" || (pkg.status === "failed" && success > 0)) { + this.recordPackageHistory(packageId, pkg, items); + } + + if (this.runPackageIds.has(packageId)) { + if (pkg.status === "completed" || pkg.status === "failed") { + this.runCompletedPackages.add(packageId); + } else { + this.runCompletedPackages.delete(packageId); + } + } + pkg.postProcessLabel = undefined; + pkg.updatedAt = nowMs(); + logger.info(`Post-Processing Ende: pkg=${pkg.name}, status=${pkg.status} (deferred work wird im Hintergrund ausgeführt)`); + this.logPackageForPackage(pkg, "INFO", "Post-Processing Ende", { + status: pkg.status, + success, + failed, + extractedCount, + alreadyMarkedExtracted + }); + + void this.runDeferredPostExtraction(packageId, pkg, success, failed, alreadyMarkedExtracted, extractedCount); + } + + private async runDeferredPostExtraction( + packageId: string, + pkg: PackageEntry, + success: number, + failed: number, + alreadyMarkedExtracted: boolean, + extractedCount: number + ): Promise<void> { + const replacedController = this.packageDeferredPostProcessAbortControllers.get(packageId); + if (replacedController && !replacedController.signal.aborted) { + replacedController.abort("deferred_replaced"); + } + const deferredController = new AbortController(); + this.packageDeferredPostProcessAbortControllers.set(packageId, deferredController); + const deferredVersion = this.getPackagePostProcessVersion(packageId); + const shouldAbort = (): boolean => !this.isDeferredPostProcessStillCurrent(packageId, pkg, deferredVersion, deferredController.signal); + const throwIfAborted = (): void => this.throwIfDeferredPostProcessAborted(packageId, pkg, deferredVersion, deferredController.signal); + const hasBlockingExtractError = pkg.itemIds.some((itemId) => { + const item = this.session.items[itemId]; + return Boolean(item && item.status === "completed" && isExtractErrorLabel(item.fullStatus || "")); + }); + + try { + throwIfAborted(); + if ((extractedCount > 0 || alreadyMarkedExtracted) && failed === 0 && this.settings.autoExtract) { + const nestedBlacklist = /\.(iso|img|bin|dmg|vhd|vhdx|vmdk|wim)$/i; + const nestedCandidates = (await findArchiveCandidates(pkg.extractDir)) + .filter((p) => !nestedBlacklist.test(p)); + if (nestedCandidates.length > 0) { + pkg.postProcessLabel = "Nested Entpacken..."; + this.emitState(); + logger.info(`Deferred Nested-Extraction: ${nestedCandidates.length} Archive in ${pkg.extractDir}`); + this.logPackageForPackage(pkg, "INFO", "Deferred Nested-Extraction gestartet", { + nestedCandidates: nestedCandidates.length, + extractDir: pkg.extractDir + }); + const nestedResult = await extractPackageArchives({ + packageDir: pkg.extractDir, + targetDir: pkg.extractDir, + cleanupMode: this.settings.cleanupMode, + conflictMode: this.settings.extractConflictMode, + removeLinks: false, + removeSamples: false, + passwordList: this.settings.archivePasswordList, + signal: deferredController.signal, + packageId, + onlyArchives: new Set(nestedCandidates.map((p) => process.platform === "win32" ? path.resolve(p).toLowerCase() : path.resolve(p))), + maxParallel: this.settings.maxParallelExtract || 2, + extractCpuPriority: this.settings.extractCpuPriority, + onLog: (level, message) => this.logPackageForPackage(pkg, level, `Nested-Extractor: ${message}`), + }); + throwIfAborted(); + extractedCount += nestedResult.extracted; + logger.info(`Deferred Nested-Extraction Ende: extracted=${nestedResult.extracted}, failed=${nestedResult.failed}`); + this.logPackageForPackage(pkg, "INFO", "Deferred Nested-Extraction Ende", { + extracted: nestedResult.extracted, + failed: nestedResult.failed + }); + } + } + + if (extractedCount > 0 || alreadyMarkedExtracted) { + pkg.postProcessLabel = "Renaming..."; + this.emitState(); + this.logPackageForPackage(pkg, "INFO", "Deferred Auto-Rename gestartet", { + extractDir: pkg.extractDir + }); + throwIfAborted(); + await this.autoRenameExtractedVideoFiles(pkg.extractDir, pkg, shouldAbort, true); + if (this.settings.keepGermanAudioOnly) { + pkg.postProcessLabel = "Tonspur..."; + this.emitState(); + throwIfAborted(); + await this.keepGermanAudioOnly(pkg.extractDir, pkg, shouldAbort, deferredController.signal); + } + } + + if ((extractedCount > 0 || alreadyMarkedExtracted) && failed === 0 && this.settings.cleanupMode !== "none") { + pkg.postProcessLabel = "Aufräumen..."; + this.emitState(); + throwIfAborted(); + if (hasBlockingExtractError) { + logger.info(`Deferred Archive-Cleanup uebersprungen: pkg=${pkg.name}, reason=extract_error`); + } else { + const sourceAndTargetEqual = path.resolve(pkg.outputDir).toLowerCase() === path.resolve(pkg.extractDir).toLowerCase(); + if (!sourceAndTargetEqual) { + const candidates = await findArchiveCandidates(pkg.outputDir); + if (candidates.length > 0) { + const removed = await cleanupArchives(candidates, this.settings.cleanupMode, { shouldAbort }); + if (removed > 0) { + logger.info(`Deferred Archive-Cleanup: pkg=${pkg.name}, entfernt=${removed}`); + } + } + } + } + } + + if (this.settings.autoExtract && alreadyMarkedExtracted && failed === 0 && success > 0 && this.settings.cleanupMode !== "none" && !hasBlockingExtractError) { + throwIfAborted(); + const removedArchives = await this.cleanupRemainingArchiveArtifacts(pkg.outputDir, shouldAbort); + if (removedArchives > 0) { + logger.info(`Hybrid-Post-Cleanup entfernte Archive: pkg=${pkg.name}, entfernt=${removedArchives}`); + } + } + + if (extractedCount > 0 || alreadyMarkedExtracted) { + throwIfAborted(); + if (this.settings.removeLinkFilesAfterExtract) { + const removedLinks = await removeDownloadLinkArtifacts(pkg.extractDir, { shouldAbort }); + if (removedLinks > 0) { + logger.info(`Deferred Link-Cleanup: pkg=${pkg.name}, entfernt=${removedLinks}`); + } + } + if (this.settings.removeSamplesAfterExtract) { + const removedSamples = await removeSampleArtifacts(pkg.extractDir, { shouldAbort }); + if (removedSamples.files > 0 || removedSamples.dirs > 0) { + logger.info(`Deferred Sample-Cleanup: pkg=${pkg.name}, files=${removedSamples.files}, dirs=${removedSamples.dirs}`); + } + } + } + + if ((extractedCount > 0 || alreadyMarkedExtracted) && failed === 0) { + throwIfAborted(); + await clearExtractResumeState(pkg.outputDir, packageId); + await clearExtractResumeState(pkg.outputDir); + } + + if ((extractedCount > 0 || alreadyMarkedExtracted) && failed === 0 && this.settings.cleanupMode === "delete") { + throwIfAborted(); + if (!(await hasAnyFilesRecursive(pkg.outputDir))) { + const removedDirs = await removeEmptyDirectoryTree(pkg.outputDir); + if (removedDirs > 0) { + logger.info(`Deferred leere Download-Ordner entfernt: pkg=${pkg.name}, dirs=${removedDirs}`); + } + } + } + + if (success > 0 && (pkg.status === "completed" || pkg.status === "failed")) { + throwIfAborted(); + pkg.postProcessLabel = "Verschiebe Videos..."; + this.emitState(); + await this.chainPackageFileOp(pkg.id, () => this.collectMkvFilesToLibrary(packageId, pkg, shouldAbort)); + } + + throwIfAborted(); + pkg.postProcessLabel = undefined; + pkg.updatedAt = nowMs(); + this.persistSoon(); + this.emitState(); + + this.applyPackageDoneCleanup(packageId); + } catch (error) { + const reason = compactErrorText(error); + if (reason.includes("aborted:deferred") + || reason.includes("deferred_replaced") + || reason.includes("package_removed") + || reason === "reset" + || reason === "cancel" + || reason === "overwrite" + || reason === "skip" + || reason === "package_toggle") { + logger.info(`Deferred Post-Extraction abgebrochen: pkg=${pkg.name}, reason=${reason}`); + } else { + logger.warn(`Deferred Post-Extraction Fehler: pkg=${pkg.name}, reason=${reason}`); + } + } finally { + if (this.packageDeferredPostProcessAbortControllers.get(packageId) === deferredController) { + this.packageDeferredPostProcessAbortControllers.delete(packageId); + } + if (this.session.packages[packageId] === pkg && this.getPackagePostProcessVersion(packageId) === deferredVersion) { + pkg.postProcessLabel = undefined; + pkg.updatedAt = nowMs(); + this.persistSoon(); + this.emitState(); + } + } + } + + private applyPackageDoneCleanup(packageId: string): void { + const policy = this.settings.completedCleanupPolicy; + if (policy !== "package_done" && policy !== "immediate") { + return; + } + + const pkg = this.session.packages[packageId]; + if (!pkg || (pkg.status !== "completed" && pkg.status !== "failed")) { + return; + } + + if (policy === "immediate") { + for (const itemId of [...pkg.itemIds]) { + this.applyCompletedCleanupPolicy(packageId, itemId, { ignoreDeferred: true }); + } + return; + } + + if (pkg.status !== "completed") { + return; + } + + const allDone = pkg.itemIds.every((itemId) => { + const item = this.session.items[itemId]; + return !item || item.status === "completed" || item.status === "cancelled" || item.status === "failed"; + }); + if (!allDone) { + return; + } + + if (this.settings.autoExtract) { + const allExtracted = pkg.itemIds.every((itemId) => { + const item = this.session.items[itemId]; + if (!item) return true; + if (item.status === "failed" || item.status === "cancelled") return true; + return isExtractedLabel(item.fullStatus || ""); + }); + if (!allExtracted) { + return; + } + } + + this.removePackageFromSession(packageId, [...pkg.itemIds], "completed"); + } + + private applyCompletedCleanupPolicy( + packageId: string, + itemId: string, + options?: { ignoreDeferred?: boolean } + ): void { + const policy = this.settings.completedCleanupPolicy; + if (policy === "never" || policy === "on_start") { + return; + } + + const pkg = this.session.packages[packageId]; + if (!pkg) { + return; + } + + if (!options?.ignoreDeferred && this.hasDeferredPostProcessPending(packageId)) { + return; + } + + if (policy === "immediate") { + const item = this.session.items[itemId]; + if (!item || item.status !== "completed") { + return; + } + if (this.settings.autoExtract) { + const extracted = isExtractedLabel(item.fullStatus || ""); + if (!extracted) { + return; + } + } + pkg.itemIds = pkg.itemIds.filter((id) => id !== itemId); + this.releaseTargetPath(itemId); + this.dropItemContribution(itemId); + delete this.session.items[itemId]; + this.itemCount = Math.max(0, this.itemCount - 1); + this.retryAfterByItem.delete(itemId); + this.pacedStartReservationByItem.delete(itemId); + this.retryStateByItem.delete(itemId); + if (pkg.itemIds.length === 0) { + this.removePackageFromSession(packageId, []); + } + return; + } + + if (policy === "package_done") { + if (pkg.status !== "completed") { + return; + } + const hasOpen = pkg.itemIds.some((id) => { + const item = this.session.items[id]; + return item != null && item.status !== "completed" && item.status !== "cancelled" && item.status !== "failed"; + }); + if (!hasOpen) { + if (this.settings.autoExtract) { + const allExtracted = pkg.itemIds.every((id) => { + const item = this.session.items[id]; + if (!item) return true; + if (item.status === "failed" || item.status === "cancelled") return true; + return isExtractedLabel(item.fullStatus || ""); + }); + if (!allExtracted) { + return; + } + } + this.removePackageFromSession(packageId, [...pkg.itemIds], "completed"); + } + } + } + + private finishRun(): void { + const runStartedAt = this.session.runStartedAt; + this.session.running = false; + this.session.paused = false; + this.session.runStartedAt = 0; + const total = this.runItemIds.size; + const outcomes = Array.from(this.runOutcomes.values()); + const success = outcomes.filter((status) => status === "completed").length; + const failed = outcomes.filter((status) => status === "failed").length; + const cancelled = outcomes.filter((status) => status === "cancelled").length; + const extracted = this.runCompletedPackages.size; + const duration = runStartedAt > 0 ? Math.max(1, Math.floor((nowMs() - runStartedAt) / 1000)) : 1; + const avgSpeed = Math.floor(this.session.totalDownloadedBytes / duration); + this.summary = { + total, + success, + failed, + cancelled, + extracted, + durationSeconds: duration, + averageSpeedBps: avgSpeed + }; + this.session.summaryText = `Summary: Dauer ${duration}s, Ø Speed ${humanSize(avgSpeed)}/s, Erfolg ${success}/${total}`; + if (this.settings.notifyOnRunFinished && total > 0) { + // With autoExtractWhenStopped (default) the run ends as soon as downloads + // are done while extraction may still be running — say so instead of + // claiming the whole run is finished. + const postProcessPending = this.packagePostProcessTasks.size > 0 || this.hasAnyDeferredPostProcessPending(); + const scope = postProcessPending ? "Downloads beendet" : "Durchlauf beendet"; + void sendNotification(this.settings.notifyUrl, { + title: failed > 0 ? `⚠️ ${scope}` : `🏁 ${scope}`, + message: `${success}/${total} erfolgreich, ${failed} fehlgeschlagen, ${cancelled} abgebrochen\nDauer ${duration}s, Durchschnitt ${humanSize(avgSpeed)}/s${postProcessPending ? "\nEntpacken laeuft noch — Paket-Meldungen folgen." : ""}`, + mention: this.settings.notifyMention + }); + } + this.runItemIds.clear(); + this.runOutcomes.clear(); + if (this.packagePostProcessTasks.size === 0 && !this.hasAnyDeferredPostProcessPending()) { + this.runPackageIds.clear(); + this.runCompletedPackages.clear(); + } + this.retryAfterByItem.clear(); + this.providerStartReservations.clear(); + this.pacedStartReservationByItem.clear(); + this.retryStateByItem.clear(); + this.reservedTargetPaths.clear(); + this.claimedTargetPathByItem.clear(); + this.itemContributedBytes.clear(); + this.speedEvents = []; + this.speedEventsHead = 0; + this.speedBytesLastWindow = 0; + this.speedBytesPerPackage.clear(); + this.globalSpeedLimitQueue = Promise.resolve(); + this.globalSpeedLimitNextAt = 0; + this.nonResumableActive = 0; + this.lastGlobalProgressBytes = this.session.totalDownloadedBytes; + this.lastGlobalProgressAt = nowMs(); + this.lastSettingsPersistAt = 0; + this.persistNow(); + this.emitState(); + } + + public getSessionStats(): import("../shared/types").SessionStats { + const now = nowMs(); + this.pruneSpeedEvents(now); + + const bandwidthSamples: import("../shared/types").BandwidthSample[] = []; + for (let i = this.speedEventsHead; i < this.speedEvents.length; i += 1) { + const event = this.speedEvents[i]; + if (event) { + bandwidthSamples.push({ + timestamp: event.at, + speedBps: Math.floor(event.bytes * (1000 / 120)) + }); + } + } + + const paused = this.session.running && this.session.paused; + const currentSpeedBps = !this.session.running || paused ? 0 : this.speedBytesLastWindow / SPEED_WINDOW_SECONDS; + + let maxSpeed = 0; + for (let i = this.speedEventsHead; i < this.speedEvents.length; i += 1) { + const event = this.speedEvents[i]; + if (event) { + const speed = Math.floor(event.bytes * (1000 / 120)); + if (speed > maxSpeed) { + maxSpeed = speed; + } + } + } + + const sessionDurationSeconds = this.session.runStartedAt > 0 + ? Math.max(0, Math.floor((now - this.session.runStartedAt) / 1000)) + : 0; + + const averageSpeedBps = sessionDurationSeconds > 0 + ? Math.floor(this.session.totalDownloadedBytes / sessionDurationSeconds) + : 0; + + let totalDownloads = 0; + let completedDownloads = 0; + let failedDownloads = 0; + let activeDownloads = 0; + let queuedDownloads = 0; + + for (const item of Object.values(this.session.items)) { + totalDownloads += 1; + if (item.status === "completed") { + completedDownloads += 1; + } else if (item.status === "failed") { + failedDownloads += 1; + } else if (item.status === "downloading" || item.status === "validating" || item.status === "integrity_check") { + activeDownloads += 1; + } else if (item.status === "queued" || item.status === "reconnect_wait" || item.status === "paused") { + queuedDownloads += 1; + } + } + + return { + bandwidth: { + samples: bandwidthSamples.slice(-120), + currentSpeedBps: Math.floor(currentSpeedBps), + averageSpeedBps, + maxSpeedBps: Math.floor(maxSpeed), + totalBytesSession: this.session.totalDownloadedBytes, + sessionDurationSeconds + }, + totalDownloads, + completedDownloads, + failedDownloads, + activeDownloads, + queuedDownloads + }; + } +} diff --git a/src/main/error-ring.ts b/src/main/error-ring.ts new file mode 100644 index 0000000..c2001da --- /dev/null +++ b/src/main/error-ring.ts @@ -0,0 +1,45 @@ +export interface ErrorRingEntry { + ts: string; + level: string; + message: string; +} + +export interface ErrorRing { + push: (entry: ErrorRingEntry) => void; + snapshot: () => ErrorRingEntry[]; + clear: () => void; + size: () => number; +} + +export function createErrorRing(capacity: number): ErrorRing { + const limit = Math.max(1, Math.floor(capacity)); + const buffer: ErrorRingEntry[] = []; + return { + push(entry: ErrorRingEntry): void { + buffer.push(entry); + while (buffer.length > limit) { + buffer.shift(); + } + }, + snapshot(): ErrorRingEntry[] { + return buffer.slice(); + }, + clear(): void { + buffer.length = 0; + }, + size(): number { + return buffer.length; + } + }; +} + +const RECENT_ERROR_CAPACITY = 200; +const recentErrors = createErrorRing(RECENT_ERROR_CAPACITY); + +export function recordRecentError(level: string, message: string, ts: string): void { + recentErrors.push({ level, message, ts }); +} + +export function getRecentErrors(): ErrorRingEntry[] { + return recentErrors.snapshot(); +} diff --git a/src/main/extractor.ts b/src/main/extractor.ts new file mode 100644 index 0000000..f1923d9 --- /dev/null +++ b/src/main/extractor.ts @@ -0,0 +1,3451 @@ +import fs from "node:fs"; +import path from "node:path"; +import os from "node:os"; +import { spawn, spawnSync, type ChildProcess } from "node:child_process"; +import AdmZip from "adm-zip"; +import { CleanupMode, ConflictMode } from "../shared/types"; +import { logger } from "./logger"; +import { removeDownloadLinkArtifacts, removeSampleArtifacts } from "./cleanup"; + +import crypto from "node:crypto"; + +const DEFAULT_ARCHIVE_PASSWORDS = ["", "serienfans.org", "serienjunkies.org"]; +const NO_EXTRACTOR_MESSAGE = "Kein nativer Entpacker gefunden (7-Zip/WinRAR). Bitte 7-Zip oder WinRAR installieren."; +const NO_JVM_EXTRACTOR_MESSAGE = "7-Zip-JBinding Runtime nicht gefunden. Bitte resources/extractor-jvm prüfen."; +const JVM_EXTRACTOR_MAIN_CLASS = "com.sucukdeluxe.extractor.JBindExtractorMain"; +const JVM_EXTRACTOR_CLASSES_SUBDIR = "classes"; +const JVM_EXTRACTOR_LIB_SUBDIR = "lib"; +const JVM_EXTRACTOR_REQUIRED_LIBS = [ + "sevenzipjbinding.jar", + "sevenzipjbinding-all-platforms.jar", + "zip4j.jar" +]; + +let resolvedExtractorCommand: string | null = null; +let resolveFailureReason = ""; +let resolveFailureAt = 0; +let externalExtractorSupportsPerfFlags = true; +let resolveExtractorCommandInFlight: Promise<string> | null = null; + +const EXTRACTOR_RETRY_AFTER_MS = 30_000; +const DEFAULT_ZIP_ENTRY_MEMORY_LIMIT_MB = 256; +const MAX_EXTRACT_OUTPUT_BUFFER = 48 * 1024; +const EXTRACT_PROGRESS_FILE = ".rd_extract_progress.json"; +const EXTRACT_BASE_TIMEOUT_MS = 6 * 60 * 1000; +const EXTRACT_PER_GIB_TIMEOUT_MS = 4 * 60 * 1000; +const EXTRACT_MAX_TIMEOUT_MS = 120 * 60 * 1000; +const ARCHIVE_SORT_COLLATOR = new Intl.Collator(undefined, { numeric: true, sensitivity: "base" }); +const DISK_SPACE_SAFETY_FACTOR = 1.1; +const NESTED_EXTRACT_BLACKLIST_RE = /\.(iso|img|bin|dmg|vhd|vhdx|vmdk|wim)$/i; +const PACKAGE_PASSWORD_CACHE_LIMIT = 256; +const packageLearnedPasswords = new Map<string, string>(); +const EXTRACTOR_PROBE_TIMEOUT_MS = 8_000; +const DEFAULT_EXTRACT_CPU_BUDGET_PERCENT = 80; +let currentExtractCpuPriority: string | undefined; + +export interface ExtractOptions { + packageDir: string; + targetDir: string; + cleanupMode: CleanupMode; + conflictMode: ConflictMode; + removeLinks: boolean; + removeSamples: boolean; + passwordList?: string; + signal?: AbortSignal; + onProgress?: (update: ExtractProgressUpdate) => void; + onlyArchives?: Set<string>; + skipPostCleanup?: boolean; + packageId?: string; + hybridMode?: boolean; + maxParallel?: number; + extractCpuPriority?: string; + onArchiveFailure?: (failure: ExtractArchiveFailureInfo) => void; + onLog?: (level: "INFO" | "WARN" | "ERROR", message: string) => void; +} + +export interface ExtractProgressUpdate { + current: number; + total: number; + percent: number; + archiveName: string; + archivePercent?: number; + elapsedMs?: number; + phase: "extracting" | "done" | "preparing"; + passwordAttempt?: number; + passwordTotal?: number; + passwordFound?: boolean; + archiveDone?: boolean; + archiveSuccess?: boolean; +} + +export interface ExtractArchiveFailureInfo { + archiveName: string; + errorText: string; + category: ExtractErrorCategory; + suggestRedownload: boolean; + jvmFailureReason?: string; +} + +export type ArchiveSignature = "rar" | "7z" | "zip" | "gzip" | "bzip2" | "xz" | null; + +const ARCHIVE_SIGNATURES: { prefix: string; type: ArchiveSignature }[] = [ + { prefix: "526172211a07", type: "rar" }, + { prefix: "377abcaf271c", type: "7z" }, + { prefix: "504b0304", type: "zip" }, + { prefix: "1f8b08", type: "gzip" }, + { prefix: "425a68", type: "bzip2" }, + { prefix: "fd377a585a00", type: "xz" }, +]; + +export type ExtractErrorCategory = + | "crc_error" + | "wrong_password" + | "missing_parts" + | "unsupported_format" + | "disk_full" + | "timeout" + | "aborted" + | "no_extractor" + | "unknown"; + +export class ExtractionError extends Error { + constructor(message: string, public readonly category: ExtractErrorCategory) { + super(message); + this.name = "ExtractionError"; + } +} + +type ExtractionErrorWithHints = Error & { + suggestRedownload?: boolean; + jvmFailureReason?: string; + legacyBestPercent?: number; + legacyExtractor?: string; +}; + +type ExtractBackendMode = "auto" | "jvm" | "legacy"; + +type JvmExtractorLayout = { + javaCommand: string; + classPath: string; + rootDir: string; +}; + +type JvmExtractResult = { + ok: boolean; + missingCommand: boolean; + missingRuntime: boolean; + aborted: boolean; + timedOut: boolean; + errorText: string; + usedPassword: string; + backend: string; +}; + +type ExtractSpawnResult = { + ok: boolean; + missingCommand: boolean; + aborted: boolean; + timedOut: boolean; + errorText: string; +}; + +type ExtractResumeState = { + completedArchives: string[]; +}; + +type ExtractorCommandKind = "rar_native" | "seven_zip" | "other"; + +interface SubstMapping { drive: string; original: string; } + +interface DaemonRequest { + resolve: (result: JvmExtractResult) => void; + onArchiveProgress?: (percent: number) => void; + signal?: AbortSignal; + timeoutMs?: number; + parseState: { bestPercent: number; usedPassword: string; backend: string; reportedError: string }; + archiveName: string; + startedAt: number; + passwordCount: number; +} + +const activeSubstDrives = new Set<string>(); + +function findFreeSubstDrive(): string | null { + if (process.platform !== "win32") return null; + for (let code = 90; code >= 71; code--) { + const letter = String.fromCharCode(code); + if (activeSubstDrives.has(letter)) continue; + try { + fs.accessSync(`${letter}:\\`); + } catch { + return letter; + } + } + return null; +} + +function createSubstMapping(targetDir: string): SubstMapping | null { + if (process.platform !== "win32" || !path.isAbsolute(targetDir)) return null; + const drive = findFreeSubstDrive(); + if (!drive) return null; + const result = spawnSync("subst", [`${drive}:`, targetDir], { stdio: "pipe", timeout: 5000 }); + if (result.status !== 0) { + logger.warn(`subst ${drive}: fehlgeschlagen: ${String(result.stderr || "").trim()}`); + return null; + } + activeSubstDrives.add(drive); + logger.info(`subst ${drive}: -> ${targetDir}`); + return { drive, original: targetDir }; +} + +function removeSubstMapping(mapping: SubstMapping): void { + spawnSync("subst", [`${mapping.drive}:`, "/d"], { stdio: "pipe", timeout: 5000 }); + activeSubstDrives.delete(mapping.drive); + logger.info(`subst ${mapping.drive}: entfernt`); +} + +export function cleanupStaleSubstDrives(): void { + if (process.platform !== "win32") return; + try { + const result = spawnSync("subst", [], { stdio: "pipe", timeout: 5000 }); + const output = String(result.stdout || ""); + for (const line of output.split("\n")) { + const match = line.match(/^([A-Z]):\\: => (.+)/i); + if (!match) continue; + const drive = match[1].toUpperCase(); + const target = match[2].trim(); + if (/\\rd-extract-|\\Real-Debrid-Downloader/i.test(target)) { + spawnSync("subst", [`${drive}:`, "/d"], { stdio: "pipe", timeout: 5000 }); + logger.info(`Stale subst ${drive}: entfernt (${target})`); + } + } + } catch { + } +} + +export async function detectArchiveSignature(filePath: string): Promise<ArchiveSignature> { + let fd: fs.promises.FileHandle | null = null; + try { + fd = await fs.promises.open(filePath, "r"); + const buf = Buffer.alloc(8); + const { bytesRead } = await fd.read(buf, 0, 8, 0); + if (bytesRead < 3) return null; + const hex = buf.subarray(0, bytesRead).toString("hex"); + for (const sig of ARCHIVE_SIGNATURES) { + if (hex.startsWith(sig.prefix)) return sig.type; + } + return null; + } catch { + return null; + } finally { + await fd?.close(); + } +} + +export function pathSetKey(filePath: string): string { + return process.platform === "win32" ? filePath.toLowerCase() : filePath; +} + +function archiveNameKey(fileName: string): string { + return process.platform === "win32" ? String(fileName || "").toLowerCase() : String(fileName || ""); +} + +function stripDuplicateSuffixBeforeExtension(fileName: string): string { + return String(fileName || "").replace(/ \(\d+\)(?=\.[^.]+$)/, ""); +} + +function hasDuplicateSuffixBeforeExtension(fileName: string): boolean { + return stripDuplicateSuffixBeforeExtension(fileName) !== String(fileName || ""); +} + +function archiveDetectionName(fileName: string): string { + return stripDuplicateSuffixBeforeExtension(path.basename(String(fileName || ""))); +} + +function archiveCandidateIdentity(filePath: string): string { + const normalizedPath = path.join(path.dirname(filePath), archiveDetectionName(filePath)); + return pathSetKey(normalizedPath); +} + +function prefersArchiveCandidate(nextCandidate: string, currentCandidate: string): boolean { + const nextName = path.basename(nextCandidate); + const currentName = path.basename(currentCandidate); + const nextHasDuplicateSuffix = hasDuplicateSuffixBeforeExtension(nextName); + const currentHasDuplicateSuffix = hasDuplicateSuffixBeforeExtension(currentName); + if (nextHasDuplicateSuffix !== currentHasDuplicateSuffix) { + return !nextHasDuplicateSuffix; + } + return ARCHIVE_SORT_COLLATOR.compare(nextName, currentName) < 0; +} + +function archiveSortKey(filePath: string): string { + const fileName = archiveDetectionName(filePath).toLowerCase(); + return fileName + .replace(/\.part0*1\.rar$/i, "") + .replace(/\.zip\.\d{3}$/i, "") + .replace(/\.7z\.\d{3}$/i, "") + .replace(/\.\d{3}$/i, "") + .replace(/\.(?:tar\.(?:gz|bz2|xz)|tgz|tbz2|txz)$/i, "") + .replace(/\.rar$/i, "") + .replace(/\.zip$/i, "") + .replace(/\.7z$/i, "") + .replace(/[._\-\s]+$/g, ""); +} + +function archiveTypeRank(filePath: string): number { + const fileName = archiveDetectionName(filePath).toLowerCase(); + if (/\.part0*1\.rar$/i.test(fileName)) { + return 0; + } + if (/\.rar$/i.test(fileName)) { + return 1; + } + if (/\.zip(?:\.\d{3})?$/i.test(fileName)) { + return 2; + } + if (/\.7z(?:\.\d{3})?$/i.test(fileName)) { + return 3; + } + if (/\.(?:tar\.(?:gz|bz2|xz)|tgz|tbz2|txz)$/i.test(fileName)) { + return 4; + } + if (/\.\d{3}$/i.test(fileName)) { + return 5; + } + return 9; +} + +export async function findArchiveCandidates(packageDir: string): Promise<string[]> { + if (!packageDir) { + return []; + } + try { + await fs.promises.access(packageDir); + } catch { + return []; + } + + let files: string[] = []; + try { + files = (await fs.promises.readdir(packageDir, { withFileTypes: true })) + .filter((entry) => entry.isFile()) + .map((entry) => path.join(packageDir, entry.name)); + } catch { + return []; + } + + const fileNamesLower = new Set(files.map((filePath) => archiveDetectionName(filePath).toLowerCase())); + const multipartRar = files.filter((filePath) => /\.part0*1\.rar$/i.test(archiveDetectionName(filePath))); + const singleRar = files.filter((filePath) => { + const fileName = archiveDetectionName(filePath); + return /\.rar$/i.test(fileName) && !/\.part\d+\.rar$/i.test(fileName); + }); + const zipSplit = files.filter((filePath) => /\.zip\.001$/i.test(archiveDetectionName(filePath))); + const zip = files.filter((filePath) => { + const fileName = archiveDetectionName(filePath); + if (!/\.zip$/i.test(fileName)) { + return false; + } + return !fileNamesLower.has(`${fileName}.001`.toLowerCase()); + }); + const sevenSplit = files.filter((filePath) => /\.7z\.001$/i.test(archiveDetectionName(filePath))); + const seven = files.filter((filePath) => { + const fileName = archiveDetectionName(filePath); + if (!/\.7z$/i.test(fileName)) { + return false; + } + return !fileNamesLower.has(`${fileName}.001`.toLowerCase()); + }); + const tarCompressed = files.filter((filePath) => /\.(?:tar\.(?:gz|bz2|xz)|tgz|tbz2|txz)$/i.test(filePath)); + const genericSplit = files.filter((filePath) => { + const fileName = archiveDetectionName(filePath).toLowerCase(); + if (!/\.001$/.test(fileName)) return false; + if (/\.zip\.001$/.test(fileName) || /\.7z\.001$/.test(fileName)) return false; + return true; + }); + + const unique: string[] = []; + const seen = new Map<string, number>(); + for (const candidate of [...multipartRar, ...singleRar, ...zipSplit, ...zip, ...sevenSplit, ...seven, ...tarCompressed, ...genericSplit]) { + const key = archiveCandidateIdentity(candidate); + const existingIndex = seen.get(key); + if (existingIndex !== undefined) { + if (prefersArchiveCandidate(candidate, unique[existingIndex])) { + unique[existingIndex] = candidate; + } + continue; + } + seen.set(key, unique.length); + unique.push(candidate); + } + + unique.sort((left, right) => { + const keyCmp = ARCHIVE_SORT_COLLATOR.compare(archiveSortKey(left), archiveSortKey(right)); + if (keyCmp !== 0) { + return keyCmp; + } + const rankCmp = archiveTypeRank(left) - archiveTypeRank(right); + if (rankCmp !== 0) { + return rankCmp; + } + return ARCHIVE_SORT_COLLATOR.compare(path.basename(left), path.basename(right)); + }); + + return unique; +} + +function escapeRegex(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + +export function collectArchiveCleanupTargets(sourceArchivePath: string, directoryFiles?: string[]): string[] { + const targets = new Set<string>([sourceArchivePath]); + const dir = path.dirname(sourceArchivePath); + const fileName = path.basename(sourceArchivePath); + + let filesInDir: string[] = directoryFiles ?? []; + if (!directoryFiles) { + try { + filesInDir = fs.readdirSync(dir, { withFileTypes: true }) + .filter((entry) => entry.isFile()) + .map((entry) => entry.name); + } catch { + return Array.from(targets); + } + } + + const addMatching = (pattern: RegExp): void => { + for (const candidate of filesInDir) { + if (pattern.test(candidate)) { + targets.add(path.join(dir, candidate)); + } + } + }; + + const COMPANION_EXTS_RE = /\.(?:sfv|nfo|nzb|md5|sha1|sha256|crc|srr)$/i; + const addCompanions = (stemRe: string): void => { + for (const candidate of filesInDir) { + if (!COMPANION_EXTS_RE.test(candidate)) continue; + const candidateStem = candidate.replace(/\.[^.]+$/, ""); + if (new RegExp(`^${stemRe}$`, "i").test(candidateStem)) { + targets.add(path.join(dir, candidate)); + } + } + }; + + const multipartRar = fileName.match(/^(.*)\.part\d+\.rar$/i); + if (multipartRar) { + const prefix = escapeRegex(multipartRar[1]); + addMatching(new RegExp(`^${prefix}\\.part\\d+\\.rar$`, "i")); + addMatching(new RegExp(`^${prefix}\\.part\\d+\\.rev$`, "i")); + addMatching(new RegExp(`^${prefix}\\.rev$`, "i")); + addCompanions(prefix); + return Array.from(targets); + } + + if (/\.rar$/i.test(fileName)) { + const stem = escapeRegex(fileName.replace(/\.rar$/i, "")); + addMatching(new RegExp(`^${stem}\\.rar$`, "i")); + addMatching(new RegExp(`^${stem}\\.r\\d{2,3}$`, "i")); + addMatching(new RegExp(`^${stem}\\.rev$`, "i")); + addCompanions(stem); + return Array.from(targets); + } + + if (/\.zip$/i.test(fileName)) { + const stem = escapeRegex(fileName.replace(/\.zip$/i, "")); + addMatching(new RegExp(`^${stem}\\.zip$`, "i")); + addMatching(new RegExp(`^${stem}\\.z\\d{2,3}$`, "i")); + addCompanions(stem); + return Array.from(targets); + } + + const splitZip = fileName.match(/^(.*)\.zip\.\d{3}$/i); + if (splitZip) { + const stem = escapeRegex(splitZip[1]); + addMatching(new RegExp(`^${stem}\\.zip$`, "i")); + addMatching(new RegExp(`^${stem}\\.zip\\.\\d{3}$`, "i")); + addCompanions(stem); + return Array.from(targets); + } + + if (/\.7z$/i.test(fileName)) { + const stem = escapeRegex(fileName.replace(/\.7z$/i, "")); + addMatching(new RegExp(`^${stem}\\.7z$`, "i")); + addMatching(new RegExp(`^${stem}\\.7z\\.\\d{3}$`, "i")); + addCompanions(stem); + return Array.from(targets); + } + + const splitSeven = fileName.match(/^(.*)\.7z\.\d{3}$/i); + if (splitSeven) { + const stem = escapeRegex(splitSeven[1]); + addMatching(new RegExp(`^${stem}\\.7z$`, "i")); + addMatching(new RegExp(`^${stem}\\.7z\\.\\d{3}$`, "i")); + addCompanions(stem); + return Array.from(targets); + } + + if (/\.(?:tar\.(?:gz|bz2|xz)|tgz|tbz2|txz)$/i.test(fileName)) { + return Array.from(targets); + } + + const genericSplit = fileName.match(/^(.*)\.(\d{3})$/i); + if (genericSplit) { + const stem = escapeRegex(genericSplit[1]); + addMatching(new RegExp(`^${stem}\\.\\d{3}$`, "i")); + return Array.from(targets); + } + + return Array.from(targets); +} + +export async function cleanupArchives( + sourceFiles: string[], + cleanupMode: CleanupMode, + options: { shouldAbort?: () => boolean } = {} +): Promise<number> { + if (cleanupMode === "none") { + return 0; + } + + const targets = new Set<string>(); + const dirFilesCache = new Map<string, string[]>(); + for (const sourceFile of sourceFiles) { + if (options.shouldAbort?.()) { + return 0; + } + const dir = path.dirname(sourceFile); + let filesInDir = dirFilesCache.get(dir); + if (!filesInDir) { + try { + filesInDir = (await fs.promises.readdir(dir, { withFileTypes: true })) + .filter((entry) => entry.isFile()) + .map((entry) => entry.name); + } catch { + filesInDir = []; + } + dirFilesCache.set(dir, filesInDir); + } + + for (const target of collectArchiveCleanupTargets(sourceFile, filesInDir)) { + targets.add(target); + } + } + + let removed = 0; + + const moveToTrashLike = async (filePath: string): Promise<boolean> => { + try { + const parsed = path.parse(filePath); + const trashDir = path.join(parsed.dir, ".rd-trash"); + await fs.promises.mkdir(trashDir, { recursive: true }); + let index = 0; + while (index <= 10000) { + const suffix = index === 0 ? "" : `-${index}`; + const candidate = path.join(trashDir, `${parsed.base}.${Date.now()}${suffix}`); + const candidateExists = await fs.promises.access(candidate).then(() => true, () => false); + if (!candidateExists) { + await fs.promises.rename(filePath, candidate); + return true; + } + index += 1; + } + } catch { + } + return false; + }; + + for (const filePath of targets) { + if (options.shouldAbort?.()) { + return removed; + } + try { + const fileExists = await fs.promises.access(filePath).then(() => true, () => false); + if (!fileExists) { + continue; + } + if (cleanupMode === "trash") { + if (await moveToTrashLike(filePath)) { + removed += 1; + } + continue; + } + await fs.promises.rm(filePath, { force: true }); + removed += 1; + } catch { + } + } + return removed; +} + +export async function hasAnyFilesRecursive(rootDir: string): Promise<boolean> { + const rootExists = await fs.promises.access(rootDir).then(() => true, () => false); + if (!rootExists) { + return false; + } + const deadline = Date.now() + 220; + let inspectedDirs = 0; + const stack = [rootDir]; + while (stack.length > 0) { + inspectedDirs += 1; + if (inspectedDirs > 8000 || Date.now() > deadline) { + return hasAnyEntries(rootDir); + } + const current = stack.pop() as string; + let entries: fs.Dirent[] = []; + try { + entries = await fs.promises.readdir(current, { withFileTypes: true }); + } catch { + continue; + } + + for (const entry of entries) { + if (entry.isFile()) { + return true; + } + if (entry.isDirectory()) { + stack.push(path.join(current, entry.name)); + } + } + } + return false; +} + +async function hasAnyEntries(rootDir: string): Promise<boolean> { + if (!rootDir) { + return false; + } + const rootExists = await fs.promises.access(rootDir).then(() => true, () => false); + if (!rootExists) { + return false; + } + try { + return (await fs.promises.readdir(rootDir)).length > 0; + } catch { + return false; + } +} + +export async function removeEmptyDirectoryTree(rootDir: string): Promise<number> { + const rootExists = await fs.promises.access(rootDir).then(() => true, () => false); + if (!rootExists) { + return 0; + } + + const dirs = [rootDir]; + const stack = [rootDir]; + while (stack.length > 0) { + const current = stack.pop() as string; + let entries: fs.Dirent[] = []; + try { + entries = await fs.promises.readdir(current, { withFileTypes: true }); + } catch { + continue; + } + for (const entry of entries) { + if (entry.isDirectory()) { + const full = path.join(current, entry.name); + dirs.push(full); + stack.push(full); + } + } + } + + dirs.sort((a, b) => b.length - a.length); + let removed = 0; + for (const dirPath of dirs) { + try { + const entries = await fs.promises.readdir(dirPath); + if (entries.length === 0) { + await fs.promises.rmdir(dirPath); + removed += 1; + } + } catch { + } + } + return removed; +} + +function packagePasswordCacheKey(packageDir: string, packageId?: string): string { + const normalizedPackageId = String(packageId || "").trim(); + if (normalizedPackageId) { + return `pkg:${normalizedPackageId}`; + } + return `dir:${pathSetKey(path.resolve(packageDir))}`; +} + +function packagePasswordCacheLabel(packageDir: string, packageId?: string): string { + const normalizedPackageId = String(packageId || "").trim(); + if (normalizedPackageId) { + return `packageId=${normalizedPackageId.slice(0, 8)}`; + } + return `packageDir=${path.basename(path.resolve(packageDir))}`; +} + +function readCachedPackagePassword(cacheKey: string): string { + const cached = packageLearnedPasswords.get(cacheKey); + if (!cached) { + return ""; + } + packageLearnedPasswords.delete(cacheKey); + packageLearnedPasswords.set(cacheKey, cached); + return cached; +} + +function writeCachedPackagePassword(cacheKey: string, password: string): void { + const normalized = String(password || "").trim(); + if (!normalized) { + return; + } + if (packageLearnedPasswords.has(cacheKey)) { + packageLearnedPasswords.delete(cacheKey); + } + packageLearnedPasswords.set(cacheKey, normalized); + if (packageLearnedPasswords.size > PACKAGE_PASSWORD_CACHE_LIMIT) { + const oldestKey = packageLearnedPasswords.keys().next().value as string | undefined; + if (oldestKey) { + packageLearnedPasswords.delete(oldestKey); + } + } +} + +function clearCachedPackagePassword(cacheKey: string): void { + packageLearnedPasswords.delete(cacheKey); +} + +export function resetExtractorCachesForPasswordChange(): { learnedCleared: number; daemonRestarted: boolean } { + const learnedCleared = packageLearnedPasswords.size; + packageLearnedPasswords.clear(); + let daemonRestarted = false; + if (daemonProcess && !daemonBusy) { + shutdownDaemon(); + daemonRestarted = true; + } + return { learnedCleared, daemonRestarted }; +} + +export function archiveFilenamePasswords(archiveName: string): string[] { + const name = String(archiveName || ""); + if (!name) return []; + const stem = name + .replace(/\.part\d+\.rar$/i, "") + .replace(/\.zip\.\d{3}$/i, "") + .replace(/\.7z\.\d{3}$/i, "") + .replace(/\.\d{3}$/i, "") + .replace(/\.(?:tar\.(?:gz|bz2|xz)|tgz|tbz2|txz)$/i, "") + .replace(/\.(rar|zip|7z|tar|gz|bz2|xz)$/i, ""); + if (!stem) return []; + const candidates = [stem]; + const withSpaces = stem.replace(/[._]/g, " "); + if (withSpaces !== stem) candidates.push(withSpaces); + return candidates; +} + +function archivePasswords(listInput: string): string[] { + const custom = String(listInput || "") + .split(/\r?\n/g) + .map((part) => part.trim()) + .filter(Boolean); + + const fromEnv = String(process.env.RD_ARCHIVE_PASSWORDS || "") + .split(/[;,\n]/g) + .map((part) => part.trim()) + .filter(Boolean); + + return Array.from(new Set(["", ...custom, ...fromEnv, ...DEFAULT_ARCHIVE_PASSWORDS])); +} + +function prioritizePassword(passwords: string[], successful: string): string[] { + const target = String(successful || ""); + if (!target || passwords.length <= 1) { + return passwords; + } + const index = passwords.findIndex((candidate) => candidate === target); + if (index === 0) { + return passwords; + } + if (index < 0) { + return [target, ...passwords.filter((candidate) => candidate !== target)]; + } + const next = [...passwords]; + const [value] = next.splice(index, 1); + next.unshift(value); + return next; +} + +export function cleanErrorText(text: string): string { + const normalized = String(text || "").replace(/\s+/g, " ").trim(); + if (normalized.length <= 500) { + return normalized; + } + const head = normalized.slice(0, 240).trimEnd(); + const tail = normalized.slice(-240).trimStart(); + return `${head} ... ${tail}`; +} + +function appendLimited(base: string, chunk: string, maxLen = MAX_EXTRACT_OUTPUT_BUFFER): string { + const next = `${base}${chunk}`; + if (next.length <= maxLen) { + return next; + } + return next.slice(next.length - maxLen); +} + +function withExtractionErrorHints( + error: unknown, + hints: { + suggestRedownload?: boolean; + jvmFailureReason?: string; + legacyBestPercent?: number; + legacyExtractor?: string; + } +): Error { + const base = error instanceof Error ? error : new Error(String(error || "Entpacken fehlgeschlagen")); + const enhanced = base as ExtractionErrorWithHints; + if (hints.suggestRedownload) { + enhanced.suggestRedownload = true; + } + if (hints.jvmFailureReason) { + enhanced.jvmFailureReason = hints.jvmFailureReason; + } + if (Number.isFinite(hints.legacyBestPercent)) { + enhanced.legacyBestPercent = Math.max(Number(enhanced.legacyBestPercent || 0), Number(hints.legacyBestPercent || 0)); + } + if (hints.legacyExtractor) { + enhanced.legacyExtractor = hints.legacyExtractor; + } + return enhanced; +} + +export function classifyExtractionError(errorText: unknown): ExtractErrorCategory { + if (errorText instanceof ExtractionError) return errorText.category; + const text = String(errorText || "").toLowerCase(); + if (text.includes("aborted:extract") || text.includes("extract_aborted")) return "aborted"; + if (text.includes("timeout")) return "timeout"; + if (text.includes("crc failed") || text.includes("checksum error") || text.includes("crc error")) return "crc_error"; + if (text.includes("wrong password") || text.includes("falsches passwort") || text.includes("incorrect password")) return "wrong_password"; + if (text.includes("missing volume") || text.includes("next volume") || text.includes("unexpected end of archive") || text.includes("missing parts")) return "missing_parts"; + if (text.includes("nicht gefunden") || text.includes("not found") || text.includes("no extractor")) return "no_extractor"; + if (isUnsupportedArchiveFormatError(text)) return "unsupported_format"; + if (text.includes("disk full") || text.includes("speicherplatz") || text.includes("no space left") || text.includes("not enough space")) return "disk_full"; + return "unknown"; +} + +export function shouldSerialRetryParallelFailures( + extractedCount: number, + failedCategories: ExtractErrorCategory[] +): boolean { + if (failedCategories.length === 0) { + return false; + } + if (extractedCount > 0) { + return true; + } + return failedCategories.every((category) => + category === "crc_error" + || category === "wrong_password" + || category === "unknown" + ); +} + +export function shouldFallbackLegacyRarToJvm( + archivePath: string, + configuredMode: ExtractBackendMode, + backendMode: ExtractBackendMode, + errorText: string, + bestPercent = 0, + platform = process.platform +): boolean { + if (configuredMode !== "auto" || backendMode !== "legacy") { + return false; + } + if (String(platform || "").toLowerCase() !== "win32") { + return false; + } + if (!isRarArchivePath(archivePath)) { + return false; + } + + const category = classifyExtractionError(errorText); + if (category === "aborted" || category === "timeout" || category === "no_extractor" || category === "missing_parts" || category === "disk_full") { + return false; + } + + const text = String(errorText || "").toLowerCase(); + if (text.includes("cannot create")) { + return false; + } + + return bestPercent > 0 || category === "unknown"; +} + +function isExtractAbortError(errorText: string): boolean { + const text = String(errorText || "").toLowerCase(); + return text.includes("aborted:extract") || text.includes("extract_aborted") || text.includes("noextractor:skipped"); +} + +function isNoExtractorError(errorText: string): boolean { + return String(errorText || "").toLowerCase().includes("nicht gefunden"); +} + +function isUnsupportedArchiveFormatError(errorText: string): boolean { + const text = String(errorText || "").toLowerCase(); + return text.includes("kein rar-archiv") + || text.includes("not a rar archive") + || text.includes("is not a rar archive") + || text.includes("is not archive") + || text.includes("unsupported"); +} + +function isUnsupportedExtractorSwitchError(errorText: string): boolean { + const text = String(errorText || "").toLowerCase(); + return text.includes("unknown switch") + || text.includes("unknown option") + || text.includes("invalid switch") + || text.includes("unsupported option") + || text.includes("unbekannter schalter") + || text.includes("falscher parameter"); +} + +function isJvmRuntimeMissingError(errorText: string): boolean { + const text = String(errorText || "").toLowerCase(); + return text.includes("could not find or load main class") + || text.includes("classnotfoundexception") + || text.includes("noclassdeffounderror") + || text.includes("unsatisfiedlinkerror") + || text.includes("enoent"); +} + +export function resolveExtractorBackendMode( + rawValue?: string | null, + isVitestEnv = Boolean(process.env.VITEST) +): ExtractBackendMode { + const defaultMode: ExtractBackendMode = isVitestEnv ? "legacy" : "auto"; + const raw = String(rawValue ?? defaultMode).trim().toLowerCase(); + if (raw === "legacy") { + return "legacy"; + } + if (raw === "jvm" || raw === "jbind" || raw === "7zjbinding") { + return "jvm"; + } + return "auto"; +} + +export function resolveExtractorBackendModeForArchive( + archivePath: string, + rawValue?: string | null, + isVitestEnv = Boolean(process.env.VITEST), + platform = process.platform +): ExtractBackendMode { + const requestedMode = resolveExtractorBackendMode(rawValue, isVitestEnv); + if (requestedMode !== "auto") { + return requestedMode; + } + if (String(platform || "").toLowerCase() === "win32" && isRarArchivePath(archivePath)) { + return "legacy"; + } + return requestedMode; +} + +function extractorBackendMode(): ExtractBackendMode { + return resolveExtractorBackendMode(process.env.RD_EXTRACT_BACKEND); +} + +function extractorBackendModeForArchive(archivePath: string): ExtractBackendMode { + return resolveExtractorBackendModeForArchive(archivePath, process.env.RD_EXTRACT_BACKEND); +} + +function isRarArchivePath(filePath: string): boolean { + return /\.(?:rar|r\d{2,3})$/i.test(String(filePath || "")); +} + +function is7zCommand(command: string): boolean { + const lower = command.toLowerCase(); + return lower.includes("7z") && !lower.includes("unrar") && !lower.includes("winrar"); +} + +function isRarNativeCommand(command: string): boolean { + const base = path.basename(String(command || "")).toLowerCase(); + return base === "unrar.exe" + || base === "unrar" + || base === "winrar.exe" + || base === "rar.exe" + || base === "rar"; +} + +function extractorCommandKind(command: string): ExtractorCommandKind { + if (isRarNativeCommand(command)) { + return "rar_native"; + } + if (is7zCommand(command)) { + return "seven_zip"; + } + return "other"; +} + +function isAbsoluteCommand(command: string): boolean { + return path.isAbsolute(command) + || command.includes("\\") + || command.includes("/"); +} + +function cachedExtractorFitsArchive(command: string, archivePath: string): boolean { + if (!archivePath) { + return true; + } + const kind = extractorCommandKind(command); + if (isRarArchivePath(archivePath)) { + return kind === "rar_native"; + } + return kind === "seven_zip"; +} + +export function orderExtractorCandidatesForArchive( + candidates: string[], + archivePath: string, + preferredCommand = "" +): string[] { + const unique = Array.from(new Set(candidates.filter(Boolean))); + const preferRarNative = isRarArchivePath(archivePath); + const rank = (command: string): number => { + const kind = extractorCommandKind(command); + if (preferRarNative) { + if (kind === "rar_native") return 0; + if (kind === "seven_zip") return 1; + return 2; + } + if (kind === "seven_zip") return 0; + if (kind === "rar_native") return 1; + return 2; + }; + + return unique + .map((command, index) => ({ command, index })) + .sort((left, right) => { + const rankDiff = rank(left.command) - rank(right.command); + if (rankDiff !== 0) { + return rankDiff; + } + const leftPreferred = preferredCommand && left.command === preferredCommand; + const rightPreferred = preferredCommand && right.command === preferredCommand; + if (leftPreferred !== rightPreferred) { + return leftPreferred ? -1 : 1; + } + return left.index - right.index; + }) + .map((entry) => entry.command); +} + +function nativeExtractorCandidates(archivePath = ""): string[] { + const programFiles = process.env.ProgramFiles || "C:\\Program Files"; + const programFilesX86 = process.env["ProgramFiles(x86)"] || "C:\\Program Files (x86)"; + const localAppData = process.env.LOCALAPPDATA || ""; + + const sevenZipInstalled = [ + process.env.RD_7Z_BIN || "", + path.join(programFiles, "7-Zip", "7z.exe"), + path.join(programFilesX86, "7-Zip", "7z.exe") + ]; + if (localAppData) { + sevenZipInstalled.push(path.join(localAppData, "Programs", "7-Zip", "7z.exe")); + } + + const winRarInstalled = [ + path.join(programFiles, "WinRAR", "Rar.exe"), + path.join(programFilesX86, "WinRAR", "Rar.exe"), + path.join(programFiles, "WinRAR", "UnRAR.exe"), + path.join(programFilesX86, "WinRAR", "UnRAR.exe") + ]; + + if (localAppData) { + winRarInstalled.push(path.join(localAppData, "Programs", "WinRAR", "Rar.exe")); + winRarInstalled.push(path.join(localAppData, "Programs", "WinRAR", "UnRAR.exe")); + } + + const ordered = resolvedExtractorCommand + ? [ + resolvedExtractorCommand, + ...sevenZipInstalled, + "7z.exe", + "7z", + "7za.exe", + "7za", + ...winRarInstalled, + "Rar.exe", + "rar", + "UnRAR.exe", + "unrar" + ] + : [ + ...sevenZipInstalled, + "7z.exe", + "7z", + "7za.exe", + "7za", + ...winRarInstalled, + "Rar.exe", + "rar", + "UnRAR.exe", + "unrar" + ]; + return orderExtractorCandidatesForArchive(ordered, archivePath, resolvedExtractorCommand || ""); +} + +function extractorProbeArgs(command: string): string[] { + return isRarNativeCommand(command) ? ["-?"] : ["?"]; +} + +async function resolveExtractorCommandInternal(archivePath = ""): Promise<string> { + if (resolvedExtractorCommand) { + return resolvedExtractorCommand; + } + if (resolveFailureReason) { + const age = Date.now() - resolveFailureAt; + if (age < EXTRACTOR_RETRY_AFTER_MS) { + throw new Error(resolveFailureReason); + } + resolveFailureReason = ""; + resolveFailureAt = 0; + } + + const candidates = nativeExtractorCandidates(archivePath); + for (const command of candidates) { + if (isAbsoluteCommand(command) && !fs.existsSync(command)) { + continue; + } + const probeArgs = extractorProbeArgs(command); + const probe = await runExtractCommand(command, probeArgs, undefined, undefined, EXTRACTOR_PROBE_TIMEOUT_MS); + if (probe.ok || (!probe.missingCommand && !probe.timedOut)) { + resolvedExtractorCommand = command; + resolveFailureReason = ""; + resolveFailureAt = 0; + logger.info(`Entpacker erkannt: ${command}`); + return command; + } + } + + resolveFailureReason = NO_EXTRACTOR_MESSAGE; + resolveFailureAt = Date.now(); + throw new Error(resolveFailureReason); +} + +async function resolveExtractorCommand(archivePath = ""): Promise<string> { + if (resolvedExtractorCommand && cachedExtractorFitsArchive(resolvedExtractorCommand, archivePath)) { + return resolvedExtractorCommand; + } + if (resolveExtractorCommandInFlight) { + return resolveExtractorCommandInFlight; + } + + const pending = resolveExtractorCommandInternal(archivePath); + resolveExtractorCommandInFlight = pending; + try { + return await pending; + } finally { + if (resolveExtractorCommandInFlight === pending) { + resolveExtractorCommandInFlight = null; + } + } +} + +async function findAlternativeExtractor(currentCommand: string, archivePath = ""): Promise<string | null> { + const candidates = nativeExtractorCandidates(archivePath); + const currentKind = extractorCommandKind(currentCommand); + const preferredKinds: ExtractorCommandKind[] = currentKind === "seven_zip" + ? ["rar_native"] + : isRarArchivePath(archivePath) + ? ["rar_native", "seven_zip"] + : ["seven_zip", "rar_native"]; + for (const kind of preferredKinds) { + for (const candidate of candidates) { + if (candidate === currentCommand) continue; + if (extractorCommandKind(candidate) !== kind) continue; + if (isAbsoluteCommand(candidate) && !fs.existsSync(candidate)) continue; + const probe = await runExtractCommand(candidate, extractorProbeArgs(candidate), undefined, undefined, EXTRACTOR_PROBE_TIMEOUT_MS); + if (probe.ok || (!probe.missingCommand && !probe.timedOut)) { + return candidate; + } + } + } + return null; +} + +function jvmMaxHeapArg(): string { + const totalGb = os.totalmem() / (1024 ** 3); + const heapGb = Math.max(1, Math.min(Math.floor(totalGb - 4), 16)); + return `-Xmx${heapGb}g`; +} + +function shouldUseExtractorPerformanceFlags(): boolean { + const raw = String(process.env.RD_EXTRACT_PERF_FLAGS || "").trim().toLowerCase(); + return raw !== "0" && raw !== "false" && raw !== "off" && raw !== "no"; +} + +function extractCpuBudgetFromPriority(priority?: string): number { + switch (priority) { + case "low": return 25; + case "middle": return 50; + default: return 80; + } +} + +function extractOsPriority(priority?: string): number { + switch (priority) { + case "high": return os.constants.priority.PRIORITY_NORMAL; + default: return os.constants.priority.PRIORITY_BELOW_NORMAL; + } +} + +function extractCpuBudgetPercent(priority?: string): number { + const envValue = Number(process.env.RD_EXTRACT_CPU_BUDGET_PERCENT ?? NaN); + if (Number.isFinite(envValue) && envValue >= 40 && envValue <= 95) { + return Math.floor(envValue); + } + return extractCpuBudgetFromPriority(priority); +} + +function extractorThreadSwitch(hybridMode = false, priority?: string): string { + if (hybridMode) { + const envValue = Number(process.env.RD_EXTRACT_THREADS ?? NaN); + if (Number.isFinite(envValue) && envValue >= 1 && envValue <= 32) { + return `-mt${Math.floor(envValue)}`; + } + const cpuCount = Math.max(1, os.cpus().length || 1); + const hybridThreads = Math.max(2, Math.min(12, Math.ceil(cpuCount * 0.75))); + return `-mt${hybridThreads}`; + } + const envValue = Number(process.env.RD_EXTRACT_THREADS ?? NaN); + if (Number.isFinite(envValue) && envValue >= 1 && envValue <= 32) { + return `-mt${Math.floor(envValue)}`; + } + const cpuCount = Math.max(1, os.cpus().length || 1); + const budgetPercent = extractCpuBudgetPercent(priority); + const budgetedThreads = Math.floor((cpuCount * budgetPercent) / 100); + const threadCount = Math.max(1, Math.min(16, Math.max(1, budgetedThreads))); + return `-mt${threadCount}`; +} + +function lowerExtractProcessPriority(childPid: number | undefined, cpuPriority?: string): void { + if (process.platform !== "win32") { + return; + } + const pid = Number(childPid || 0); + if (!Number.isFinite(pid) || pid <= 0) { + return; + } + try { + os.setPriority(pid, extractOsPriority(cpuPriority)); + } catch { + } +} + +function killProcessTree(child: { pid?: number; kill: () => void }): void { + const pid = Number(child.pid || 0); + if (!Number.isFinite(pid) || pid <= 0) { + try { + child.kill(); + } catch { + } + return; + } + + if (process.platform === "win32") { + try { + const killer = spawn("taskkill", ["/PID", String(pid), "/T", "/F"], { + windowsHide: true, + stdio: "ignore" + }); + killer.on("error", () => { + try { + child.kill(); + } catch { + } + }); + } catch { + try { + child.kill(); + } catch { + } + } + return; + } + + try { + child.kill(); + } catch { + } +} + +function parseProgressPercent(chunk: string): number | null { + const text = String(chunk || ""); + const matches = text.match(/(?:^|\D)(\d{1,3})%/g); + if (!matches) { + return null; + } + let latest: number | null = null; + for (const raw of matches) { + const digits = raw.match(/(\d{1,3})%/); + if (!digits) { + continue; + } + const value = Number(digits[1]); + if (Number.isFinite(value) && value >= 0 && value <= 100) { + latest = value; + } + } + return latest; +} + +function nextArchivePercent(previous: number, incoming: number): number { + const prev = Math.max(0, Math.min(100, Math.floor(Number(previous) || 0))); + const next = Math.max(0, Math.min(100, Math.floor(Number(incoming) || 0))); + return next >= prev ? next : prev; +} + +function runExtractCommand( + command: string, + args: string[], + onChunk?: (chunk: string) => void, + signal?: AbortSignal, + timeoutMs?: number +): Promise<ExtractSpawnResult> { + if (signal?.aborted) { + return Promise.resolve({ ok: false, missingCommand: false, aborted: true, timedOut: false, errorText: "aborted:extract" }); + } + + return new Promise((resolve) => { + let settled = false; + let output = ""; + const child = spawn(command, args, { windowsHide: true }); + lowerExtractProcessPriority(child.pid, currentExtractCpuPriority); + let timeoutId: NodeJS.Timeout | null = null; + let timedOutByWatchdog = false; + let abortedBySignal = false; + + const finish = (result: ExtractSpawnResult): void => { + if (settled) { + return; + } + settled = true; + if (timeoutId) { + clearTimeout(timeoutId); + timeoutId = null; + } + if (signal && onAbort) { + signal.removeEventListener("abort", onAbort); + } + resolve(result); + }; + + if (timeoutMs && timeoutMs > 0) { + timeoutId = setTimeout(() => { + timedOutByWatchdog = true; + killProcessTree(child); + finish({ + ok: false, + missingCommand: false, + aborted: false, + timedOut: true, + errorText: `Entpacken Timeout nach ${Math.ceil(timeoutMs / 1000)}s` + }); + }, timeoutMs); + } + + const onAbort = signal + ? (): void => { + abortedBySignal = true; + killProcessTree(child); + finish({ ok: false, missingCommand: false, aborted: true, timedOut: false, errorText: "aborted:extract" }); + } + : null; + if (signal && onAbort) { + signal.addEventListener("abort", onAbort, { once: true }); + } + + child.stdout.on("data", (chunk) => { + const text = String(chunk || ""); + output = appendLimited(output, text); + onChunk?.(text); + }); + child.stderr.on("data", (chunk) => { + const text = String(chunk || ""); + output = appendLimited(output, text); + onChunk?.(text); + }); + + child.on("error", (error) => { + const text = cleanErrorText(String(error)); + finish({ + ok: false, + missingCommand: text.toLowerCase().includes("enoent"), + aborted: false, + timedOut: false, + errorText: text + }); + }); + + child.on("close", (code) => { + if (abortedBySignal) { + finish({ ok: false, missingCommand: false, aborted: true, timedOut: false, errorText: "aborted:extract" }); + return; + } + if (timedOutByWatchdog) { + finish({ + ok: false, + missingCommand: false, + aborted: false, + timedOut: true, + errorText: `Entpacken Timeout nach ${Math.ceil((timeoutMs || 0) / 1000)}s` + }); + return; + } + if (code === 0) { + finish({ ok: true, missingCommand: false, aborted: false, timedOut: false, errorText: "" }); + return; + } + if (code === 1) { + const lowered = output.toLowerCase(); + const warningOnly = !lowered.includes("crc failed") + && !lowered.includes("checksum error") + && !lowered.includes("wrong password") + && !lowered.includes("cannot open") + && !lowered.includes("fatal error") + && !lowered.includes("unexpected end of archive") + && !lowered.includes("error:"); + if (warningOnly) { + finish({ ok: true, missingCommand: false, aborted: false, timedOut: false, errorText: "" }); + return; + } + } + const cleaned = cleanErrorText(output); + finish({ + ok: false, + missingCommand: false, + aborted: false, + timedOut: false, + errorText: cleaned || `Exit Code ${String(code ?? "?")}` + }); + }); + }); +} + +let cachedJvmLayout: JvmExtractorLayout | null | undefined; +let cachedJvmLayoutNullSince = 0; +const JVM_LAYOUT_NULL_TTL_MS = 5 * 60 * 1000; + +function resolveJavaCommandCandidates(): string[] { + const programFiles = process.env.ProgramFiles || "C:\\Program Files"; + const programFilesX86 = process.env["ProgramFiles(x86)"] || "C:\\Program Files (x86)"; + const localAppData = process.env.LOCALAPPDATA || ""; + + const candidates = [ + process.env.RD_JAVA_BIN || "", + path.join(programFiles, "JDownloader", "jre", "bin", "java.exe"), + path.join(programFilesX86, "JDownloader", "jre", "bin", "java.exe"), + localAppData ? path.join(localAppData, "JDownloader", "jre", "bin", "java.exe") : "", + "java" + ].filter(Boolean); + + return Array.from(new Set(candidates)); +} + +function resolveJvmExtractorRootCandidates(): string[] { + const fromEnv = String(process.env.RD_EXTRACTOR_JVM_DIR || "").trim(); + const electronResourcesPath = (process as NodeJS.Process & { resourcesPath?: string }).resourcesPath || ""; + const candidates = [ + fromEnv, + electronResourcesPath ? path.join(electronResourcesPath, "app.asar.unpacked", "resources", "extractor-jvm") : "", + path.join(process.cwd(), "resources", "extractor-jvm"), + path.join(process.cwd(), "build", "resources", "extractor-jvm"), + path.join(__dirname, "..", "..", "..", "resources", "extractor-jvm"), + electronResourcesPath ? path.join(electronResourcesPath, "extractor-jvm") : "" + ].filter(Boolean); + return Array.from(new Set(candidates)); +} + +function resolveJvmExtractorLayout(): JvmExtractorLayout | null { + if (cachedJvmLayout !== undefined) { + if (cachedJvmLayout === null && Date.now() - cachedJvmLayoutNullSince > JVM_LAYOUT_NULL_TTL_MS) { + cachedJvmLayout = undefined; + } else { + return cachedJvmLayout; + } + } + const javaCandidates = resolveJavaCommandCandidates(); + const javaCommand = javaCandidates.find((candidate) => { + if (!candidate) { + return false; + } + if (!isAbsoluteCommand(candidate)) { + return true; + } + return fs.existsSync(candidate); + }) || ""; + + if (!javaCommand) { + cachedJvmLayout = null; + cachedJvmLayoutNullSince = Date.now(); + return null; + } + + for (const rootDir of resolveJvmExtractorRootCandidates()) { + const classesDir = path.join(rootDir, JVM_EXTRACTOR_CLASSES_SUBDIR); + if (!fs.existsSync(classesDir)) { + continue; + } + const libs = JVM_EXTRACTOR_REQUIRED_LIBS.map((name) => path.join(rootDir, JVM_EXTRACTOR_LIB_SUBDIR, name)); + if (libs.some((filePath) => !fs.existsSync(filePath))) { + continue; + } + const classPath = [classesDir, ...libs].join(path.delimiter); + const layout = { javaCommand, classPath, rootDir }; + cachedJvmLayout = layout; + return layout; + } + + cachedJvmLayout = null; + cachedJvmLayoutNullSince = Date.now(); + return null; +} + +function parseJvmLine( + line: string, + onArchiveProgress: ((percent: number) => void) | undefined, + state: { bestPercent: number; usedPassword: string; backend: string; reportedError: string } +): void { + const trimmed = String(line || "").trim(); + if (!trimmed) { + return; + } + + if (trimmed.startsWith("RD_PROGRESS ")) { + const parsed = parseProgressPercent(trimmed); + if (parsed !== null) { + const next = nextArchivePercent(state.bestPercent, parsed); + if (next !== state.bestPercent) { + state.bestPercent = next; + onArchiveProgress?.(next); + } + } + return; + } + + if (trimmed.startsWith("RD_PASSWORD ")) { + const encoded = trimmed.slice("RD_PASSWORD ".length).trim(); + try { + state.usedPassword = Buffer.from(encoded, "base64").toString("utf8"); + } catch { + state.usedPassword = ""; + } + return; + } + + if (trimmed.startsWith("RD_BACKEND ")) { + state.backend = trimmed.slice("RD_BACKEND ".length).trim(); + return; + } + + if (trimmed.startsWith("RD_ERROR ")) { + state.reportedError = trimmed.slice("RD_ERROR ".length).trim(); + } +} + +let daemonProcess: ChildProcess | null = null; +let daemonReady = false; +let daemonBusy = false; +let daemonCurrentRequest: DaemonRequest | null = null; +let daemonStdoutBuffer = ""; +let daemonStderrBuffer = ""; +let daemonOutput = ""; +let daemonTimeoutId: NodeJS.Timeout | null = null; +let daemonAbortHandler: (() => void) | null = null; +let daemonLayout: JvmExtractorLayout | null = null; + +export function shutdownDaemon(): void { + if (daemonProcess) { + try { daemonProcess.stdin?.end(); } catch { } + try { killProcessTree(daemonProcess); } catch { } + daemonProcess = null; + } + daemonReady = false; + daemonBusy = false; + daemonCurrentRequest = null; + daemonStdoutBuffer = ""; + daemonStderrBuffer = ""; + daemonOutput = ""; + if (daemonTimeoutId) { clearTimeout(daemonTimeoutId); daemonTimeoutId = null; } + if (daemonAbortHandler) { daemonAbortHandler = null; } + daemonLayout = null; +} + +function finishDaemonRequest(result: JvmExtractResult): void { + const req = daemonCurrentRequest; + if (!req) return; + daemonCurrentRequest = null; + daemonBusy = false; + daemonStdoutBuffer = ""; + daemonStderrBuffer = ""; + daemonOutput = ""; + if (daemonTimeoutId) { clearTimeout(daemonTimeoutId); daemonTimeoutId = null; } + if (req.signal && daemonAbortHandler) { + req.signal.removeEventListener("abort", daemonAbortHandler); + daemonAbortHandler = null; + } + req.resolve(result); +} + +function flushDaemonParseBuffers(req: DaemonRequest | null): void { + if (!req) { + return; + } + if (daemonStdoutBuffer.trim()) { + parseJvmLine(daemonStdoutBuffer, req.onArchiveProgress, req.parseState); + daemonStdoutBuffer = ""; + } + if (daemonStderrBuffer.trim()) { + parseJvmLine(daemonStderrBuffer, req.onArchiveProgress, req.parseState); + daemonStderrBuffer = ""; + } +} + +function handleDaemonLine(line: string): void { + const trimmed = String(line || "").trim(); + if (!trimmed) return; + + if (trimmed === "RD_DAEMON_READY") { + daemonReady = true; + logger.info("JVM Daemon bereit (persistent)"); + return; + } + + if (trimmed.startsWith("RD_REQUEST_DONE ")) { + const code = parseInt(trimmed.slice("RD_REQUEST_DONE ".length).trim(), 10); + const req = daemonCurrentRequest; + if (!req) return; + const finalize = (): void => { + if (daemonCurrentRequest !== req) { + return; + } + flushDaemonParseBuffers(req); + const elapsedMs = Date.now() - req.startedAt; + logger.info( + `JVM Daemon Request Ende: archive=${req.archiveName}, code=${code}, ms=${elapsedMs}, pwCandidates=${req.passwordCount}, ` + + `bestPercent=${req.parseState.bestPercent}, backend=${req.parseState.backend || "unknown"}, usedPassword=${req.parseState.usedPassword ? "yes" : "no"}` + ); + + if (code === 0) { + req.onArchiveProgress?.(100); + finishDaemonRequest({ + ok: true, missingCommand: false, missingRuntime: false, + aborted: false, timedOut: false, errorText: "", + usedPassword: req.parseState.usedPassword, backend: req.parseState.backend + }); + return; + } + + const message = cleanErrorText(req.parseState.reportedError || daemonOutput) || `Exit Code ${code}`; + finishDaemonRequest({ + ok: false, missingCommand: false, missingRuntime: isJvmRuntimeMissingError(message), + aborted: false, timedOut: false, errorText: message, + usedPassword: req.parseState.usedPassword, backend: req.parseState.backend + }); + }; + + if (code !== 0 && !req.parseState.reportedError) { + setTimeout(finalize, 40); + return; + } + + finalize(); + return; + } + + if (daemonCurrentRequest) { + parseJvmLine(trimmed, daemonCurrentRequest.onArchiveProgress, daemonCurrentRequest.parseState); + } +} + +function startDaemon(layout: JvmExtractorLayout): boolean { + if (daemonProcess && daemonReady) return true; + if (daemonProcess) return false; + shutdownDaemon(); + + const jvmTmpDir = path.join(os.tmpdir(), `rd-extract-daemon-${crypto.randomUUID()}`); + fs.mkdirSync(jvmTmpDir, { recursive: true }); + + const args = [ + "-Dfile.encoding=UTF-8", + `-Djava.io.tmpdir=${jvmTmpDir}`, + "-Xms1g", + jvmMaxHeapArg(), + "-XX:+UseG1GC", + "-XX:MaxGCPauseMillis=50", + "-cp", + layout.classPath, + JVM_EXTRACTOR_MAIN_CLASS, + "--daemon" + ]; + + try { + const child = spawn(layout.javaCommand, args, { + windowsHide: true, + stdio: ["pipe", "pipe", "pipe"] + }); + lowerExtractProcessPriority(child.pid, currentExtractCpuPriority); + daemonProcess = child; + daemonLayout = layout; + + child.stdout!.on("data", (chunk) => { + const raw = String(chunk || ""); + daemonOutput = appendLimited(daemonOutput, raw); + daemonStdoutBuffer += raw; + const lines = daemonStdoutBuffer.split(/\r?\n/); + daemonStdoutBuffer = lines.pop() || ""; + for (const line of lines) { + handleDaemonLine(line); + } + }); + + child.stderr!.on("data", (chunk) => { + const raw = String(chunk || ""); + daemonOutput = appendLimited(daemonOutput, raw); + daemonStderrBuffer += raw; + const lines = daemonStderrBuffer.split(/\r?\n/); + daemonStderrBuffer = lines.pop() || ""; + for (const line of lines) { + if (daemonCurrentRequest) { + parseJvmLine(line, daemonCurrentRequest.onArchiveProgress, daemonCurrentRequest.parseState); + } + } + }); + + child.on("error", () => { + if (daemonCurrentRequest) { + finishDaemonRequest({ + ok: false, missingCommand: true, missingRuntime: true, + aborted: false, timedOut: false, errorText: "Daemon process error", + usedPassword: "", backend: "" + }); + } + shutdownDaemon(); + }); + + child.on("close", () => { + if (daemonCurrentRequest) { + const req = daemonCurrentRequest; + finishDaemonRequest({ + ok: false, missingCommand: false, missingRuntime: false, + aborted: false, timedOut: false, + errorText: cleanErrorText(req.parseState.reportedError || daemonOutput) || "Daemon process exited unexpectedly", + usedPassword: req.parseState.usedPassword, backend: req.parseState.backend + }); + } + fs.rm(jvmTmpDir, { recursive: true, force: true }, () => {}); + daemonProcess = null; + daemonReady = false; + daemonBusy = false; + daemonLayout = null; + }); + + logger.info(`JVM Daemon gestartet (PID ${child.pid})`); + return true; + } catch (error) { + logger.warn(`JVM Daemon Start fehlgeschlagen: ${String(error)}`); + return false; + } +} + +function isDaemonAvailable(layout: JvmExtractorLayout): boolean { + if (!daemonProcess || !daemonReady) { + startDaemon(layout); + } + return Boolean(daemonProcess && daemonReady && !daemonBusy); +} + +function waitForDaemonReady(maxWaitMs: number, signal?: AbortSignal): Promise<boolean> { + return new Promise((resolve) => { + const start = Date.now(); + const check = () => { + if (signal?.aborted) { resolve(false); return; } + if (daemonProcess && daemonReady && !daemonBusy) { resolve(true); return; } + if (!daemonProcess) { resolve(false); return; } + if (Date.now() - start >= maxWaitMs) { resolve(false); return; } + setTimeout(check, 50); + }; + check(); + }); +} + +function sendDaemonRequest( + archivePath: string, + targetDir: string, + conflictMode: ConflictMode, + passwordCandidates: string[], + onArchiveProgress?: (percent: number) => void, + signal?: AbortSignal, + timeoutMs?: number +): Promise<JvmExtractResult> { + return new Promise((resolve) => { + const mode = effectiveConflictMode(conflictMode); + const parseState = { bestPercent: 0, usedPassword: "", backend: "", reportedError: "" }; + const archiveName = path.basename(archivePath); + + daemonBusy = true; + daemonOutput = ""; + daemonCurrentRequest = { + resolve, + onArchiveProgress, + signal, + timeoutMs, + parseState, + archiveName, + startedAt: Date.now(), + passwordCount: passwordCandidates.length + }; + logger.info(`JVM Daemon Request Start: archive=${archiveName}, pwCandidates=${passwordCandidates.length}, timeoutMs=${timeoutMs || 0}, conflict=${mode}`); + + if (timeoutMs && timeoutMs > 0) { + daemonTimeoutId = setTimeout(() => { + const req = daemonCurrentRequest; + if (req) { + finishDaemonRequest({ + ok: false, missingCommand: false, missingRuntime: false, + aborted: false, timedOut: true, + errorText: `Entpacken Timeout nach ${Math.ceil(timeoutMs / 1000)}s`, + usedPassword: parseState.usedPassword, backend: parseState.backend + }); + } + shutdownDaemon(); + }, timeoutMs); + } + + if (signal) { + daemonAbortHandler = () => { + const req = daemonCurrentRequest; + if (req) { + finishDaemonRequest({ + ok: false, missingCommand: false, missingRuntime: false, + aborted: true, timedOut: false, errorText: "aborted:extract", + usedPassword: parseState.usedPassword, backend: parseState.backend + }); + } + shutdownDaemon(); + }; + signal.addEventListener("abort", daemonAbortHandler, { once: true }); + } + + const jsonRequest = JSON.stringify({ + archive: archivePath, + target: targetDir, + conflict: mode, + backend: "auto", + passwords: passwordCandidates + }); + + try { + daemonProcess!.stdin!.write(jsonRequest + "\n"); + } catch (error) { + finishDaemonRequest({ + ok: false, missingCommand: false, missingRuntime: false, + aborted: false, timedOut: false, + errorText: `Daemon stdin write failed: ${String(error)}`, + usedPassword: "", backend: "" + }); + shutdownDaemon(); + } + }); +} + +async function runJvmExtractCommand( + layout: JvmExtractorLayout, + archivePath: string, + targetDir: string, + conflictMode: ConflictMode, + passwordCandidates: string[], + onArchiveProgress?: (percent: number) => void, + signal?: AbortSignal, + timeoutMs?: number +): Promise<JvmExtractResult> { + if (signal?.aborted) { + return Promise.resolve({ + ok: false, missingCommand: false, missingRuntime: false, + aborted: true, timedOut: false, errorText: "aborted:extract", + usedPassword: "", backend: "" + }); + } + + if (isDaemonAvailable(layout)) { + lowerExtractProcessPriority(daemonProcess?.pid, currentExtractCpuPriority); + logger.info(`JVM Daemon: Sofort verfügbar, sende Request für ${path.basename(archivePath)} (pwCandidates=${passwordCandidates.length})`); + return sendDaemonRequest(archivePath, targetDir, conflictMode, passwordCandidates, onArchiveProgress, signal, timeoutMs); + } + + if (daemonProcess) { + const reason = !daemonReady ? "booting" : "busy"; + const waitStartedAt = Date.now(); + logger.info(`JVM Daemon: Warte auf ${reason} Daemon für ${path.basename(archivePath)}...`); + const ready = await waitForDaemonReady(15_000, signal); + const waitedMs = Date.now() - waitStartedAt; + if (ready) { + lowerExtractProcessPriority(daemonProcess?.pid, currentExtractCpuPriority); + logger.info(`JVM Daemon: Bereit nach ${waitedMs}ms — sende Request für ${path.basename(archivePath)}`); + return sendDaemonRequest(archivePath, targetDir, conflictMode, passwordCandidates, onArchiveProgress, signal, timeoutMs); + } + logger.warn(`JVM Daemon: Timeout nach ${waitedMs}ms beim Warten — Fallback auf neuen Prozess für ${path.basename(archivePath)}`); + } + + logger.info(`JVM Spawn: Neuer Prozess für ${path.basename(archivePath)}`); + + const mode = effectiveConflictMode(conflictMode); + const jvmTmpDir = path.join(os.tmpdir(), `rd-extract-${crypto.randomUUID()}`); + fs.mkdirSync(jvmTmpDir, { recursive: true }); + const args = [ + "-Dfile.encoding=UTF-8", + `-Djava.io.tmpdir=${jvmTmpDir}`, + "-Xms1g", + jvmMaxHeapArg(), + "-XX:+UseG1GC", + "-XX:MaxGCPauseMillis=50", + "-cp", + layout.classPath, + JVM_EXTRACTOR_MAIN_CLASS, + "--archive", + archivePath, + "--target", + targetDir, + "--conflict", + mode, + "--backend", + "auto" + ]; + for (const password of passwordCandidates) { + args.push("--password", password); + } + + return new Promise((resolve) => { + let settled = false; + let output = ""; + let timeoutId: NodeJS.Timeout | null = null; + let timedOutByWatchdog = false; + let abortedBySignal = false; + let onAbort: (() => void) | null = null; + const parseState = { bestPercent: 0, usedPassword: "", backend: "", reportedError: "" }; + let stdoutBuffer = ""; + let stderrBuffer = ""; + + const child = spawn(layout.javaCommand, args, { windowsHide: true }); + lowerExtractProcessPriority(child.pid, currentExtractCpuPriority); + + const flushLines = (rawChunk: string, fromStdErr = false): void => { + if (!rawChunk) { + return; + } + output = appendLimited(output, rawChunk); + const nextBuffer = `${fromStdErr ? stderrBuffer : stdoutBuffer}${rawChunk}`; + const lines = nextBuffer.split(/\r?\n/); + const keep = lines.pop() || ""; + for (const line of lines) { + parseJvmLine(line, onArchiveProgress, parseState); + } + if (fromStdErr) { + stderrBuffer = keep; + } else { + stdoutBuffer = keep; + } + }; + + const cleanupTmpDir = (): void => { + fs.rm(jvmTmpDir, { recursive: true, force: true }, () => {}); + }; + + const finish = (result: JvmExtractResult): void => { + if (settled) { + return; + } + settled = true; + if (timeoutId) { + clearTimeout(timeoutId); + timeoutId = null; + } + if (signal && onAbort) { + signal.removeEventListener("abort", onAbort); + } + cleanupTmpDir(); + resolve(result); + }; + + if (timeoutMs && timeoutMs > 0) { + timeoutId = setTimeout(() => { + timedOutByWatchdog = true; + killProcessTree(child); + finish({ + ok: false, missingCommand: false, missingRuntime: false, + aborted: false, timedOut: true, + errorText: `Entpacken Timeout nach ${Math.ceil(timeoutMs / 1000)}s`, + usedPassword: parseState.usedPassword, backend: parseState.backend + }); + }, timeoutMs); + } + + onAbort = signal + ? (): void => { + abortedBySignal = true; + killProcessTree(child); + finish({ + ok: false, missingCommand: false, missingRuntime: false, + aborted: true, timedOut: false, errorText: "aborted:extract", + usedPassword: parseState.usedPassword, backend: parseState.backend + }); + } + : null; + + if (signal && onAbort) { + signal.addEventListener("abort", onAbort, { once: true }); + } + + child.stdout.on("data", (chunk) => { + flushLines(String(chunk || ""), false); + }); + child.stderr.on("data", (chunk) => { + flushLines(String(chunk || ""), true); + }); + + child.on("error", (error) => { + const text = cleanErrorText(String(error)); + finish({ + ok: false, missingCommand: text.toLowerCase().includes("enoent"), + missingRuntime: true, aborted: false, timedOut: false, + errorText: text, usedPassword: parseState.usedPassword, backend: parseState.backend + }); + }); + + child.on("close", (code) => { + parseJvmLine(stdoutBuffer, onArchiveProgress, parseState); + parseJvmLine(stderrBuffer, onArchiveProgress, parseState); + + if (abortedBySignal) { + finish({ + ok: false, missingCommand: false, missingRuntime: false, + aborted: true, timedOut: false, errorText: "aborted:extract", + usedPassword: parseState.usedPassword, backend: parseState.backend + }); + return; + } + if (timedOutByWatchdog) { + finish({ + ok: false, missingCommand: false, missingRuntime: false, + aborted: false, timedOut: true, + errorText: `Entpacken Timeout nach ${Math.ceil((timeoutMs || 0) / 1000)}s`, + usedPassword: parseState.usedPassword, backend: parseState.backend + }); + return; + } + + const message = cleanErrorText(parseState.reportedError || output) || `Exit Code ${String(code ?? "?")}`; + if (code === 0) { + onArchiveProgress?.(100); + finish({ + ok: true, missingCommand: false, missingRuntime: false, + aborted: false, timedOut: false, errorText: "", + usedPassword: parseState.usedPassword, backend: parseState.backend + }); + return; + } + + finish({ + ok: false, missingCommand: false, + missingRuntime: isJvmRuntimeMissingError(message), + aborted: false, timedOut: false, errorText: message, + usedPassword: parseState.usedPassword, backend: parseState.backend + }); + }); + }); +} + +export function buildExternalExtractArgs( + command: string, + archivePath: string, + targetDir: string, + conflictMode: ConflictMode, + password = "", + usePerformanceFlags = true, + hybridMode = false, + flatMode = false +): string[] { + const mode = effectiveConflictMode(conflictMode); + if (isRarNativeCommand(command)) { + const extractCmd = flatMode ? "e" : "x"; + const overwrite = mode === "overwrite" ? "-o+" : mode === "rename" ? "-or" : "-o-"; + const pass = password ? `-p${password}` : "-p-"; + const perfArgs = usePerformanceFlags && shouldUseExtractorPerformanceFlags() + ? ["-idc", extractorThreadSwitch(hybridMode, currentExtractCpuPriority)] + : []; + return [extractCmd, overwrite, pass, "-y", ...perfArgs, archivePath, `${targetDir}${path.sep}`]; + } + + const overwrite = mode === "overwrite" ? "-aoa" : mode === "rename" ? "-aou" : "-aos"; + const pass = password ? `-p${password}` : "-p"; + return ["x", "-y", overwrite, pass, archivePath, `-o${targetDir}`]; +} + +const extractRetryDelay = (ms: number): Promise<void> => new Promise((resolve) => setTimeout(resolve, ms)); + +async function runExternalExtractInner( + command: string, + archivePath: string, + targetDir: string, + conflictMode: ConflictMode, + passwordCandidates: string[], + onArchiveProgress: ((percent: number) => void) | undefined, + signal: AbortSignal | undefined, + timeoutMs: number, + hybridMode = false, + onPasswordAttempt?: (attempt: number, total: number) => void, + forceFlatMode = false, + flatModeResult?: { needed: boolean }, + onLog?: ExtractOptions["onLog"] +): Promise<string> { + const passwords = passwordCandidates; + let lastError = ""; + const extractorName = path.basename(command).replace(/\.exe$/i, "") || command; + + const emptyPasswordCount = passwords.filter((candidate) => candidate === "").length; + onLog?.("INFO", `Legacy-Extractor Start: archive=${path.basename(archivePath)}, extractor=${extractorName}, passwordCount=${passwords.length}, forceFlatMode=${forceFlatMode}, targetDir=${targetDir}`); + logger.info(`Legacy-Extractor (${extractorName}): ${path.basename(archivePath)}, passwordCount=${passwords.length}, redacted=true, emptyCandidates=${emptyPasswordCount}${forceFlatMode ? " (flat-mode cached)" : ""}`); + + let announcedStart = false; + let bestPercent = 0; + let passwordAttempt = 0; + let usePerformanceFlags = externalExtractorSupportsPerfFlags && shouldUseExtractorPerformanceFlags(); + const summarizeResultError = (errorText: string): string => cleanErrorText(errorText); + let createErrorText = ""; + let createErrorPassword = ""; + + if (forceFlatMode) { + logger.info(`Flat-Modus direkt (gespeichert vom vorherigen Archiv): ${path.basename(archivePath)}`); + onLog?.("INFO", `Flat-Modus direkt (gespeichert vom vorherigen Archiv): ${path.basename(archivePath)}`); + for (const password of passwords) { + if (signal?.aborted) throw new Error("aborted:extract"); + passwordAttempt += 1; + onLog?.("INFO", `Flach-Extraktion Versuch ${passwordAttempt}/${passwords.length}: archive=${path.basename(archivePath)}, password=<redacted>`); + logger.info(`Flach-Extraktion Versuch ${passwordAttempt}/${passwords.length} für ${path.basename(archivePath)} (password=<redacted>)`); + const args = buildExternalExtractArgs(command, archivePath, targetDir, conflictMode, password, usePerformanceFlags, hybridMode, true); + const result = await runExtractCommand(command, args, (chunk) => { + const parsed = parseProgressPercent(chunk); + if (parsed === null) return; + const next = nextArchivePercent(bestPercent, parsed); + if (next !== bestPercent) { bestPercent = next; onArchiveProgress?.(bestPercent); } + }, signal, timeoutMs); + logger.info(`Flach-Extraktion Versuch ${passwordAttempt}/${passwords.length}: ok=${result.ok}, bestPercent=${bestPercent}`); + onLog?.("INFO", `Flach-Extraktion Ergebnis ${passwordAttempt}/${passwords.length}: archive=${path.basename(archivePath)}, ok=${result.ok}, timedOut=${result.timedOut}, missingCommand=${result.missingCommand}, bestPercent=${bestPercent}`); + if (result.ok) { if (flatModeResult) flatModeResult.needed = true; onArchiveProgress?.(100); return password; } + if (result.aborted) throw new Error("aborted:extract"); + if (result.timedOut || result.missingCommand) break; + lastError = result.errorText; + } + throw withExtractionErrorHints(new Error(lastError || "Entpacken fehlgeschlagen (flat-mode)"), { legacyBestPercent: bestPercent, legacyExtractor: extractorName }); + } + + for (const password of passwords) { + if (signal?.aborted) { + throw new Error("aborted:extract"); + } + if (!announcedStart) { + announcedStart = true; + onArchiveProgress?.(0); + } + passwordAttempt += 1; + const attemptStartedAt = Date.now(); + onLog?.("INFO", `Legacy-Passwort-Versuch ${passwordAttempt}/${passwords.length}: archive=${path.basename(archivePath)}, password=<redacted>`); + logger.info(`Legacy-Passwort-Versuch ${passwordAttempt}/${passwords.length} für ${path.basename(archivePath)} (password=<redacted>)`); + if (passwords.length > 1) { + onPasswordAttempt?.(passwordAttempt, passwords.length); + } + let args = buildExternalExtractArgs(command, archivePath, targetDir, conflictMode, password, usePerformanceFlags, hybridMode); + let result = await runExtractCommand(command, args, (chunk) => { + const parsed = parseProgressPercent(chunk); + if (parsed === null) { + return; + } + const next = nextArchivePercent(bestPercent, parsed); + if (next !== bestPercent) { + bestPercent = next; + onArchiveProgress?.(bestPercent); + } + }, signal, timeoutMs); + + if (!result.ok && usePerformanceFlags && isUnsupportedExtractorSwitchError(result.errorText)) { + usePerformanceFlags = false; + externalExtractorSupportsPerfFlags = false; + onLog?.("WARN", `Entpacker ohne Performance-Flags fortgesetzt: ${path.basename(archivePath)}`); + logger.warn(`Entpacker ohne Performance-Flags fortgesetzt: ${path.basename(archivePath)}`); + args = buildExternalExtractArgs(command, archivePath, targetDir, conflictMode, password, false, hybridMode); + result = await runExtractCommand(command, args, (chunk) => { + const parsed = parseProgressPercent(chunk); + if (parsed === null) { + return; + } + const next = nextArchivePercent(bestPercent, parsed); + if (next !== bestPercent) { + bestPercent = next; + onArchiveProgress?.(bestPercent); + } + }, signal, timeoutMs); + } + + logger.info( + `Legacy-Passwort-Versuch Ergebnis: archive=${path.basename(archivePath)}, attempt=${passwordAttempt}/${passwords.length}, ` + + `ms=${Date.now() - attemptStartedAt}, ok=${result.ok}, timedOut=${result.timedOut}, missingCommand=${result.missingCommand}, bestPercent=${bestPercent}` + ); + onLog?.("INFO", `Legacy-Passwort-Versuch Ergebnis: archive=${path.basename(archivePath)}, attempt=${passwordAttempt}/${passwords.length}, ms=${Date.now() - attemptStartedAt}, ok=${result.ok}, timedOut=${result.timedOut}, missingCommand=${result.missingCommand}, bestPercent=${bestPercent}`); + if (!result.ok) { + const errorSummary = summarizeResultError(result.errorText); + if (errorSummary) { + logger.info(`Legacy-Passwort-Versuch Fehlertext: archive=${path.basename(archivePath)}, attempt=${passwordAttempt}/${passwords.length}, extractor=${extractorName}, error=${errorSummary}`); + onLog?.("INFO", `Legacy-Passwort-Versuch Fehlertext: archive=${path.basename(archivePath)}, attempt=${passwordAttempt}/${passwords.length}, extractor=${extractorName}, error=${errorSummary}`); + } + } + + if (result.ok) { + onArchiveProgress?.(100); + return password; + } + + if (!createErrorText && result.errorText.includes("Cannot create")) { + createErrorText = result.errorText; + createErrorPassword = password; + logger.warn(`Entpack-Pfadfehler gemerkt: archive=${path.basename(archivePath)}, attempt=${passwordAttempt}/${passwords.length}, extractor=${extractorName}, password=<redacted>`); + onLog?.("WARN", `Entpack-Pfadfehler gemerkt: archive=${path.basename(archivePath)}, attempt=${passwordAttempt}/${passwords.length}, extractor=${extractorName}, password=<redacted>`); + } + + if (result.aborted) { + throw new Error("aborted:extract"); + } + + if (result.timedOut) { + lastError = result.errorText; + break; + } + + if (result.missingCommand) { + resolvedExtractorCommand = null; + resolveFailureReason = NO_EXTRACTOR_MESSAGE; + resolveFailureAt = Date.now(); + throw withExtractionErrorHints(new Error(NO_EXTRACTOR_MESSAGE), { legacyBestPercent: bestPercent, legacyExtractor: extractorName }); + } + + lastError = result.errorText; + } + + const pathCreateError = createErrorText || (lastError.includes("Cannot create") ? lastError : ""); + if (pathCreateError) { + const flatPasswords = createErrorPassword + ? prioritizePassword(passwords, createErrorPassword) + : passwords; + logger.warn(`Entpack-Pfadfehler: Wiederholung mit flachem Modus: ${path.basename(archivePath)}`); + onLog?.("WARN", `Entpack-Pfadfehler: Wiederholung mit flachem Modus: ${path.basename(archivePath)}`); + bestPercent = 0; + passwordAttempt = 0; + lastError = pathCreateError; + for (const password of flatPasswords) { + if (signal?.aborted) throw new Error("aborted:extract"); + passwordAttempt += 1; + logger.info(`Flach-Extraktion Versuch ${passwordAttempt}/${passwords.length} für ${path.basename(archivePath)} (password=<redacted>)`); + onLog?.("INFO", `Flach-Extraktion Versuch ${passwordAttempt}/${flatPasswords.length}: archive=${path.basename(archivePath)}, password=<redacted>`); + const args = buildExternalExtractArgs(command, archivePath, targetDir, conflictMode, password, usePerformanceFlags, hybridMode, true); + const result = await runExtractCommand(command, args, (chunk) => { + const parsed = parseProgressPercent(chunk); + if (parsed === null) return; + const next = nextArchivePercent(bestPercent, parsed); + if (next !== bestPercent) { bestPercent = next; onArchiveProgress?.(bestPercent); } + }, signal, timeoutMs); + logger.info(`Flach-Extraktion Versuch ${passwordAttempt}/${passwords.length}: ok=${result.ok}, bestPercent=${bestPercent}`); + onLog?.("INFO", `Flach-Extraktion Ergebnis ${passwordAttempt}/${flatPasswords.length}: archive=${path.basename(archivePath)}, ok=${result.ok}, timedOut=${result.timedOut}, missingCommand=${result.missingCommand}, bestPercent=${bestPercent}`); + if (result.ok) { if (flatModeResult) flatModeResult.needed = true; onArchiveProgress?.(100); return password; } + if (result.aborted) throw new Error("aborted:extract"); + if (result.timedOut || result.missingCommand) break; + lastError = result.errorText; + } + } + + throw withExtractionErrorHints(new Error(lastError || "Entpacken fehlgeschlagen"), { legacyBestPercent: bestPercent, legacyExtractor: extractorName }); +} + +async function runExternalExtract( + archivePath: string, + targetDir: string, + conflictMode: ConflictMode, + passwordCandidates: string[], + onArchiveProgress?: (percent: number) => void, + signal?: AbortSignal, + hybridMode = false, + onPasswordAttempt?: (attempt: number, total: number) => void, + forceFlatMode = false, + flatModeResult?: { needed: boolean }, + onLog?: ExtractOptions["onLog"] +): Promise<string> { + const timeoutMs = await computeExtractTimeoutMs(archivePath); + const configuredBackendMode = extractorBackendMode(); + const backendMode = extractorBackendModeForArchive(archivePath); + const archiveName = path.basename(archivePath); + const totalStartedAt = Date.now(); + let jvmFailureReason = ""; + let jvmCodecError = false; + let fallbackFromJvm = false; + logger.info(`Extract-Backend Start: archive=${archiveName}, mode=${backendMode}, configuredMode=${configuredBackendMode}, pwCandidates=${passwordCandidates.length}, timeoutMs=${timeoutMs}, hybrid=${hybridMode}`); + onLog?.("INFO", `Extract-Backend Start: archive=${archiveName}, mode=${backendMode}, configuredMode=${configuredBackendMode}, pwCandidates=${passwordCandidates.length}, timeoutMs=${timeoutMs}, hybrid=${hybridMode}`); + + await fs.promises.mkdir(targetDir, { recursive: true }); + + let subst: SubstMapping | null = null; + + try { + if (backendMode !== "legacy") { + const layout = resolveJvmExtractorLayout(); + if (!layout) { + jvmFailureReason = NO_JVM_EXTRACTOR_MESSAGE; + if (backendMode === "jvm") { + throw new Error(NO_JVM_EXTRACTOR_MESSAGE); + } + logger.warn(`JVM-Extractor nicht verfügbar, nutze Legacy-Extractor: ${path.basename(archivePath)}`); + } else { + const emptyCount = passwordCandidates.filter((candidate) => candidate === "").length; + logger.info(`JVM-Extractor aktiv (${layout.rootDir}): ${archiveName}, passwordCount=${passwordCandidates.length}, redacted=true, emptyCandidates=${emptyCount}`); + const jvmStartedAt = Date.now(); + onLog?.("INFO", `JVM-Extractor vorbereitet: archive=${archiveName}, passwordCandidates=${passwordCandidates.length}, layout=${layout.rootDir}`); + const jvmResult = await runJvmExtractCommand( + layout, archivePath, targetDir, conflictMode, passwordCandidates, + onArchiveProgress, signal, timeoutMs + ); + const jvmMs = Date.now() - jvmStartedAt; + onLog?.("INFO", `JVM-Extractor Ergebnis: archive=${archiveName}, ok=${jvmResult.ok}, ms=${jvmMs}, timedOut=${jvmResult.timedOut}, aborted=${jvmResult.aborted}, backend=${jvmResult.backend || "unknown"}, usedPassword=${jvmResult.usedPassword ? "yes" : "no"}`); + logger.info(`JVM-Extractor Ergebnis: archive=${archiveName}, ok=${jvmResult.ok}, ms=${jvmMs}, timedOut=${jvmResult.timedOut}, aborted=${jvmResult.aborted}, backend=${jvmResult.backend || "unknown"}, usedPassword=${jvmResult.usedPassword ? "yes" : "no"}`); + + if (jvmResult.ok) { + logger.info(`Entpackt via ${jvmResult.backend || "jvm"}: ${archiveName}`); + logger.info(`Extract-Backend Ende: archive=${archiveName}, backend=${jvmResult.backend || "jvm"}, mode=${backendMode}, ms=${Date.now() - totalStartedAt}, fallbackFromJvm=false, usedPassword=${jvmResult.usedPassword ? "yes" : "no"}`); + return jvmResult.usedPassword; + } + if (jvmResult.aborted) { + throw new Error("aborted:extract"); + } + if (jvmResult.timedOut) { + throw new Error(jvmResult.errorText || `Entpacken Timeout nach ${Math.ceil(timeoutMs / 1000)}s`); + } + + jvmFailureReason = jvmResult.errorText || "JVM-Extractor fehlgeschlagen"; + fallbackFromJvm = true; + const jvmFailureLower = jvmFailureReason.toLowerCase(); + const isUnsupportedMethod = jvmFailureReason.includes("UNSUPPORTEDMETHOD"); + const isCodecError = jvmFailureLower.includes("registered codecs") + || jvmFailureLower.includes("can not open") + || jvmFailureLower.includes("cannot open archive"); + jvmCodecError = isCodecError; + const isWrongPassword = jvmFailureReason.includes("WRONG_PASSWORD") + || jvmFailureLower.includes("wrong password"); + const shouldFallbackToLegacy = isUnsupportedMethod || isCodecError || isWrongPassword; + onLog?.("WARN", `JVM-Extractor Fallback-Analyse: archive=${archiveName}, unsupportedMethod=${isUnsupportedMethod}, codecError=${isCodecError}, wrongPassword=${isWrongPassword}, backendMode=${backendMode}`); + if (backendMode === "jvm" && !shouldFallbackToLegacy) { + throw new Error(jvmFailureReason); + } + if (isUnsupportedMethod) { + logger.warn(`JVM-Extractor: Komprimierungsmethode nicht unterstützt, fallback auf Legacy: ${path.basename(archivePath)}`); + } else if (isCodecError) { + logger.warn(`JVM-Extractor: Archiv-Format nicht erkannt, fallback auf Legacy: ${path.basename(archivePath)}`); + } else if (isWrongPassword) { + logger.warn(`JVM-Extractor: Kein Passwort hat funktioniert, fallback auf Legacy: ${path.basename(archivePath)}`); + } else { + logger.warn(`JVM-Extractor Fehler, fallback auf Legacy: ${jvmFailureReason}`); + } + } + } + + subst = createSubstMapping(targetDir); + const effectiveTargetDir = subst ? `${subst.drive}:\\` : targetDir; + if (subst) { + onLog?.("INFO", `Legacy-Zielpfad verkuerzt via subst: archive=${archiveName}, originalTargetDir=${targetDir}, effectiveTargetDir=${effectiveTargetDir}`); + } else { + onLog?.("INFO", `Legacy-Zielpfad unveraendert: archive=${archiveName}, effectiveTargetDir=${effectiveTargetDir}`); + } + + const command = await resolveExtractorCommand(archivePath); + const legacyStartedAt = Date.now(); + let password: string; + let usedCommand = command; + try { + try { + password = await runExternalExtractInner( + command, archivePath, effectiveTargetDir, conflictMode, passwordCandidates, + onArchiveProgress, signal, timeoutMs, hybridMode, onPasswordAttempt, + forceFlatMode, flatModeResult, onLog + ); + } catch (primaryError) { + const isRar = /\.rar$/i.test(archiveName) || /\.r\d{2,3}$/i.test(archiveName); + const errText = String((primaryError as Error)?.message || primaryError || ""); + const isPasswordOrCorrupt = /wrong.password|checksum error|corrupt/i.test(errText); + if (isRar && isPasswordOrCorrupt && !signal?.aborted) { + const alt = await findAlternativeExtractor(command, archivePath); + if (alt) { + const altName = path.basename(alt).replace(/\.exe$/i, ""); + onLog?.("INFO", `Legacy-Fallback: primary=${path.basename(command)}, alternative=${altName}, archive=${archiveName}`); + logger.info(`Legacy-Fallback: ${path.basename(command)} fehlgeschlagen bei RAR, versuche ${altName}: ${archiveName}`); + usedCommand = alt; + password = await runExternalExtractInner( + alt, archivePath, effectiveTargetDir, conflictMode, passwordCandidates, + onArchiveProgress, signal, timeoutMs, hybridMode, onPasswordAttempt, + forceFlatMode, flatModeResult, onLog + ); + } else { + throw primaryError; + } + } else { + throw primaryError; + } + } + } catch (legacyError) { + const initialLegacyText = String((legacyError as Error)?.message || legacyError || ""); + const initialLegacyCategory = classifyExtractionError(initialLegacyText); + const initialLegacyHints = legacyError as ExtractionErrorWithHints; + const initialLegacyBestPercent = Number.isFinite(initialLegacyHints.legacyBestPercent) + ? Number(initialLegacyHints.legacyBestPercent || 0) + : 0; + const isCrcOrWrongPw = initialLegacyCategory === "crc_error" || initialLegacyCategory === "wrong_password"; + let finalLegacyError: Error; + + if (isCrcOrWrongPw && !signal?.aborted) { + const retryDelayMs = 2500; + logger.warn( + `Legacy-Extraktion fehlgeschlagen (${initialLegacyCategory}), Retry nach ${retryDelayMs}ms Delay: ${archiveName}` + ); + onLog?.("WARN", `Legacy-Extraktion fehlgeschlagen (${initialLegacyCategory}), Retry nach ${retryDelayMs}ms Delay: ${archiveName}`); + await extractRetryDelay(retryDelayMs); + if (!signal?.aborted) { + try { + const retryCmd = usedCommand; + const retryPassword = await runExternalExtractInner( + retryCmd, + archivePath, + effectiveTargetDir, + conflictMode, + passwordCandidates, + onArchiveProgress, + signal, + timeoutMs, + hybridMode, + onPasswordAttempt, + forceFlatMode, + flatModeResult, + onLog + ); + logger.info(`Legacy-Retry erfolgreich: ${archiveName}`); + onLog?.("INFO", `Legacy-Retry erfolgreich: ${archiveName}`); + password = retryPassword; + usedCommand = retryCmd; + const retryExtractorName = path.basename(retryCmd).replace(/\.exe$/i, ""); + const retryLegacyMs = Date.now() - legacyStartedAt; + if (jvmFailureReason) { + logger.info(`Entpackt via legacy/${retryExtractorName} (nach JVM-Fehler): ${archiveName}`); + } else { + logger.info(`Entpackt via legacy/${retryExtractorName} (nach Legacy-Retry): ${archiveName}`); + } + logger.info(`Extract-Backend Ende: archive=${archiveName}, backend=legacy/${retryExtractorName}, mode=${backendMode}, ms=${Date.now() - totalStartedAt}, legacyMs=${retryLegacyMs}, fallbackFromJvm=${fallbackFromJvm}, usedPassword=${password ? "yes" : "no"}`); + onLog?.("INFO", `Extract-Backend Ende: archive=${archiveName}, backend=legacy/${retryExtractorName}, mode=${backendMode}, ms=${Date.now() - totalStartedAt}, legacyMs=${retryLegacyMs}, fallbackFromJvm=${fallbackFromJvm}, usedPassword=${password ? "yes" : "no"}`); + return password; + } catch (retryError) { + const retryText = String((retryError as Error)?.message || retryError || ""); + const retryCategory = classifyExtractionError(retryText); + logger.warn(`Legacy-Retry ebenfalls fehlgeschlagen (${retryCategory}): ${archiveName}`); + onLog?.("WARN", `Legacy-Retry ebenfalls fehlgeschlagen (${retryCategory}): ${archiveName}`); + const suggestRedownload = jvmCodecError && (retryCategory === "crc_error" || retryCategory === "wrong_password"); + finalLegacyError = withExtractionErrorHints(retryError, { + suggestRedownload, + jvmFailureReason: jvmFailureReason || undefined + }); + } + } else { + finalLegacyError = withExtractionErrorHints(legacyError, { + jvmFailureReason: jvmFailureReason || undefined + }); + } + } else { + const suggestRedownload = jvmCodecError && isCrcOrWrongPw; + finalLegacyError = withExtractionErrorHints(legacyError, { + suggestRedownload, + jvmFailureReason: jvmFailureReason || undefined + }); + } + + const finalLegacyHints = finalLegacyError as ExtractionErrorWithHints; + const finalLegacyText = String(finalLegacyError?.message || finalLegacyError || ""); + const finalLegacyBestPercent = Number.isFinite(finalLegacyHints.legacyBestPercent) + ? Number(finalLegacyHints.legacyBestPercent || 0) + : initialLegacyBestPercent; + + if (!signal?.aborted && shouldFallbackLegacyRarToJvm(archivePath, configuredBackendMode, backendMode, finalLegacyText, finalLegacyBestPercent)) { + const layout = resolveJvmExtractorLayout(); + if (layout) { + logger.warn(`Legacy->JVM-Fallback: archive=${archiveName}, bestPercent=${finalLegacyBestPercent}, reason=${cleanErrorText(finalLegacyText)}`); + onLog?.("WARN", `Legacy->JVM-Fallback: archive=${archiveName}, bestPercent=${finalLegacyBestPercent}, reason=${cleanErrorText(finalLegacyText)}`); + const jvmStartedAt = Date.now(); + const jvmResult = await runJvmExtractCommand( + layout, + archivePath, + targetDir, + conflictMode, + passwordCandidates, + onArchiveProgress, + signal, + timeoutMs + ); + const jvmMs = Date.now() - jvmStartedAt; + logger.info(`JVM-Extractor Ergebnis (nach Legacy-Fallback): archive=${archiveName}, ok=${jvmResult.ok}, ms=${jvmMs}, timedOut=${jvmResult.timedOut}, aborted=${jvmResult.aborted}, backend=${jvmResult.backend || "unknown"}, usedPassword=${jvmResult.usedPassword ? "yes" : "no"}`); + onLog?.("INFO", `JVM-Extractor Ergebnis (nach Legacy-Fallback): archive=${archiveName}, ok=${jvmResult.ok}, ms=${jvmMs}, timedOut=${jvmResult.timedOut}, aborted=${jvmResult.aborted}, backend=${jvmResult.backend || "unknown"}, usedPassword=${jvmResult.usedPassword ? "yes" : "no"}`); + if (jvmResult.ok) { + logger.info(`Entpackt via ${jvmResult.backend || "jvm"} (nach Legacy-Fallback): ${archiveName}`); + logger.info(`Extract-Backend Ende: archive=${archiveName}, backend=${jvmResult.backend || "jvm"}, mode=${backendMode}, ms=${Date.now() - totalStartedAt}, fallbackFromJvm=${fallbackFromJvm}, fallbackFromLegacy=true, usedPassword=${jvmResult.usedPassword ? "yes" : "no"}`); + onLog?.("INFO", `Extract-Backend Ende: archive=${archiveName}, backend=${jvmResult.backend || "jvm"}, mode=${backendMode}, ms=${Date.now() - totalStartedAt}, fallbackFromJvm=${fallbackFromJvm}, fallbackFromLegacy=true, usedPassword=${jvmResult.usedPassword ? "yes" : "no"}`); + return jvmResult.usedPassword; + } + if (jvmResult.aborted) { + throw new Error("aborted:extract"); + } + finalLegacyError = withExtractionErrorHints(finalLegacyError, { + jvmFailureReason: jvmResult.errorText || "JVM-Extractor fehlgeschlagen" + }); + logger.warn(`Legacy->JVM-Fallback ebenfalls fehlgeschlagen: ${archiveName} (${cleanErrorText(jvmResult.errorText || "JVM-Extractor fehlgeschlagen")})`); + onLog?.("WARN", `Legacy->JVM-Fallback ebenfalls fehlgeschlagen: archive=${archiveName}, error=${cleanErrorText(jvmResult.errorText || "JVM-Extractor fehlgeschlagen")}`); + } else { + logger.warn(`Legacy->JVM-Fallback uebersprungen: JVM-Extractor nicht verfuegbar fuer ${archiveName}`); + onLog?.("WARN", `Legacy->JVM-Fallback uebersprungen: archive=${archiveName}, reason=no_jvm_extractor`); + } + } + + throw finalLegacyError; + } + const legacyMs = Date.now() - legacyStartedAt; + const extractorName = path.basename(usedCommand).replace(/\.exe$/i, ""); + if (jvmFailureReason) { + logger.info(`Entpackt via legacy/${extractorName} (nach JVM-Fehler): ${archiveName}`); + } else { + logger.info(`Entpackt via legacy/${extractorName}: ${archiveName}`); + } + logger.info(`Extract-Backend Ende: archive=${archiveName}, backend=legacy/${extractorName}, mode=${backendMode}, ms=${Date.now() - totalStartedAt}, legacyMs=${legacyMs}, fallbackFromJvm=${fallbackFromJvm}, usedPassword=${password ? "yes" : "no"}`); + onLog?.("INFO", `Extract-Backend Ende: archive=${archiveName}, backend=legacy/${extractorName}, mode=${backendMode}, ms=${Date.now() - totalStartedAt}, legacyMs=${legacyMs}, fallbackFromJvm=${fallbackFromJvm}, usedPassword=${password ? "yes" : "no"}`); + return password; + } finally { + if (subst) removeSubstMapping(subst); + } +} + +function isZipSafetyGuardError(error: unknown): boolean { + const text = String(error || "").toLowerCase(); + return text.includes("path traversal") + || text.includes("zip-eintrag verdächtig groß") + || text.includes("zip-eintrag verdaechtig gross"); +} + +function isZipInternalLimitError(error: unknown): boolean { + const text = String(error || "").toLowerCase(); + return text.includes("zip-eintrag zu groß") + || text.includes("zip-eintrag komprimiert zu groß") + || text.includes("zip-eintrag ohne sichere groessenangabe"); +} + +function shouldFallbackToExternalZip(error: unknown): boolean { + if (isZipSafetyGuardError(error)) { + return false; + } + if (isZipInternalLimitError(error)) { + return true; + } + const text = String(error || "").toLowerCase(); + if (text.includes("aborted:extract") || text.includes("extract_aborted")) { + return false; + } + return true; +} + +export function selectZipFallbackError(internalError: unknown, externalError: unknown): unknown { + const category = classifyExtractionError(externalError); + if (category === "no_extractor" || category === "unsupported_format") { + return internalError; + } + return externalError; +} + +async function extractZipArchive(archivePath: string, targetDir: string, conflictMode: ConflictMode, signal?: AbortSignal): Promise<void> { + const mode = effectiveConflictMode(conflictMode); + const memoryLimitBytes = zipEntryMemoryLimitBytes(); + const zip = new AdmZip(archivePath); + const entries = zip.getEntries(); + const resolvedTarget = path.resolve(targetDir); + const usedOutputs = new Set<string>(); + const renameCounters = new Map<string, number>(); + + for (const entry of entries) { + if (signal?.aborted) { + throw new Error("aborted:extract"); + } + const baseOutputPath = path.resolve(targetDir, entry.entryName); + if (!baseOutputPath.startsWith(resolvedTarget + path.sep) && baseOutputPath !== resolvedTarget) { + logger.warn(`ZIP-Eintrag übersprungen (Path Traversal): ${entry.entryName}`); + continue; + } + if (entry.isDirectory) { + await fs.promises.mkdir(baseOutputPath, { recursive: true }); + continue; + } + + const header = (entry as unknown as { + header?: { + size?: number; + compressedSize?: number; + crc?: number; + dataHeader?: { + size?: number; + compressedSize?: number; + crc?: number; + }; + }; + }).header; + const uncompressedSize = Number(header?.size ?? header?.dataHeader?.size ?? NaN); + const compressedSize = Number(header?.compressedSize ?? header?.dataHeader?.compressedSize ?? NaN); + + if (!Number.isFinite(uncompressedSize) || uncompressedSize < 0) { + throw new Error("ZIP-Eintrag ohne sichere Groessenangabe fur internen Entpacker"); + } + if (!Number.isFinite(compressedSize) || compressedSize < 0) { + throw new Error("ZIP-Eintrag ohne sichere Groessenangabe fur internen Entpacker"); + } + + if (uncompressedSize > memoryLimitBytes) { + const entryMb = Math.ceil(uncompressedSize / (1024 * 1024)); + const limitMb = Math.ceil(memoryLimitBytes / (1024 * 1024)); + throw new Error(`ZIP-Eintrag zu groß für internen Entpacker (${entryMb} MB > ${limitMb} MB)`); + } + if (compressedSize > memoryLimitBytes) { + const entryMb = Math.ceil(compressedSize / (1024 * 1024)); + const limitMb = Math.ceil(memoryLimitBytes / (1024 * 1024)); + throw new Error(`ZIP-Eintrag komprimiert zu groß für internen Entpacker (${entryMb} MB > ${limitMb} MB)`); + } + + let outputPath = baseOutputPath; + let outputKey = pathSetKey(outputPath); + + await fs.promises.mkdir(path.dirname(outputPath), { recursive: true }); + const outputExists = usedOutputs.has(outputKey) || await fs.promises.access(outputPath).then(() => true, () => false); + if (outputExists) { + if (mode === "skip") { + continue; + } + if (mode === "rename") { + const parsed = path.parse(baseOutputPath); + const counterKey = pathSetKey(baseOutputPath); + let n = renameCounters.get(counterKey) || 1; + let candidate = baseOutputPath; + let candidateKey = outputKey; + while (n <= 10000) { + candidate = path.join(parsed.dir, `${parsed.name} (${n})${parsed.ext}`); + candidateKey = pathSetKey(candidate); + if (!usedOutputs.has(candidateKey) && !(await fs.promises.access(candidate).then(() => true, () => false))) { + break; + } + n += 1; + } + if (n > 10000) { + throw new Error(`ZIP-Rename-Limit erreicht für ${entry.entryName}`); + } + renameCounters.set(counterKey, n + 1); + if (signal?.aborted) { + throw new Error("aborted:extract"); + } + outputPath = candidate; + outputKey = candidateKey; + } + } + + if (signal?.aborted) { + throw new Error("aborted:extract"); + } + const data = entry.getData(); + if (data.length > memoryLimitBytes) { + const entryMb = Math.ceil(data.length / (1024 * 1024)); + const limitMb = Math.ceil(memoryLimitBytes / (1024 * 1024)); + throw new Error(`ZIP-Eintrag zu groß für internen Entpacker (${entryMb} MB > ${limitMb} MB)`); + } + const maxDeclaredSize = Math.max(uncompressedSize, compressedSize); + if (maxDeclaredSize > 0 && data.length > maxDeclaredSize * 20) { + throw new Error(`ZIP-Eintrag verdächtig groß nach Entpacken (${entry.entryName})`); + } + await fs.promises.writeFile(outputPath, data); + usedOutputs.add(outputKey); + } +} + +async function estimateArchivesTotalBytes(candidates: string[]): Promise<number> { + let total = 0; + for (const archivePath of candidates) { + const parts = collectArchiveCleanupTargets(archivePath); + for (const part of parts) { + try { + total += (await fs.promises.stat(part)).size; + } catch { } + } + } + return total; +} + +function humanSizeGB(bytes: number): string { + if (bytes >= 1024 * 1024 * 1024) { + return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} GB`; + } + return `${(bytes / (1024 * 1024)).toFixed(0)} MB`; +} + +async function checkDiskSpaceForExtraction(targetDir: string, candidates: string[]): Promise<void> { + if (candidates.length === 0) return; + const archiveBytes = await estimateArchivesTotalBytes(candidates); + if (archiveBytes <= 0) return; + const requiredBytes = Math.ceil(archiveBytes * DISK_SPACE_SAFETY_FACTOR); + + let freeBytes: number; + try { + const stats = await fs.promises.statfs(targetDir); + freeBytes = stats.bfree * stats.bsize; + } catch { + return; + } + + if (freeBytes < requiredBytes) { + const msg = `Nicht genug Speicherplatz: ${humanSizeGB(requiredBytes)} benötigt, ${humanSizeGB(freeBytes)} frei`; + logger.error(`Disk-Space-Check: ${msg} (target=${targetDir})`); + throw new Error(msg); + } + logger.info(`Disk-Space-Check OK: ${humanSizeGB(freeBytes)} frei, ${humanSizeGB(requiredBytes)} benötigt (target=${targetDir})`); +} + +function zipEntryMemoryLimitBytes(): number { + const fromEnvMb = Number(process.env.RD_ZIP_ENTRY_MEMORY_LIMIT_MB ?? NaN); + if (Number.isFinite(fromEnvMb) && fromEnvMb >= 8 && fromEnvMb <= 4096) { + return Math.floor(fromEnvMb * 1024 * 1024); + } + return DEFAULT_ZIP_ENTRY_MEMORY_LIMIT_MB * 1024 * 1024; +} + +async function shouldPreferExternalZip(archivePath: string): Promise<boolean> { + if (extractorBackendMode() !== "legacy") { + return true; + } + try { + const stat = await fs.promises.stat(archivePath); + return stat.size >= 64 * 1024 * 1024; + } catch { + return true; + } +} + +async function computeExtractTimeoutMs(archivePath: string): Promise<number> { + try { + const relatedFiles = collectArchiveCleanupTargets(archivePath); + let totalBytes = 0; + for (const filePath of relatedFiles) { + try { + totalBytes += (await fs.promises.stat(filePath)).size; + } catch { + } + } + if (totalBytes <= 0) { + totalBytes = (await fs.promises.stat(archivePath)).size; + } + const gib = totalBytes / (1024 * 1024 * 1024); + const dynamicMs = EXTRACT_BASE_TIMEOUT_MS + Math.floor(gib * EXTRACT_PER_GIB_TIMEOUT_MS); + return Math.max(EXTRACT_BASE_TIMEOUT_MS, Math.min(EXTRACT_MAX_TIMEOUT_MS, dynamicMs)); + } catch { + return EXTRACT_BASE_TIMEOUT_MS; + } +} + +function extractProgressFilePath(packageDir: string, packageId?: string): string { + if (packageId) { + return path.join(packageDir, `.rd_extract_progress_${packageId}.json`); + } + return path.join(packageDir, EXTRACT_PROGRESS_FILE); +} + +async function readExtractResumeState(packageDir: string, packageId?: string): Promise<Set<string>> { + const progressPath = extractProgressFilePath(packageDir, packageId); + try { + await fs.promises.access(progressPath); + } catch { + return new Set<string>(); + } + try { + const payload = JSON.parse(await fs.promises.readFile(progressPath, "utf8")) as Partial<ExtractResumeState>; + const names = Array.isArray(payload.completedArchives) ? payload.completedArchives : []; + return new Set(names.map((value) => archiveNameKey(String(value || "").trim())).filter(Boolean)); + } catch { + return new Set<string>(); + } +} + +async function writeExtractResumeState(packageDir: string, completedArchives: Set<string>, packageId?: string): Promise<void> { + try { + await fs.promises.mkdir(packageDir, { recursive: true }); + const progressPath = extractProgressFilePath(packageDir, packageId); + const payload: ExtractResumeState = { + completedArchives: Array.from(completedArchives) + .map((name) => archiveNameKey(name)) + .sort((a, b) => a.localeCompare(b)) + }; + const tmpPath = progressPath + "." + Date.now() + "." + Math.random().toString(36).slice(2, 8) + ".tmp"; + await fs.promises.writeFile(tmpPath, JSON.stringify(payload, null, 2), "utf8"); + await fs.promises.rename(tmpPath, progressPath).catch(async () => { + await fs.promises.rm(tmpPath, { force: true }).catch(() => {}); + }); + } catch (error) { + logger.warn(`ExtractResumeState schreiben fehlgeschlagen: ${String(error)}`); + } +} + +export async function clearExtractResumeState(packageDir: string, packageId?: string): Promise<void> { + try { + await fs.promises.rm(extractProgressFilePath(packageDir, packageId), { force: true }); + } catch { + } +} + +function emitExtractLog( + onLog: ExtractOptions["onLog"] | undefined, + level: "INFO" | "WARN" | "ERROR", + message: string +): void { + if (level === "INFO") { + logger.info(message); + } else if (level === "WARN") { + logger.warn(message); + } else { + logger.error(message); + } + onLog?.(level, message); +} + +function effectiveConflictMode(conflictMode: ConflictMode): "overwrite" | "skip" | "rename" { + if (conflictMode === "rename") { + return "rename"; + } + if (conflictMode === "overwrite") { + return "overwrite"; + } + return "skip"; +} + +export async function extractPackageArchives(options: ExtractOptions): Promise<{ extracted: number; failed: number; lastError: string }> { + if (options.signal?.aborted) { + throw new Error("aborted:extract"); + } + options.onProgress?.({ current: 0, total: 0, percent: 0, archiveName: "Archive scannen...", phase: "preparing" }); + const allCandidates = await findArchiveCandidates(options.packageDir); + const candidates = options.onlyArchives + ? allCandidates.filter((archivePath) => { + const key = process.platform === "win32" ? path.resolve(archivePath).toLowerCase() : path.resolve(archivePath); + return options.onlyArchives!.has(key); + }) + : allCandidates; + logger.info(`Entpacken gestartet: packageDir=${options.packageDir}, targetDir=${options.targetDir}, archives=${candidates.length}${options.onlyArchives ? ` (hybrid, gesamt=${allCandidates.length})` : ""}, cleanupMode=${options.cleanupMode}, conflictMode=${options.conflictMode}`); + options.onLog?.("INFO", `Entpacken gestartet: packageDir=${options.packageDir}, targetDir=${options.targetDir}, archives=${candidates.length}${options.onlyArchives ? ` (hybrid, gesamt=${allCandidates.length})` : ""}, cleanupMode=${options.cleanupMode}, conflictMode=${options.conflictMode}`); + + if (candidates.length > 0) { + options.onProgress?.({ current: 0, total: candidates.length, percent: 0, archiveName: "Speicherplatz prüfen...", phase: "preparing" }); + try { + await fs.promises.mkdir(options.targetDir, { recursive: true }); + } catch { } + await checkDiskSpaceForExtraction(options.targetDir, candidates); + } + + if (candidates.length === 0) { + if (!options.onlyArchives) { + const existingResume = await readExtractResumeState(options.packageDir, options.packageId); + if (existingResume.size > 0 && await hasAnyEntries(options.targetDir)) { + await clearExtractResumeState(options.packageDir, options.packageId); + logger.info(`Entpacken übersprungen (Archive bereinigt, Ziel hat Dateien): ${options.packageDir}`); + options.onProgress?.({ + current: existingResume.size, + total: existingResume.size, + percent: 100, + archiveName: "", + phase: "done" + }); + return { extracted: existingResume.size, failed: 0, lastError: "" }; + } + await clearExtractResumeState(options.packageDir, options.packageId); + } + logger.info(`Entpacken übersprungen (keine Archive gefunden): ${options.packageDir}`); + return { extracted: 0, failed: 0, lastError: "" }; + } + + const conflictMode = effectiveConflictMode(options.conflictMode); + if (options.conflictMode === "ask") { + logger.warn("Extract-ConflictMode 'ask' wird ohne Prompt als 'skip' behandelt"); + } + const passwordCacheKey = packagePasswordCacheKey(options.packageDir, options.packageId); + const passwordCacheLabel = packagePasswordCacheLabel(options.packageDir, options.packageId); + let passwordCandidates = archivePasswords(options.passwordList || ""); + const cachedPackagePassword = readCachedPackagePassword(passwordCacheKey); + if (cachedPackagePassword) { + passwordCandidates = prioritizePassword(passwordCandidates, cachedPackagePassword); + logger.info(`Passwort-Cache Treffer: ${passwordCacheLabel}, bekanntes Passwort wird zuerst getestet`); + options.onLog?.("INFO", `Passwort-Cache Treffer: ${passwordCacheLabel}, bekanntes Passwort wird zuerst getestet`); + } + const resumeCompleted = await readExtractResumeState(options.packageDir, options.packageId); + const resumeCompletedAtStart = resumeCompleted.size; + const allCandidateNames = new Set(allCandidates.map((archivePath) => archiveNameKey(path.basename(archivePath)))); + for (const archiveName of Array.from(resumeCompleted.values())) { + // Nested-archive progress (keyed "nested:<name>") has no top-level candidate on + // disk to validate against, so it must NOT be pruned here — otherwise every + // extractPackageArchives call wiped it and nested archives were re-extracted on + // resume. It is cleared together with the rest once the package fully completes. + if (archiveName.startsWith("nested:")) { + continue; + } + if (!allCandidateNames.has(archiveName)) { + resumeCompleted.delete(archiveName); + } + } + if (resumeCompleted.size > 0) { + await writeExtractResumeState(options.packageDir, resumeCompleted, options.packageId); + } else { + await clearExtractResumeState(options.packageDir, options.packageId); + } + + const pendingCandidates = candidates.filter((archivePath) => !resumeCompleted.has(archiveNameKey(path.basename(archivePath)))); + let extracted = candidates.length - pendingCandidates.length; + let failed = 0; + let lastError = ""; + let learnedPassword = cachedPackagePassword; + let packageNeedsFlatMode = false; + const extractedArchives = new Set<string>(); + const skippedNonArchives = new Set<string>(); + const failedArchiveCategories = new Map<string, ExtractErrorCategory>(); + for (const archivePath of candidates) { + if (resumeCompleted.has(archiveNameKey(path.basename(archivePath)))) { + const resumedName = path.basename(archivePath); + const resumedIsGenericSplit = /\.\d{3}$/i.test(resumedName) && !/\.(zip|7z)\.\d{3}$/i.test(resumedName); + if (resumedIsGenericSplit && !(await detectArchiveSignature(archivePath))) { + skippedNonArchives.add(pathSetKey(archivePath)); + continue; + } + extractedArchives.add(archivePath); + } + } + + const rememberLearnedPassword = (password: string): void => { + const normalized = String(password || "").trim(); + if (!normalized) { + return; + } + const changed = normalized !== learnedPassword; + learnedPassword = normalized; + passwordCandidates = prioritizePassword(passwordCandidates, normalized); + writeCachedPackagePassword(passwordCacheKey, normalized); + if (changed) { + logger.info(`Passwort-Cache Update: ${passwordCacheLabel}, neues Passwort gelernt`); + options.onLog?.("INFO", `Passwort-Cache Update: ${passwordCacheLabel}, neues Passwort gelernt`); + } + }; + + const emitProgress = ( + current: number, + archiveName: string, + phase: "extracting" | "done", + archivePercent?: number, + elapsedMs?: number, + pwInfo?: { passwordAttempt?: number; passwordTotal?: number; passwordFound?: boolean }, + archiveInfo?: { archiveDone?: boolean; archiveSuccess?: boolean } + ): void => { + if (!options.onProgress) { + return; + } + const total = Math.max(1, candidates.length); + let percent = Math.max(0, Math.min(100, Math.floor((current / total) * 100))); + let normalizedArchivePercent = Math.max(0, Math.min(100, Number(archivePercent ?? 0))); + if (phase !== "done") { + const boundedCurrent = Math.max(0, Math.min(total, current)); + if (archiveInfo?.archiveDone !== true && normalizedArchivePercent >= 100) { + normalizedArchivePercent = 99; + } + percent = Math.max(0, Math.min(100, Math.floor(((boundedCurrent + (normalizedArchivePercent / 100)) / total) * 100))); + } + try { + options.onProgress({ + current, + total, + percent, + archiveName, + archivePercent: normalizedArchivePercent, + elapsedMs, + phase, + ...(archiveInfo || {}), + ...(pwInfo || {}) + }); + } catch (error) { + logger.warn(`onProgress callback Fehler unterdrückt: ${cleanErrorText(String(error))}`); + } + }; + + emitProgress(extracted, "", "extracting"); + + for (const archivePath of candidates) { + if (resumeCompleted.has(archiveNameKey(path.basename(archivePath)))) { + emitProgress(extracted, path.basename(archivePath), "extracting", 100, 0, undefined, { archiveDone: true, archiveSuccess: true }); + } + } + + const maxParallel = Math.max(1, options.maxParallel || 1); + let noExtractorEncountered = false; + let lastArchiveFinishedAt: number | null = null; + + const extractSingleArchive = async (archivePath: string): Promise<void> => { + if (options.signal?.aborted) { + throw new Error("aborted:extract"); + } + if (noExtractorEncountered) { + throw new Error("noextractor:skipped"); + } + const archiveName = path.basename(archivePath); + const archiveResumeKey = archiveNameKey(archiveName); + const archiveStartedAt = Date.now(); + const startedCurrent = extracted + failed; + if (lastArchiveFinishedAt !== null) { + logger.info(`Extract-Trace Gap: before=${archiveName}, prevDoneToStartMs=${archiveStartedAt - lastArchiveFinishedAt}, progress=${startedCurrent}/${candidates.length}`); + } + let archivePercent = 0; + let reached99At: number | null = null; + let archiveOutcome: "success" | "failed" | "skipped" = "failed"; + emitProgress(extracted + failed, archiveName, "extracting", archivePercent, 0); + const pulseTimer = setInterval(() => { + emitProgress(extracted + failed, archiveName, "extracting", archivePercent, Date.now() - archiveStartedAt); + }, 1100); + const hybrid = Boolean(options.hybridMode); + const filenamePasswords = archiveFilenamePasswords(archiveName); + const nonEmptyBasePasswords = passwordCandidates.filter((p) => p !== ""); + const orderedNonEmpty = learnedPassword + ? [learnedPassword, ...nonEmptyBasePasswords.filter((p) => p !== learnedPassword), ...filenamePasswords] + : [...filenamePasswords, ...nonEmptyBasePasswords]; + const archivePasswordCandidates = learnedPassword + ? Array.from(new Set([...orderedNonEmpty, ""])) + : Array.from(new Set(["", ...orderedNonEmpty])); + const reportArchiveProgress = (value: number): void => { + archivePercent = nextArchivePercent(archivePercent, value); + if (reached99At === null && archivePercent >= 99) { + reached99At = Date.now(); + logger.info(`Extract-Trace 99%: archive=${archiveName}, elapsedMs=${reached99At - archiveStartedAt}`); + } + emitProgress(extracted + failed, archiveName, "extracting", archivePercent, Date.now() - archiveStartedAt); + }; + + const isGenericSplit = /\.\d{3}$/i.test(archiveName) && !/\.(zip|7z)\.\d{3}$/i.test(archiveName); + if (isGenericSplit) { + const sig = await detectArchiveSignature(archivePath); + if (!sig) { + logger.info(`Generische Split-Datei übersprungen (keine Archiv-Signatur): ${archiveName}`); + extracted += 1; + resumeCompleted.add(archiveResumeKey); + skippedNonArchives.add(pathSetKey(archivePath)); + await writeExtractResumeState(options.packageDir, resumeCompleted, options.packageId); + clearInterval(pulseTimer); + archiveOutcome = "skipped"; + const skippedAt = Date.now(); + lastArchiveFinishedAt = skippedAt; + logger.info(`Extract-Trace Archiv Übersprungen: archive=${archiveName}, ms=${skippedAt - archiveStartedAt}, reason=no-signature`); + return; + } + logger.info(`Generische Split-Datei verifiziert (Signatur: ${sig}): ${archiveName}`); + } + + logger.info(`Entpacke Archiv: ${path.basename(archivePath)} -> ${options.targetDir}${hybrid ? " (hybrid, reduced threads, low I/O)" : ""}`); + options.onLog?.("INFO", `Entpacke Archiv: ${path.basename(archivePath)} -> ${options.targetDir}${hybrid ? " (hybrid, reduced threads, low I/O)" : ""}`); + const emptyArchivePasswordCount = archivePasswordCandidates.filter((candidate) => candidate === "").length; + options.onLog?.("INFO", `Archiv-Passwortliste: archive=${archiveName}, passwordCount=${archivePasswordCandidates.length}, redacted=true, emptyCandidates=${emptyArchivePasswordCount}`); + const hasManyPasswords = archivePasswordCandidates.length > 1; + if (hasManyPasswords) { + emitProgress(extracted + failed, archiveName, "extracting", 0, 0, { passwordAttempt: 0, passwordTotal: archivePasswordCandidates.length }); + } + const onPwAttempt = hasManyPasswords + ? (attempt: number, total: number) => { + emitProgress(extracted + failed, archiveName, "extracting", archivePercent, Date.now() - archiveStartedAt, { passwordAttempt: attempt, passwordTotal: total }); + options.onLog?.("INFO", `Passwort-Versuch ${attempt}/${total}: archive=${archiveName}, password=<redacted>`); + } + : undefined; + try { + currentExtractCpuPriority = options.extractCpuPriority; + const ext = path.extname(archivePath).toLowerCase(); + if (ext === ".zip") { + const preferExternal = await shouldPreferExternalZip(archivePath); + if (preferExternal) { + try { + const usedPassword = await runExternalExtract(archivePath, options.targetDir, options.conflictMode, archivePasswordCandidates, (value) => { + reportArchiveProgress(value); + }, options.signal, hybrid, onPwAttempt, false, undefined, options.onLog); + rememberLearnedPassword(usedPassword); + } catch (error) { + if (isNoExtractorError(String(error))) { + await extractZipArchive(archivePath, options.targetDir, options.conflictMode, options.signal); + } else { + throw error; + } + } + } else { + try { + await extractZipArchive(archivePath, options.targetDir, options.conflictMode, options.signal); + archivePercent = 100; + } catch (error) { + if (!shouldFallbackToExternalZip(error)) { + throw error; + } + try { + const usedPassword = await runExternalExtract(archivePath, options.targetDir, options.conflictMode, archivePasswordCandidates, (value) => { + reportArchiveProgress(value); + }, options.signal, hybrid, onPwAttempt, false, undefined, options.onLog); + rememberLearnedPassword(usedPassword); + } catch (externalError) { + throw selectZipFallbackError(error, externalError); + } + } + } + } else { + const flatResult = { needed: false }; + const usedPassword = await runExternalExtract(archivePath, options.targetDir, options.conflictMode, archivePasswordCandidates, (value) => { + reportArchiveProgress(value); + }, options.signal, hybrid, onPwAttempt, packageNeedsFlatMode, flatResult, options.onLog); + rememberLearnedPassword(usedPassword); + if (flatResult.needed) packageNeedsFlatMode = true; + } + extracted += 1; + extractedArchives.add(archivePath); + failedArchiveCategories.delete(archivePath); + resumeCompleted.add(archiveResumeKey); + await writeExtractResumeState(options.packageDir, resumeCompleted, options.packageId); + logger.info(`Entpacken erfolgreich: ${path.basename(archivePath)}`); + options.onLog?.("INFO", `Entpacken erfolgreich: ${path.basename(archivePath)}`); + archiveOutcome = "success"; + const successAt = Date.now(); + const tailAfter99Ms = reached99At ? (successAt - reached99At) : -1; + logger.info(`Extract-Trace Archiv Erfolg: archive=${archiveName}, totalMs=${successAt - archiveStartedAt}, tailAfter99Ms=${tailAfter99Ms >= 0 ? tailAfter99Ms : "n/a"}, pwCandidates=${archivePasswordCandidates.length}`); + lastArchiveFinishedAt = successAt; + archivePercent = 100; + if (hasManyPasswords) { + emitProgress(extracted + failed, archiveName, "extracting", archivePercent, Date.now() - archiveStartedAt, { passwordFound: true }, { archiveDone: true, archiveSuccess: true }); + } else { + emitProgress(extracted + failed, archiveName, "extracting", archivePercent, Date.now() - archiveStartedAt, undefined, { archiveDone: true, archiveSuccess: true }); + } + } catch (error) { + const errorText = String(error); + if (isExtractAbortError(errorText)) { + throw error; + } + failed += 1; + lastError = errorText; + const errorCategory = classifyExtractionError(errorText); + failedArchiveCategories.set(archivePath, errorCategory); + const hintedError = error as ExtractionErrorWithHints; + options.onArchiveFailure?.({ + archiveName, + errorText, + category: errorCategory, + suggestRedownload: hintedError?.suggestRedownload === true, + jvmFailureReason: hintedError?.jvmFailureReason + }); + logger.error(`Entpack-Fehler ${path.basename(archivePath)} [${errorCategory}]: ${errorText}`); + options.onLog?.("ERROR", `Entpack-Fehler ${path.basename(archivePath)} [${errorCategory}]: ${errorText}`); + if (errorCategory === "wrong_password" && learnedPassword) { + learnedPassword = ""; + clearCachedPackagePassword(passwordCacheKey); + logger.warn(`Passwort-Cache verworfen: ${passwordCacheLabel} (wrong_password)`); + options.onLog?.("WARN", `Passwort-Cache verworfen: ${passwordCacheLabel} (wrong_password)`); + } + const failedAt = Date.now(); + const tailAfter99Ms = reached99At ? (failedAt - reached99At) : -1; + logger.warn(`Extract-Trace Archiv Fehler: archive=${archiveName}, totalMs=${failedAt - archiveStartedAt}, tailAfter99Ms=${tailAfter99Ms >= 0 ? tailAfter99Ms : "n/a"}, category=${errorCategory}`); + lastArchiveFinishedAt = failedAt; + emitProgress(extracted + failed, archiveName, "extracting", archivePercent, Date.now() - archiveStartedAt, undefined, { archiveDone: true, archiveSuccess: false }); + if (isNoExtractorError(errorText)) { + noExtractorEncountered = true; + } + } finally { + clearInterval(pulseTimer); + if (lastArchiveFinishedAt === null || lastArchiveFinishedAt < archiveStartedAt) { + lastArchiveFinishedAt = Date.now(); + } + logger.info(`Extract-Trace Archiv Ende: archive=${archiveName}, outcome=${archiveOutcome}, elapsedMs=${lastArchiveFinishedAt - archiveStartedAt}, percent=${archivePercent}`); + } + }; + + if (maxParallel <= 1) { + for (const archivePath of pendingCandidates) { + if (options.signal?.aborted || noExtractorEncountered) break; + await extractSingleArchive(archivePath); + } + if (noExtractorEncountered) { + const remaining = candidates.length - (extracted + failed); + if (remaining > 0) { + failed += remaining; + emitProgress(candidates.length, "", "extracting", 0, 0); + } + } + } else { + let parallelQueue = pendingCandidates; + if (passwordCandidates.length > 1 && pendingCandidates.length > 1) { + logger.info(`Passwort-Discovery: Extrahiere erstes Archiv seriell (${passwordCandidates.length} Passwort-Kandidaten)...`); + options.onLog?.("INFO", `Passwort-Discovery: Extrahiere erstes Archiv seriell (${passwordCandidates.length} Passwort-Kandidaten)...`); + const first = pendingCandidates[0]; + try { + await extractSingleArchive(first); + } catch (err) { + const errText = String(err); + if (/aborted:extract/i.test(errText)) throw err; + } + parallelQueue = pendingCandidates.slice(1); + if (parallelQueue.length > 0) { + logger.info(`Passwort-Discovery abgeschlossen, starte parallele Extraktion für ${parallelQueue.length} verbleibende Archive`); + } + } + + if (parallelQueue.length > 0 && !options.signal?.aborted && !noExtractorEncountered) { + const queue = [...parallelQueue]; + let nextIdx = 0; + let abortError: Error | null = null; + + const worker = async (): Promise<void> => { + while (nextIdx < queue.length && !abortError && !noExtractorEncountered) { + if (options.signal?.aborted) break; + const idx = nextIdx; + nextIdx += 1; + try { + await extractSingleArchive(queue[idx]); + } catch (error) { + const errText = String(error); + if (errText.includes("noextractor:skipped")) { + break; + } + if (isExtractAbortError(errText)) { + abortError = error instanceof Error ? error : new Error(errText); + break; + } + } + } + }; + + const workerCount = Math.min(maxParallel, parallelQueue.length); + logger.info(`Parallele Extraktion: ${workerCount} gleichzeitige Worker für ${parallelQueue.length} Archive`); + const frozenPasswords = [...passwordCandidates]; + await Promise.all(Array.from({ length: workerCount }, () => worker())); + passwordCandidates = frozenPasswords; + + if (abortError) throw new Error("aborted:extract"); + + if (failed > 0 && extracted === 0) { + const failedArchives = parallelQueue.filter((ap) => !extractedArchives.has(ap) && !resumeCompleted.has(archiveNameKey(path.basename(ap)))); + const failedCategories = failedArchives.map((archivePath) => failedArchiveCategories.get(archivePath) || "unknown"); + if (failedArchives.length > 0 && shouldSerialRetryParallelFailures(extracted, failedCategories)) { + const categorySummary = [...new Set(failedCategories)].join(","); + logger.info( + `Serielle Wiederholung nach Parallel-Fehlstart: ${failedArchives.length} Archive werden einzeln wiederholt ` + + `(categories=${categorySummary || "unknown"})` + ); + let retryRecovered = 0; + for (const archivePath of failedArchives) { + if (options.signal?.aborted || noExtractorEncountered) break; + try { + failed -= 1; + await extractSingleArchive(archivePath); + retryRecovered += 1; + } catch (retryError) { + const errText = String(retryError); + if (isExtractAbortError(errText)) throw retryError; + } + } + if (retryRecovered > 0) { + logger.info(`Serielle Wiederholung nach Parallel-Fehlstart: ${retryRecovered}/${failedArchives.length} Archive erfolgreich entpackt`); + } + } + } + + if (failed > 0 && extracted > 0) { + const failedArchives = parallelQueue.filter((ap) => !extractedArchives.has(ap) && !resumeCompleted.has(archiveNameKey(path.basename(ap)))); + if (failedArchives.length > 0) { + logger.info(`Serielle Wiederholung: ${failedArchives.length} fehlgeschlagene Archive werden einzeln wiederholt (mögliche Parallelitäts-Kollision)`); + let retryRecovered = 0; + for (const archivePath of failedArchives) { + if (options.signal?.aborted || noExtractorEncountered) break; + try { + failed -= 1; + await extractSingleArchive(archivePath); + retryRecovered += 1; + } catch (retryError) { + const errText = String(retryError); + if (isExtractAbortError(errText)) throw retryError; + } + } + if (retryRecovered > 0) { + logger.info(`Serielle Wiederholung: ${retryRecovered}/${failedArchives.length} Archive erfolgreich entpackt`); + } + } + } + } + + if (noExtractorEncountered) { + const remaining = candidates.length - (extracted + failed); + if (remaining > 0) { + failed += remaining; + emitProgress(candidates.length, "", "extracting", 0, 0); + } + } + } + + if (extracted > 0 && failed === 0 && !options.skipPostCleanup && !options.onlyArchives) { + try { + const nestedCandidates = (await findArchiveCandidates(options.targetDir)) + .filter((p) => !NESTED_EXTRACT_BLACKLIST_RE.test(p)); + if (nestedCandidates.length > 0) { + logger.info(`Nested-Extraction: ${nestedCandidates.length} Archive im Output gefunden`); + let nestedExtracted = 0; + let nestedFailed = 0; + try { + await checkDiskSpaceForExtraction(options.targetDir, nestedCandidates); + } catch (spaceError) { + logger.warn(`Nested-Extraction Disk-Space-Check fehlgeschlagen: ${String(spaceError)}`); + nestedCandidates.length = 0; + } + for (const nestedArchive of nestedCandidates) { + if (options.signal?.aborted) throw new Error("aborted:extract"); + const nestedName = path.basename(nestedArchive); + const nestedKey = archiveNameKey(`nested:${nestedName}`); + if (resumeCompleted.has(nestedKey)) { + logger.info(`Nested-Extraction übersprungen (bereits entpackt): ${nestedName}`); + continue; + } + const nestedStartedAt = Date.now(); + let nestedPercent = 0; + emitProgress(extracted + failed, `nested: ${nestedName}`, "extracting", nestedPercent, 0); + const nestedPulse = setInterval(() => { + emitProgress(extracted + failed, `nested: ${nestedName}`, "extracting", nestedPercent, Date.now() - nestedStartedAt); + }, 1100); + const hybrid = Boolean(options.hybridMode); + logger.info(`Nested-Entpacke: ${nestedName} -> ${options.targetDir}${hybrid ? " (hybrid)" : ""}`); + try { + const ext = path.extname(nestedArchive).toLowerCase(); + if (ext === ".zip" && !(await shouldPreferExternalZip(nestedArchive))) { + try { + await extractZipArchive(nestedArchive, options.targetDir, options.conflictMode, options.signal); + nestedPercent = 100; + } catch (zipErr) { + if (!shouldFallbackToExternalZip(zipErr)) throw zipErr; + const usedPw = await runExternalExtract(nestedArchive, options.targetDir, options.conflictMode, passwordCandidates, (v) => { nestedPercent = Math.max(nestedPercent, v); }, options.signal, hybrid, undefined, false, undefined, options.onLog); + rememberLearnedPassword(usedPw); + } + } else { + const usedPw = await runExternalExtract(nestedArchive, options.targetDir, options.conflictMode, passwordCandidates, (v) => { nestedPercent = Math.max(nestedPercent, v); }, options.signal, hybrid, undefined, false, undefined, options.onLog); + rememberLearnedPassword(usedPw); + } + extracted += 1; + nestedExtracted += 1; + extractedArchives.add(nestedArchive); + resumeCompleted.add(nestedKey); + await writeExtractResumeState(options.packageDir, resumeCompleted, options.packageId); + logger.info(`Nested-Entpacken erfolgreich: ${nestedName}`); + if (options.cleanupMode !== "none") { + await cleanupArchives([nestedArchive], options.cleanupMode); + } + } catch (nestedErr) { + const errText = String(nestedErr); + if (isExtractAbortError(errText)) throw new Error("aborted:extract"); + if (isNoExtractorError(errText)) { + logger.warn(`Nested-Extraction: Kein Extractor, überspringe restliche`); + break; + } + failed += 1; + nestedFailed += 1; + lastError = errText; + const nestedCategory = classifyExtractionError(errText); + logger.error(`Nested-Entpack-Fehler ${nestedName} [${nestedCategory}]: ${errText}`); + } finally { + clearInterval(nestedPulse); + } + } + logger.info(`Nested-Extraction abgeschlossen: ${nestedExtracted} entpackt, ${nestedFailed} fehlgeschlagen von ${nestedCandidates.length} Kandidaten`); + } + } catch (nestedError) { + const errText = String(nestedError); + if (isExtractAbortError(errText)) throw new Error("aborted:extract"); + logger.warn(`Nested-Extraction Fehler: ${cleanErrorText(errText)}`); + } + } + + if (extracted > 0) { + const hasOutputAfter = await hasAnyFilesRecursive(options.targetDir); + const hadResumeProgress = resumeCompletedAtStart > 0; + if (!hasOutputAfter && conflictMode !== "skip" && !hadResumeProgress) { + lastError = "Keine entpackten Dateien erkannt"; + failed += extracted; + extracted = 0; + logger.error(`Entpacken ohne neue Ausgabe erkannt: ${options.targetDir}. Cleanup wird NICHT ausgeführt.`); + } else { + if (!options.skipPostCleanup) { + const cleanupSources = (failed === 0 ? candidates : Array.from(extractedArchives.values())) + .filter((archivePath) => !skippedNonArchives.has(pathSetKey(archivePath))); + const sourceAndTargetEqual = pathSetKey(path.resolve(options.packageDir)) === pathSetKey(path.resolve(options.targetDir)); + const removedArchives = sourceAndTargetEqual + ? 0 + : await cleanupArchives(cleanupSources, options.cleanupMode); + if (sourceAndTargetEqual && options.cleanupMode !== "none") { + logger.warn(`Archive-Cleanup übersprungen (Quelle=Ziel): ${options.packageDir}`); + } + if (options.cleanupMode !== "none") { + logger.info(`Archive-Cleanup abgeschlossen: ${removedArchives} Datei(en) entfernt`); + } + if (options.removeLinks) { + const removedLinks = await removeDownloadLinkArtifacts(options.targetDir); + logger.info(`Link-Artefakt-Cleanup: ${removedLinks} Datei(en) entfernt`); + } + if (options.removeSamples) { + const removedSamples = await removeSampleArtifacts(options.targetDir); + logger.info(`Sample-Cleanup: ${removedSamples.files} Datei(en), ${removedSamples.dirs} Ordner entfernt`); + } + } + + if (failed === 0 && resumeCompleted.size >= allCandidates.length && !options.skipPostCleanup) { + await clearExtractResumeState(options.packageDir, options.packageId); + } + + if (!options.skipPostCleanup && options.cleanupMode === "delete" && !(await hasAnyFilesRecursive(options.packageDir))) { + const removedDirs = await removeEmptyDirectoryTree(options.packageDir); + if (removedDirs > 0) { + logger.info(`Leere Download-Ordner entfernt: ${removedDirs} (root=${options.packageDir})`); + } + } + } + } else if (!options.skipPostCleanup) { + try { + const targetExists = await fs.promises.access(options.targetDir).then(() => true, () => false); + if (targetExists && (await fs.promises.readdir(options.targetDir)).length === 0) { + await fs.promises.rm(options.targetDir, { recursive: true, force: true }); + } + } catch { + } + } + + if (failed > 0) { + if (resumeCompleted.size > 0) { + await writeExtractResumeState(options.packageDir, resumeCompleted, options.packageId); + } else { + await clearExtractResumeState(options.packageDir, options.packageId); + } + } + + emitProgress(extracted, "", "done"); + + logger.info(`Entpacken beendet: extracted=${extracted}, failed=${failed}, targetDir=${options.targetDir}`); + + return { extracted, failed, lastError }; +} diff --git a/src/main/fs-error.ts b/src/main/fs-error.ts new file mode 100644 index 0000000..1ecdf90 --- /dev/null +++ b/src/main/fs-error.ts @@ -0,0 +1,56 @@ +// Maps low-level filesystem/OS error codes to a human-readable cause so that a +// generic "write failed" or "timeout" can be reported as the specific root cause +// (disk full, permission denied, ...). Pure + side-effect-free for testing. + +const DISK_ERROR_REASONS: Record<string, string> = { + ENOSPC: "Festplatte voll (ENOSPC)", + EDQUOT: "Speicher-Kontingent erschöpft (EDQUOT)", + EROFS: "Laufwerk schreibgeschützt (EROFS)", + EACCES: "Zugriff verweigert (EACCES)", + EPERM: "Operation nicht erlaubt (EPERM)", + EMFILE: "Zu viele offene Dateien (EMFILE)", + ENFILE: "System-Limit offener Dateien erreicht (ENFILE)", + EBUSY: "Datei/Laufwerk belegt (EBUSY)", + ENODEV: "Gerät nicht vorhanden (ENODEV)", + ENXIO: "Gerät getrennt (ENXIO)", + EIO: "Ein-/Ausgabefehler des Datenträgers (EIO)" +}; + +export function classifyDiskError(err: unknown): string | null { + const code = extractErrorCode(err); + if (code && DISK_ERROR_REASONS[code]) { + return DISK_ERROR_REASONS[code]; + } + // Some errors arrive as plain strings/messages without a `.code`; fall back to + // scanning the text for a known code token. + const text = errorText(err); + for (const knownCode of Object.keys(DISK_ERROR_REASONS)) { + if (text.includes(knownCode)) { + return DISK_ERROR_REASONS[knownCode]; + } + } + return null; +} + +function extractErrorCode(err: unknown): string { + if (err && typeof err === "object") { + const code = (err as { code?: unknown }).code; + if (typeof code === "string") { + return code.toUpperCase(); + } + } + return ""; +} + +function errorText(err: unknown): string { + if (typeof err === "string") { + return err; + } + if (err && typeof err === "object") { + const message = (err as { message?: unknown }).message; + if (typeof message === "string") { + return message; + } + } + return String(err ?? ""); +} diff --git a/src/main/integrity.ts b/src/main/integrity.ts new file mode 100644 index 0000000..165ea1a --- /dev/null +++ b/src/main/integrity.ts @@ -0,0 +1,159 @@ +import fs from "node:fs"; +import path from "node:path"; +import crypto from "node:crypto"; +import { ParsedHashEntry } from "../shared/types"; +import { MAX_MANIFEST_FILE_BYTES } from "./constants"; + +const manifestCache = new Map<string, { at: number; entries: Map<string, ParsedHashEntry> }>(); +const MANIFEST_CACHE_TTL_MS = 15000; + +function normalizeManifestKey(value: string): string { + return String(value || "") + .replace(/\\/g, "/") + .replace(/^\.\//, "") + .trim() + .toLowerCase(); +} + +export function parseHashLine(line: string): ParsedHashEntry | null { + const text = String(line || "").trim(); + if (!text || text.startsWith(";")) { + return null; + } + const md = text.match(/^([0-9a-fA-F]{32}|[0-9a-fA-F]{40})\s+\*?(.+)$/); + if (md) { + const digest = md[1].toLowerCase(); + return { + fileName: md[2].trim(), + algorithm: digest.length === 32 ? "md5" : "sha1", + digest + }; + } + const sfv = text.match(/^(.+?)\s+([0-9A-Fa-f]{8})$/); + if (sfv) { + return { + fileName: sfv[1].trim(), + algorithm: "crc32", + digest: sfv[2].toLowerCase() + }; + } + return null; +} + +export function readHashManifest(packageDir: string): Map<string, ParsedHashEntry> { + const cacheKey = path.resolve(packageDir); + const cached = manifestCache.get(cacheKey); + if (cached && Date.now() - cached.at <= MANIFEST_CACHE_TTL_MS) { + return new Map(cached.entries); + } + + const map = new Map<string, ParsedHashEntry>(); + const patterns: Array<[string, "crc32" | "md5" | "sha1"]> = [ + [".sfv", "crc32"], + [".md5", "md5"], + [".sha1", "sha1"] + ]; + + if (!fs.existsSync(packageDir)) { + return map; + } + + const manifestFiles = fs.readdirSync(packageDir, { withFileTypes: true }) + .filter((entry) => { + if (!entry.isFile()) { + return false; + } + const ext = path.extname(entry.name).toLowerCase(); + return patterns.some(([pattern]) => pattern === ext); + }) + .sort((a, b) => a.name.localeCompare(b.name, undefined, { numeric: true, sensitivity: "base" })); + + for (const entry of manifestFiles) { + if (!entry.isFile()) { + continue; + } + const ext = path.extname(entry.name).toLowerCase(); + const hit = patterns.find(([pattern]) => pattern === ext); + if (!hit) { + continue; + } + const filePath = path.join(packageDir, entry.name); + let lines: string[]; + try { + const stat = fs.statSync(filePath); + if (stat.size > MAX_MANIFEST_FILE_BYTES) { + continue; + } + lines = fs.readFileSync(filePath, "utf8").split(/\r?\n/); + } catch { + continue; + } + for (const line of lines) { + const parsed = parseHashLine(line); + if (!parsed) { + continue; + } + const key = normalizeManifestKey(parsed.fileName); + if (map.has(key)) { + continue; + } + map.set(key, parsed); + } + } + manifestCache.set(cacheKey, { at: Date.now(), entries: new Map(map) }); + return map; +} + +const crcTable = new Int32Array(256); +for (let i = 0; i < 256; i++) { + let c = i; + for (let j = 0; j < 8; j++) c = c & 1 ? (0xedb88320 ^ (c >>> 1)) : (c >>> 1); + crcTable[i] = c; +} + +function crc32Buffer(data: Buffer, seed = 0): number { + let crc = seed ^ -1; + for (let i = 0; i < data.length; i++) { + crc = (crc >>> 8) ^ crcTable[(crc ^ data[i]) & 0xff]; + } + return crc ^ -1; +} + +async function hashFile(filePath: string, algorithm: "crc32" | "md5" | "sha1"): Promise<string> { + if (algorithm === "crc32") { + const stream = fs.createReadStream(filePath, { highWaterMark: 1024 * 1024 }); + let crc = 0; + for await (const chunk of stream) { + crc = crc32Buffer(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk), crc); + await new Promise(r => setImmediate(r)); + } + return (crc >>> 0).toString(16).padStart(8, "0").toLowerCase(); + } + + const hash = crypto.createHash(algorithm); + const stream = fs.createReadStream(filePath, { highWaterMark: 1024 * 1024 }); + return await new Promise<string>((resolve, reject) => { + stream.on("data", (chunk: string | Buffer) => hash.update(typeof chunk === "string" ? Buffer.from(chunk) : chunk)); + stream.on("error", reject); + stream.on("end", () => resolve(hash.digest("hex").toLowerCase())); + }); +} + +export async function validateFileAgainstManifest(filePath: string, packageDir: string): Promise<{ ok: boolean; message: string }> { + const manifest = readHashManifest(packageDir); + if (manifest.size === 0) { + return { ok: true, message: "Kein Hash verfügbar" }; + } + const keyByBaseName = normalizeManifestKey(path.basename(filePath)); + const keyByRelativePath = normalizeManifestKey(path.relative(packageDir, filePath)); + const entry = manifest.get(keyByRelativePath) || manifest.get(keyByBaseName); + if (!entry) { + return { ok: true, message: "Kein Hash für Datei" }; + } + + const actual = await hashFile(filePath, entry.algorithm); + if (actual === entry.digest.toLowerCase()) { + return { ok: true, message: `${entry.algorithm.toUpperCase()} ok` }; + } + return { ok: false, message: `${entry.algorithm.toUpperCase()} mismatch` }; +} diff --git a/src/main/item-log.ts b/src/main/item-log.ts new file mode 100644 index 0000000..37b6c55 --- /dev/null +++ b/src/main/item-log.ts @@ -0,0 +1,232 @@ +import fs from "node:fs"; +import { logTimestamp } from "./log-timestamp"; +import path from "node:path"; +import crypto from "node:crypto"; + +const ITEM_LOG_FLUSH_INTERVAL_MS = 200; +const ITEM_LOG_RETENTION_DAYS = 30; + +type ItemLogLevel = "INFO" | "WARN" | "ERROR"; + +export interface ItemLogMeta { + itemId: string; + packageId: string; + packageName: string; + fileName: string; + targetPath: string; +} + +let itemLogsDir: string | null = null; +const knownLogPaths = new Map<string, string>(); +const pendingLinesByItem = new Map<string, string[]>(); +const initializedThisProcess = new Set<string>(); +let flushTimer: NodeJS.Timeout | null = null; + +function normalizeItemId(itemId: string): string { + const trimmed = String(itemId || "").trim(); + if (!trimmed) { + return ""; + } + const safePrefix = trimmed + .replace(/[^a-zA-Z0-9._-]/g, "_") + .replace(/_+/g, "_") + .slice(0, 64) + .replace(/^_+|_+$/g, ""); + const hash = crypto.createHash("sha1").update(trimmed).digest("hex").slice(0, 12); + return `${safePrefix || "item"}_${hash}`; +} + +function sanitizeFieldValue(value: unknown): string { + if (value === undefined || value === null) { + return ""; + } + if (typeof value === "string") { + return value.replace(/\r?\n/g, "\\n"); + } + if (typeof value === "number" || typeof value === "boolean") { + return String(value); + } + try { + return JSON.stringify(value).replace(/\r?\n/g, "\\n"); + } catch { + return String(value); + } +} + +function formatFields(fields?: Record<string, unknown>): string { + if (!fields) { + return ""; + } + const parts = Object.entries(fields) + .filter(([, value]) => value !== undefined && value !== null && sanitizeFieldValue(value) !== "") + .map(([key, value]) => `${key}=${sanitizeFieldValue(value)}`); + return parts.length > 0 ? ` | ${parts.join(" | ")}` : ""; +} + +function getItemLogFilePathFromNormalized(normalized: string): string | null { + if (!normalized || !itemLogsDir) { + return null; + } + const existing = knownLogPaths.get(normalized); + if (existing) { + return existing; + } + const logPath = path.join(itemLogsDir, `item_${normalized}.txt`); + knownLogPaths.set(normalized, logPath); + return logPath; +} + +function getItemLogFilePath(itemId: string): string | null { + return getItemLogFilePathFromNormalized(normalizeItemId(itemId)); +} + +function flushPending(): void { + for (const [itemId, lines] of pendingLinesByItem.entries()) { + if (lines.length === 0) { + continue; + } + const logPath = getItemLogFilePathFromNormalized(itemId); + if (!logPath) { + continue; + } + const chunk = lines.join(""); + pendingLinesByItem.set(itemId, []); + try { + fs.appendFileSync(logPath, chunk, "utf8"); + } catch { + } + } +} + +function scheduleFlush(): void { + if (flushTimer) { + return; + } + flushTimer = setTimeout(() => { + flushTimer = null; + flushPending(); + }, ITEM_LOG_FLUSH_INTERVAL_MS); +} + +async function cleanupOldItemLogs(dir: string): Promise<void> { + try { + const files = await fs.promises.readdir(dir); + const cutoff = Date.now() - ITEM_LOG_RETENTION_DAYS * 24 * 60 * 60 * 1000; + for (const file of files) { + if (!file.startsWith("item_") || !file.endsWith(".txt")) { + continue; + } + const filePath = path.join(dir, file); + try { + const stat = await fs.promises.stat(filePath); + if (stat.mtimeMs < cutoff) { + await fs.promises.unlink(filePath); + } + } catch { + } + } + } catch { + } +} + +function appendLine(itemId: string, line: string): void { + const normalized = normalizeItemId(itemId); + if (!normalized) { + return; + } + const lines = pendingLinesByItem.get(normalized) || []; + lines.push(line); + pendingLinesByItem.set(normalized, lines); + scheduleFlush(); +} + +export function initItemLogs(baseDir: string): void { + itemLogsDir = path.join(baseDir, "item-logs"); + try { + fs.mkdirSync(itemLogsDir, { recursive: true }); + } catch { + itemLogsDir = null; + return; + } + void cleanupOldItemLogs(itemLogsDir); +} + +export function ensureItemLog(meta: ItemLogMeta): string | null { + const normalizedItemId = normalizeItemId(meta.itemId); + const logPath = getItemLogFilePath(meta.itemId); + if (!logPath) { + return null; + } + try { + fs.mkdirSync(path.dirname(logPath), { recursive: true }); + if (!fs.existsSync(logPath)) { + fs.writeFileSync(logPath, "", "utf8"); + } + if (!initializedThisProcess.has(normalizedItemId)) { + initializedThisProcess.add(normalizedItemId); + const startedAt = logTimestamp(); + fs.appendFileSync( + logPath, + `=== Item-Log Start: ${startedAt} | itemId=${sanitizeFieldValue(String(meta.itemId || ""))} | logKey=${normalizedItemId} | fileName=${sanitizeFieldValue(meta.fileName)} ===\n`, + "utf8" + ); + fs.appendFileSync( + logPath, + `${logTimestamp()} [INFO] Item-Kontext initialisiert${formatFields({ + packageId: meta.packageId, + packageName: meta.packageName, + fileName: meta.fileName, + targetPath: meta.targetPath + })}\n`, + "utf8" + ); + } + } catch { + return null; + } + return logPath; +} + +export function logItemEvent( + itemId: string, + level: ItemLogLevel, + message: string, + fields?: Record<string, unknown> +): void { + const logPath = getItemLogFilePath(itemId); + if (!logPath) { + return; + } + const line = `${logTimestamp()} [${level}] ${message}${formatFields(fields)}\n`; + appendLine(itemId, line); +} + +export function getItemLogPath(itemId: string): string | null { + const logPath = getItemLogFilePath(itemId); + if (!logPath) { + return null; + } + return fs.existsSync(logPath) ? logPath : null; +} + +export function shutdownItemLogs(): void { + if (flushTimer) { + clearTimeout(flushTimer); + flushTimer = null; + } + flushPending(); + for (const itemId of knownLogPaths.keys()) { + const logPath = getItemLogFilePathFromNormalized(itemId); + if (!logPath) { + continue; + } + try { + fs.appendFileSync(logPath, `=== Item-Log Ende: ${logTimestamp()} ===\n`, "utf8"); + } catch { + } + } + pendingLinesByItem.clear(); + knownLogPaths.clear(); + initializedThisProcess.clear(); + itemLogsDir = null; +} diff --git a/src/main/link-export.ts b/src/main/link-export.ts new file mode 100644 index 0000000..8d20427 --- /dev/null +++ b/src/main/link-export.ts @@ -0,0 +1,116 @@ +import type { ParsedPackageInput, UiSnapshot } from "../shared/types"; +import { sanitizeFilename } from "./utils"; + +export type LinkExportSelection = { + packages: ParsedPackageInput[]; + packageCount: number; + linkCount: number; + defaultFileName: string; +}; + +function formatTimestampForFileName(date: Date): string { + const y = date.getFullYear(); + const mo = String(date.getMonth() + 1).padStart(2, "0"); + const d = String(date.getDate()).padStart(2, "0"); + const h = String(date.getHours()).padStart(2, "0"); + const mi = String(date.getMinutes()).padStart(2, "0"); + const s = String(date.getSeconds()).padStart(2, "0"); + return `${y}-${mo}-${d}_${h}-${mi}-${s}`; +} + +function buildDefaultFileName(packages: ParsedPackageInput[]): string { + if (packages.length === 1) { + const only = packages[0]; + if (only.links.length === 1) { + const itemName = sanitizeFilename(only.fileNames?.[0] || only.name || "link-export"); + return `${itemName}.txt`; + } + return `${sanitizeFilename(only.name || "paket-export")}.txt`; + } + return `rd-link-export-${formatTimestampForFileName(new Date())}.txt`; +} + +export function buildLinkExportSelection(snapshot: UiSnapshot, packageIds: string[], itemIds: string[]): LinkExportSelection { + const selectedPackageIds = new Set(packageIds); + const selectedItemIds = new Set(itemIds); + const packages: ParsedPackageInput[] = []; + + for (const packageId of snapshot.session.packageOrder) { + const pkg = snapshot.session.packages[packageId]; + if (!pkg) { + continue; + } + + const useWholePackage = selectedPackageIds.has(packageId); + const relevantItemIds = useWholePackage + ? pkg.itemIds + : pkg.itemIds.filter((itemId) => selectedItemIds.has(itemId)); + + if (relevantItemIds.length === 0) { + continue; + } + + const links: string[] = []; + const fileNames: string[] = []; + for (const itemId of relevantItemIds) { + const item = snapshot.session.items[itemId]; + if (!item || !String(item.url || "").trim()) { + continue; + } + links.push(String(item.url).trim()); + const rawFileName = String(item.fileName || "").trim(); + fileNames.push(rawFileName ? sanitizeFilename(rawFileName) : ""); + } + + if (links.length === 0) { + continue; + } + + const exportEntry: ParsedPackageInput = { + name: sanitizeFilename(pkg.name || "Paket"), + links + }; + if (fileNames.some((fileName) => fileName.length > 0)) { + exportEntry.fileNames = fileNames; + } + packages.push(exportEntry); + } + + const linkCount = packages.reduce((sum, pkg) => sum + pkg.links.length, 0); + return { + packages, + packageCount: packages.length, + linkCount, + defaultFileName: buildDefaultFileName(packages) + }; +} + +export function serializeLinkExportText(packages: ParsedPackageInput[]): string { + const lines: string[] = [ + "# rd-link-export: 1", + "# Re-import in Real-Debrid-Downloader keeps package names and optional file names.", + "" + ]; + + for (const pkg of packages) { + if (!pkg || !pkg.name || !Array.isArray(pkg.links) || pkg.links.length === 0) { + continue; + } + lines.push(`# package: ${sanitizeFilename(pkg.name)}`); + for (let index = 0; index < pkg.links.length; index += 1) { + const link = String(pkg.links[index] || "").trim(); + if (!link) { + continue; + } + const rawFileName = String(pkg.fileNames?.[index] || "").trim(); + const fileName = rawFileName ? sanitizeFilename(rawFileName) : ""; + if (fileName) { + lines.push(`# file: ${fileName}`); + } + lines.push(link); + } + lines.push(""); + } + + return `${lines.join("\n").trim()}\n`; +} diff --git a/src/main/link-parser.ts b/src/main/link-parser.ts new file mode 100644 index 0000000..79365bc --- /dev/null +++ b/src/main/link-parser.ts @@ -0,0 +1,42 @@ +import { ParsedPackageInput } from "../shared/types"; +import { inferPackageNameFromLinks, parsePackagesFromLinksText, sanitizeFilename, uniquePreserveOrder } from "./utils"; + +export function mergePackageInputs(packages: ParsedPackageInput[]): ParsedPackageInput[] { + const grouped = new Map<string, { links: string[]; fileNameByLink: Map<string, string> }>(); + for (const pkg of packages) { + const name = sanitizeFilename(pkg.name || inferPackageNameFromLinks(pkg.links)); + const current = grouped.get(name) ?? { links: [], fileNameByLink: new Map<string, string>() }; + for (let index = 0; index < pkg.links.length; index += 1) { + const link = String(pkg.links[index] || "").trim(); + if (!link) { + continue; + } + if (!current.links.includes(link)) { + current.links.push(link); + } + const rawFileName = String(pkg.fileNames?.[index] || "").trim(); + const fileName = rawFileName ? sanitizeFilename(rawFileName) : ""; + if (fileName && !current.fileNameByLink.has(link)) { + current.fileNameByLink.set(link, fileName); + } + } + grouped.set(name, current); + } + return Array.from(grouped.entries()).map(([name, entry]) => { + const links = uniquePreserveOrder(entry.links); + const fileNames = links.map((link) => entry.fileNameByLink.get(link) || ""); + return { + name, + links, + ...(fileNames.some((fileName) => fileName.length > 0) ? { fileNames } : {}) + }; + }); +} + +export function parseCollectorInput(rawText: string, packageName = ""): ParsedPackageInput[] { + const parsed = parsePackagesFromLinksText(rawText, packageName); + if (parsed.length === 0) { + return []; + } + return mergePackageInputs(parsed); +} diff --git a/src/main/log-timestamp.ts b/src/main/log-timestamp.ts new file mode 100644 index 0000000..e275bb9 --- /dev/null +++ b/src/main/log-timestamp.ts @@ -0,0 +1,11 @@ +export function logTimestamp(date: Date = new Date()): string { + const pad = (value: number, length = 2): string => String(value).padStart(length, "0"); + const offsetMinutes = -date.getTimezoneOffset(); + const sign = offsetMinutes >= 0 ? "+" : "-"; + const absOffset = Math.abs(offsetMinutes); + const offset = `${sign}${pad(Math.floor(absOffset / 60))}:${pad(absOffset % 60)}`; + return ( + `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}` + + `T${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}.${pad(date.getMilliseconds(), 3)}${offset}` + ); +} diff --git a/src/main/logger.ts b/src/main/logger.ts new file mode 100644 index 0000000..c724bca --- /dev/null +++ b/src/main/logger.ts @@ -0,0 +1,284 @@ +import fs from "node:fs"; +import { logTimestamp } from "./log-timestamp"; +import { recordRecentError } from "./error-ring"; +import path from "node:path"; + +export function isDebugFlagEnabled(value: string | undefined): boolean { + if (!value) { + return false; + } + return /^(1|true|yes|on)$/i.test(value.trim()); +} + +// Read once at startup. Enabling verbose DEBUG logging on the (unattended) server +// is a deliberate support action that requires a restart — the runtime-toggleable +// channel is the trace log, not this. +const DEBUG_ENABLED = isDebugFlagEnabled(process.env.RD_DEBUG); + +export function isDebugLoggingEnabled(): boolean { + return DEBUG_ENABLED; +} + +let logFilePath = path.resolve(process.cwd(), "rd_downloader.log"); +let fallbackLogFilePath: string | null = null; +const LOG_FLUSH_INTERVAL_MS = 120; +const LOG_BUFFER_LIMIT_CHARS = 1_000_000; +const LOG_MAX_FILE_BYTES = 10 * 1024 * 1024; +const rotateCheckAtByFile = new Map<string, number>(); + +type LogListener = (line: string) => void; +const logListeners = new Set<LogListener>(); +let legacyLogListener: LogListener | null = null; + +let pendingLines: string[] = []; +let pendingChars = 0; +let flushTimer: NodeJS.Timeout | null = null; +let flushInFlight = false; +let exitHookAttached = false; + +export function setLogListener(listener: LogListener | null): void { + if (legacyLogListener) { + logListeners.delete(legacyLogListener); + } + legacyLogListener = listener; + if (listener) { + logListeners.add(listener); + } +} + +export function addLogListener(listener: LogListener): void { + logListeners.add(listener); +} + +export function removeLogListener(listener: LogListener): void { + logListeners.delete(listener); + if (legacyLogListener === listener) { + legacyLogListener = null; + } +} + +export function configureLogger(baseDir: string): void { + logFilePath = path.join(baseDir, "rd_downloader.log"); + const cwdLogPath = path.resolve(process.cwd(), "rd_downloader.log"); + fallbackLogFilePath = cwdLogPath === logFilePath ? null : cwdLogPath; +} + +function appendLine(filePath: string, line: string): { ok: boolean; errorText: string } { + try { + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + fs.appendFileSync(filePath, line, "utf8"); + return { ok: true, errorText: "" }; + } catch (error) { + return { ok: false, errorText: String(error) }; + } +} + +async function appendChunk(filePath: string, chunk: string): Promise<{ ok: boolean; errorText: string }> { + try { + await fs.promises.mkdir(path.dirname(filePath), { recursive: true }); + await fs.promises.appendFile(filePath, chunk, "utf8"); + return { ok: true, errorText: "" }; + } catch (error) { + return { ok: false, errorText: String(error) }; + } +} + +function writeStderr(text: string): void { + try { + process.stderr.write(text); + } catch { + } +} + +function flushSyncPending(): void { + if (pendingLines.length === 0) { + return; + } + + const chunk = pendingLines.join(""); + pendingLines = []; + pendingChars = 0; + + rotateIfNeeded(logFilePath); + const primary = appendLine(logFilePath, chunk); + if (fallbackLogFilePath) { + rotateIfNeeded(fallbackLogFilePath); + const fallback = appendLine(fallbackLogFilePath, chunk); + if (!primary.ok && !fallback.ok) { + writeStderr(`LOGGER write failed (primary+fallback): ${primary.errorText} | ${fallback.errorText}\n`); + } + return; + } + + if (!primary.ok) { + writeStderr(`LOGGER write failed: ${primary.errorText}\n`); + } +} + +function scheduleFlush(immediate = false): void { + if (flushInFlight) { + return; + } + if (immediate) { + if (flushTimer) { + clearTimeout(flushTimer); + flushTimer = null; + } + void flushAsync(); + return; + } + if (flushTimer) { + return; + } + flushTimer = setTimeout(() => { + flushTimer = null; + void flushAsync(); + }, LOG_FLUSH_INTERVAL_MS); +} + +function rotateIfNeeded(filePath: string): void { + try { + const now = Date.now(); + const lastRotateCheckAt = rotateCheckAtByFile.get(filePath) || 0; + if (now - lastRotateCheckAt < 60_000) { + return; + } + rotateCheckAtByFile.set(filePath, now); + const stat = fs.statSync(filePath); + if (stat.size < LOG_MAX_FILE_BYTES) { + return; + } + const backup = `${filePath}.old`; + try { + fs.rmSync(backup, { force: true }); + } catch { + } + fs.renameSync(filePath, backup); + } catch { + } +} + +async function rotateIfNeededAsync(filePath: string): Promise<void> { + try { + const now = Date.now(); + const lastRotateCheckAt = rotateCheckAtByFile.get(filePath) || 0; + if (now - lastRotateCheckAt < 60_000) { + return; + } + rotateCheckAtByFile.set(filePath, now); + const stat = await fs.promises.stat(filePath); + if (stat.size < LOG_MAX_FILE_BYTES) { + return; + } + const backup = `${filePath}.old`; + await fs.promises.rm(backup, { force: true }).catch(() => {}); + await fs.promises.rename(filePath, backup); + } catch { + } +} + +async function flushAsync(): Promise<void> { + if (flushInFlight || pendingLines.length === 0) { + return; + } + + flushInFlight = true; + // Move (not copy) the pending lines out and take ownership. A concurrent write() + // during the await below pushes new lines AND can trim the 1MB cap from the FRONT + // of pendingLines; the old count-based removal (pendingLines.slice(snapshot.length)) + // then sliced off the wrong lines and dropped unwritten ones. Resetting the buffer + // here means await-time writes queue independently and nothing desyncs. + const linesSnapshot = pendingLines; + pendingLines = []; + pendingChars = 0; + const chunk = linesSnapshot.join(""); + + try { + await rotateIfNeededAsync(logFilePath); + const primary = await appendChunk(logFilePath, chunk); + let wroteAny = primary.ok; + if (fallbackLogFilePath) { + await rotateIfNeededAsync(fallbackLogFilePath); + const fallback = await appendChunk(fallbackLogFilePath, chunk); + wroteAny = wroteAny || fallback.ok; + if (!primary.ok && !fallback.ok) { + writeStderr(`LOGGER write failed (primary+fallback): ${primary.errorText} | ${fallback.errorText}\n`); + } + } else if (!primary.ok) { + writeStderr(`LOGGER write failed: ${primary.errorText}\n`); + } + if (!wroteAny) { + // Write failed: requeue the unwritten lines AHEAD of anything that arrived + // during the await (preserve order), then re-apply the buffer cap so a + // persistent write failure cannot grow the buffer without bound. + pendingLines = linesSnapshot.concat(pendingLines); + pendingChars += chunk.length; + while (pendingChars > LOG_BUFFER_LIMIT_CHARS && pendingLines.length > 1) { + const removed = pendingLines.shift(); + if (!removed) { + break; + } + pendingChars = Math.max(0, pendingChars - removed.length); + } + } + } finally { + flushInFlight = false; + if (pendingLines.length > 0) { + scheduleFlush(); + } + } +} + +function ensureExitHook(): void { + if (exitHookAttached) { + return; + } + exitHookAttached = true; + process.once("beforeExit", flushSyncPending); + process.once("exit", flushSyncPending); +} + +function write(level: "DEBUG" | "INFO" | "WARN" | "ERROR", message: string): void { + ensureExitHook(); + const ts = logTimestamp(); + const line = `${ts} [${level}] ${message}\n`; + pendingLines.push(line); + pendingChars += line.length; + + // Single chokepoint: every WARN/ERROR also lands in the in-memory ring so + // "what failed recently" is answerable even after the file rotates. + if (level === "ERROR" || level === "WARN") { + recordRecentError(level, message, ts); + } + + for (const listener of logListeners) { + try { listener(line); } catch { } + } + + while (pendingChars > LOG_BUFFER_LIMIT_CHARS && pendingLines.length > 1) { + const removed = pendingLines.shift(); + if (!removed) { + break; + } + pendingChars = Math.max(0, pendingChars - removed.length); + } + + if (level === "ERROR") { + scheduleFlush(true); + return; + } + scheduleFlush(); +} + +export const logger = { + // Gated to a no-op when RD_DEBUG is unset so verbose call sites cost nothing + // (no formatting, no allocation) in the normal/production path. + debug: DEBUG_ENABLED ? (msg: string): void => write("DEBUG", msg) : (_msg: string): void => {}, + info: (msg: string): void => write("INFO", msg), + warn: (msg: string): void => write("WARN", msg), + error: (msg: string): void => write("ERROR", msg) +}; + +export function getLogFilePath(): string { + return logFilePath; +} diff --git a/src/main/main.ts b/src/main/main.ts new file mode 100644 index 0000000..1be2cf1 --- /dev/null +++ b/src/main/main.ts @@ -0,0 +1,881 @@ +import fs from "node:fs"; +import path from "node:path"; +import { app, BrowserWindow, clipboard, dialog, ipcMain, IpcMainInvokeEvent, Menu, shell, Tray } from "electron"; +import { AddLinksPayload, AppSettings, DebridProvider, EnableRemoteDiagnosticsInput, UpdateInstallProgress } from "../shared/types"; +import { AppController } from "./app-controller"; +import { IPC_CHANNELS } from "../shared/ipc"; +import { getLogFilePath, logger } from "./logger"; +import { getRecentErrors } from "./error-ring"; +import { sendNotification } from "./notify"; +import { APP_NAME } from "./constants"; +import { extractHttpLinksFromText } from "./utils"; +import { cleanupStaleSubstDrives, shutdownDaemon } from "./extractor"; + +function validateString(value: unknown, name: string): string { + if (typeof value !== "string") { + throw new Error(`${name} muss ein String sein`); + } + return value; +} + +function validatePlainObject(value: unknown, name: string): Record<string, unknown> { + if (!value || typeof value !== "object" || Array.isArray(value)) { + throw new Error(`${name} muss ein Objekt sein`); + } + return value as Record<string, unknown>; +} + +const IMPORT_QUEUE_MAX_BYTES = 10 * 1024 * 1024; +const RENAME_PACKAGE_MAX_CHARS = 240; +const RESETTABLE_PROVIDER_KEYS = new Set<DebridProvider>([ + "realdebrid", + "megadebrid-api", + "megadebrid-web", + "bestdebrid", + "alldebrid", + "ddownload", + "onefichier", + "debridlink", + "linksnappy" +]); +function validateStringArray(value: unknown, name: string): string[] { + if (!Array.isArray(value) || !value.every(v => typeof v === "string")) { + throw new Error(`${name} muss ein String-Array sein`); + } + return value as string[]; +} + +const gotLock = app.requestSingleInstanceLock(); +if (!gotLock) { + app.exit(0); + process.exit(0); +} + +process.on("uncaughtException", (error) => { + logger.error(`Uncaught Exception: ${String(error?.stack || error)}`); +}); +process.on("unhandledRejection", (reason) => { + const detail = reason instanceof Error ? (reason.stack || reason.message) : String(reason); + logger.error(`Unhandled Rejection: ${detail}`); +}); +// Node-Warnungen (z.B. MaxListenersExceeded, DeprecationWarning) sind ein +// Frühindikator für Leaks/Fehlnutzung in einem langlaufenden Server-Prozess. +process.on("warning", (warning) => { + logger.warn(`Node-Warnung: ${warning.name}: ${warning.message}${warning.stack ? ` | ${warning.stack.replace(/\s*\n\s*/g, " ⏎ ")}` : ""}`); +}); + +let mainWindow: BrowserWindow | null = null; +let tray: Tray | null = null; +let clipboardTimer: ReturnType<typeof setInterval> | null = null; +let updateQuitTimer: ReturnType<typeof setTimeout> | null = null; +let scheduledStartTimer: ReturnType<typeof setTimeout> | null = null; +let lastClipboardText = ""; +const controller = new AppController(); +const CLIPBOARD_MAX_TEXT_CHARS = 50_000; + +function isDevMode(): boolean { + return process.env.NODE_ENV === "development"; +} + +// Single owner of the scheduled-start timer. startOnPast: a past time entered +// interactively starts right away; at boot a stale past time is cleared instead +// (an unattended auto-start at boot would race autoResumeOnStart's conflict gate). +function armScheduledStart(schedMs: number, opts: { startOnPast: boolean }): void { + if (scheduledStartTimer !== null) { + clearTimeout(scheduledStartTimer); + scheduledStartTimer = null; + } + if (!schedMs || schedMs <= 0) { + return; + } + const delay = schedMs - Date.now(); + if (delay <= 0) { + if (opts.startOnPast) { + void controller.start().catch((err) => logger.warn(`Scheduled-Start Fehler: ${String(err)}`)); + } else { + logger.warn(`Geplanter Start (${new Date(schedMs).toLocaleString()}) lag beim App-Start in der Vergangenheit — verworfen`); + } + controller.updateSettings({ scheduledStartEpochMs: 0 }); + return; + } + scheduledStartTimer = setTimeout(() => { + scheduledStartTimer = null; + void controller.start().catch((err) => logger.warn(`Scheduled-Start Fehler: ${String(err)}`)); + controller.updateSettings({ scheduledStartEpochMs: 0 }); + }, delay); + logger.info(`Geplanter Start gearmt: ${new Date(schedMs).toLocaleString()}`); +} + +function createWindow(): BrowserWindow { + const window = new BrowserWindow({ + width: 1920, + height: 1080, + minWidth: 1120, + minHeight: 760, + backgroundColor: "#070b14", + title: `${APP_NAME} - v${controller.getVersion()}`, + icon: path.join(app.getAppPath(), "assets", "app_icon.ico"), + webPreferences: { + contextIsolation: true, + nodeIntegration: false, + preload: path.join(__dirname, "../preload/preload.js") + } + }); + + if (!isDevMode()) { + window.webContents.session.webRequest.onHeadersReceived((details, callback) => { + callback({ + responseHeaders: { + ...details.responseHeaders, + "Content-Security-Policy": [ + "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self' https://api.real-debrid.com https://codeberg.org https://bestdebrid.com https://api.alldebrid.com https://www.mega-debrid.eu https://ddownload.com https://ddl.to https://debrid-link.com" + ] + } + }); + }); + } + + window.setMenuBarVisibility(false); + window.setAutoHideMenuBar(true); + + if (isDevMode()) { + void window.loadURL("http://localhost:5173"); + } else { + void window.loadFile(path.join(app.getAppPath(), "build", "renderer", "index.html")); + } + + return window; +} + +let rendererReloadTimes: number[] = []; +const RENDERER_RELOAD_WINDOW_MS = 5 * 60 * 1000; +const RENDERER_RELOAD_MAX = 3; + +// Circuit breaker: recover from a one-off renderer crash by reloading, but stop +// after a few crashes in a short window so a reproducible crash can't spin into a +// reload loop that pegs an unattended server. +function allowRendererReload(): boolean { + const now = Date.now(); + rendererReloadTimes = rendererReloadTimes.filter((t) => now - t < RENDERER_RELOAD_WINDOW_MS); + if (rendererReloadTimes.length >= RENDERER_RELOAD_MAX) { + return false; + } + rendererReloadTimes.push(now); + return true; +} + +function bindMainWindowLifecycle(window: BrowserWindow): void { + window.on("close", (event) => { + const settings = controller.getSettings(); + if (settings.minimizeToTray && tray) { + event.preventDefault(); + window.hide(); + } + }); + + window.on("closed", () => { + if (mainWindow === window) { + mainWindow = null; + } + }); + + window.webContents.on("render-process-gone", (_event, details) => { + logger.error(`Renderer-Prozess beendet: reason=${details.reason} exitCode=${details.exitCode ?? "?"}`); + if (details.reason === "clean-exit" || window.isDestroyed()) { + return; + } + if (allowRendererReload()) { + logger.warn("Renderer wird automatisch neu geladen (Wiederherstellung nach Absturz)"); + try { + window.webContents.reload(); + } catch (error) { + logger.error(`Renderer-Reload fehlgeschlagen: ${String(error)}`); + } + } else { + logger.error(`Renderer-Absturz: Auto-Reload gestoppt (mehr als ${RENDERER_RELOAD_MAX} Abstürze in ${RENDERER_RELOAD_WINDOW_MS / 60000} Min) - manueller Neustart nötig`); + } + }); + + // Nur protokollieren, niemals killen/neu laden: "unresponsive" feuert auch + // während legitimer langer Sync-Arbeit (große JSON-Serialisierung) und erholt + // sich meist von selbst. Eingreifen würde einen Schluckauf zum Ausfall machen. + window.webContents.on("unresponsive", () => { + logger.warn("Renderer reagiert nicht (unresponsive) - evtl. langer Sync-Task, warte auf Erholung"); + }); + window.webContents.on("responsive", () => { + logger.info("Renderer wieder reaktionsfähig (responsive)"); + }); +} + +function createTray(): void { + if (tray) { + return; + } + const iconPath = path.join(app.getAppPath(), "assets", "app_icon.ico"); + try { + tray = new Tray(iconPath); + } catch (error) { + logger.warn(`Tray-Icon konnte nicht erstellt werden (Headless/RDP/Service?): ${String(error)} - Minimize-to-Tray steht nicht zur Verfuegung, Fenster bleibt sichtbar.`); + return; + } + tray.setToolTip(APP_NAME); + const contextMenu = Menu.buildFromTemplate([ + { label: "Anzeigen", click: () => { mainWindow?.show(); mainWindow?.focus(); } }, + { type: "separator" }, + { label: "Start", click: () => { void controller.start().catch((err) => logger.warn(`Tray Start Fehler: ${String(err)}`)); } }, + { label: "Stop", click: () => { controller.stop(); } }, + { type: "separator" }, + { label: "Beenden", click: () => { app.quit(); } } + ]); + tray.setContextMenu(contextMenu); + tray.on("double-click", () => { + mainWindow?.show(); + mainWindow?.focus(); + }); +} + +function destroyTray(): void { + if (tray) { + tray.destroy(); + tray = null; + } +} + +function extractLinksFromText(text: string): string[] { + return extractHttpLinksFromText(text); +} + +function normalizeClipboardText(text: string): string { + const truncateUnicodeSafe = (value: string, maxChars: number): string => { + if (value.length <= maxChars) { + return value; + } + const points = Array.from(value); + if (points.length <= maxChars) { + return value; + } + return points.slice(0, maxChars).join(""); + }; + + const normalized = String(text || ""); + if (normalized.length <= CLIPBOARD_MAX_TEXT_CHARS) { + return normalized; + } + const truncated = truncateUnicodeSafe(normalized, CLIPBOARD_MAX_TEXT_CHARS); + const lastBreak = Math.max( + truncated.lastIndexOf("\n"), + truncated.lastIndexOf("\r"), + truncated.lastIndexOf("\t"), + truncated.lastIndexOf(" ") + ); + if (lastBreak >= Math.floor(CLIPBOARD_MAX_TEXT_CHARS * 0.7)) { + return truncated.slice(0, lastBreak); + } + return truncated; +} + +function startClipboardWatcher(): void { + if (clipboardTimer) { + return; + } + lastClipboardText = normalizeClipboardText(clipboard.readText()); + clipboardTimer = setInterval(() => { + let text: string; + try { + text = normalizeClipboardText(clipboard.readText()); + } catch { + return; + } + if (text === lastClipboardText || !text.trim()) { + return; + } + lastClipboardText = text; + const links = extractLinksFromText(text); + if (links.length > 0 && mainWindow && !mainWindow.isDestroyed()) { + mainWindow.webContents.send(IPC_CHANNELS.CLIPBOARD_DETECTED, links); + } + }, 2000); +} + +function stopClipboardWatcher(): void { + if (clipboardTimer) { + clearInterval(clipboardTimer); + clipboardTimer = null; + } +} + +function updateClipboardWatcher(): void { + const settings = controller.getSettings(); + if (settings.clipboardWatch) { + startClipboardWatcher(); + } else { + stopClipboardWatcher(); + } +} + +function updateTray(): void { + const settings = controller.getSettings(); + if (settings.minimizeToTray) { + createTray(); + } else { + destroyTray(); + } +} + +function registerIpcHandlers(): void { + ipcMain.handle(IPC_CHANNELS.GET_SNAPSHOT, () => controller.getSnapshot()); + ipcMain.handle(IPC_CHANNELS.GET_VERSION, () => controller.getVersion()); + ipcMain.handle(IPC_CHANNELS.CHECK_UPDATES, async () => controller.checkUpdates()); + ipcMain.handle(IPC_CHANNELS.INSTALL_UPDATE, async () => { + const result = await controller.installUpdate((progress: UpdateInstallProgress) => { + if (!mainWindow || mainWindow.isDestroyed()) { + return; + } + mainWindow.webContents.send(IPC_CHANNELS.UPDATE_INSTALL_PROGRESS, progress); + }); + if (result.started) { + updateQuitTimer = setTimeout(() => { + app.quit(); + }, 5000); + } + return result; + }); + ipcMain.handle(IPC_CHANNELS.OPEN_EXTERNAL, async (_event: IpcMainInvokeEvent, rawUrl: string) => { + try { + const parsed = new URL(String(rawUrl || "").trim()); + if (parsed.protocol !== "https:" && parsed.protocol !== "http:") { + return false; + } + await shell.openExternal(parsed.toString()); + return true; + } catch { + return false; + } + }); + ipcMain.handle(IPC_CHANNELS.UPDATE_SETTINGS, (_event: IpcMainInvokeEvent, partial: Partial<AppSettings>) => { + const validated = validatePlainObject(partial ?? {}, "partial"); + const result = controller.updateSettings(validated as Partial<AppSettings>); + updateClipboardWatcher(); + updateTray(); + armScheduledStart(result.scheduledStartEpochMs || 0, { startOnPast: true }); + return result; + }); + ipcMain.handle(IPC_CHANNELS.RESET_PROVIDER_DAILY_USAGE, (_event: IpcMainInvokeEvent, provider: string) => { + const validatedProvider = validateString(provider, "provider") as DebridProvider; + if (!RESETTABLE_PROVIDER_KEYS.has(validatedProvider)) { + throw new Error("provider ist ungültig"); + } + return controller.resetProviderDailyUsage(validatedProvider); + }); + ipcMain.handle(IPC_CHANNELS.RESET_DEBRID_LINK_API_KEY_DAILY_USAGE, (_event: IpcMainInvokeEvent, keyId: string) => { + const validatedKeyId = validateString(keyId, "keyId").trim(); + if (!validatedKeyId) { + throw new Error("keyId ist ungültig"); + } + return controller.resetDebridLinkApiKeyDailyUsage(validatedKeyId); + }); + ipcMain.handle(IPC_CHANNELS.ADD_LINKS, (_event: IpcMainInvokeEvent, payload: AddLinksPayload) => { + validatePlainObject(payload ?? {}, "payload"); + validateString(payload?.rawText, "rawText"); + if (payload.packageName !== undefined) { + validateString(payload.packageName, "packageName"); + } + if (payload.duplicatePolicy !== undefined && payload.duplicatePolicy !== "keep" && payload.duplicatePolicy !== "skip" && payload.duplicatePolicy !== "overwrite") { + throw new Error("duplicatePolicy muss 'keep', 'skip' oder 'overwrite' sein"); + } + return controller.addLinks(payload); + }); + ipcMain.handle(IPC_CHANNELS.ADD_CONTAINERS, async (_event: IpcMainInvokeEvent, filePaths: string[]) => { + const validPaths = validateStringArray(filePaths ?? [], "filePaths"); + const safePaths = validPaths.filter((p) => path.isAbsolute(p)); + return controller.addContainers(safePaths); + }); + ipcMain.handle(IPC_CHANNELS.GET_START_CONFLICTS, () => controller.getStartConflicts()); + ipcMain.handle(IPC_CHANNELS.RESOLVE_START_CONFLICT, (_event: IpcMainInvokeEvent, packageId: string, policy: "keep" | "skip" | "overwrite") => { + validateString(packageId, "packageId"); + validateString(policy, "policy"); + if (policy !== "keep" && policy !== "skip" && policy !== "overwrite") { + throw new Error("policy muss 'keep', 'skip' oder 'overwrite' sein"); + } + return controller.resolveStartConflict(packageId, policy); + }); + ipcMain.handle(IPC_CHANNELS.CLEAR_ALL, () => controller.clearAll()); + ipcMain.handle(IPC_CHANNELS.START, () => { + if (scheduledStartTimer !== null) { + clearTimeout(scheduledStartTimer); + scheduledStartTimer = null; + controller.updateSettings({ scheduledStartEpochMs: 0 }); + } + return controller.start(); + }); + ipcMain.handle(IPC_CHANNELS.START_PACKAGES, (_event: IpcMainInvokeEvent, packageIds: string[]) => { + validateStringArray(packageIds ?? [], "packageIds"); + return controller.startPackages(packageIds ?? []); + }); + ipcMain.handle(IPC_CHANNELS.START_ITEMS, (_event: IpcMainInvokeEvent, itemIds: string[]) => { + validateStringArray(itemIds ?? [], "itemIds"); + return controller.startItems(itemIds ?? []); + }); + ipcMain.handle(IPC_CHANNELS.STOP, () => controller.stop()); + ipcMain.handle(IPC_CHANNELS.TOGGLE_PAUSE, () => controller.togglePause()); + ipcMain.handle(IPC_CHANNELS.CANCEL_PACKAGE, (_event: IpcMainInvokeEvent, packageId: string) => { + validateString(packageId, "packageId"); + return controller.cancelPackage(packageId); + }); + ipcMain.handle(IPC_CHANNELS.RENAME_PACKAGE, (_event: IpcMainInvokeEvent, packageId: string, newName: string) => { + validateString(packageId, "packageId"); + validateString(newName, "newName"); + if (newName.length > RENAME_PACKAGE_MAX_CHARS) { + throw new Error(`newName zu lang (max ${RENAME_PACKAGE_MAX_CHARS} Zeichen)`); + } + return controller.renamePackage(packageId, newName); + }); + ipcMain.handle(IPC_CHANNELS.REORDER_PACKAGES, (_event: IpcMainInvokeEvent, packageIds: string[]) => { + validateStringArray(packageIds, "packageIds"); + return controller.reorderPackages(packageIds); + }); + ipcMain.handle(IPC_CHANNELS.REMOVE_ITEM, (_event: IpcMainInvokeEvent, itemId: string) => { + validateString(itemId, "itemId"); + return controller.removeItem(itemId); + }); + ipcMain.handle(IPC_CHANNELS.TOGGLE_PACKAGE, (_event: IpcMainInvokeEvent, packageId: string) => { + validateString(packageId, "packageId"); + return controller.togglePackage(packageId); + }); + ipcMain.handle(IPC_CHANNELS.EXPORT_PACKAGE_SELECTION, async (_event: IpcMainInvokeEvent, packageIds: string[]) => { + const validPackageIds = validateStringArray(packageIds ?? [], "packageIds"); + const exported = controller.exportPackageSelection(validPackageIds); + if (exported.packageCount === 0 || exported.linkCount === 0) { + return { saved: false, packageCount: 0, linkCount: 0 }; + } + const options = { + defaultPath: exported.defaultFileName, + filters: [{ name: "Link Export", extensions: ["txt"] }] + }; + const result = mainWindow ? await dialog.showSaveDialog(mainWindow, options) : await dialog.showSaveDialog(options); + if (result.canceled || !result.filePath) { + return { saved: false, packageCount: exported.packageCount, linkCount: exported.linkCount }; + } + await fs.promises.writeFile(result.filePath, exported.text, "utf8"); + return { saved: true, packageCount: exported.packageCount, linkCount: exported.linkCount, filePath: result.filePath }; + }); + ipcMain.handle(IPC_CHANNELS.EXPORT_ITEM_SELECTION, async (_event: IpcMainInvokeEvent, itemIds: string[]) => { + const validItemIds = validateStringArray(itemIds ?? [], "itemIds"); + const exported = controller.exportItemSelection(validItemIds); + if (exported.packageCount === 0 || exported.linkCount === 0) { + return { saved: false, packageCount: 0, linkCount: 0 }; + } + const options = { + defaultPath: exported.defaultFileName, + filters: [{ name: "Link Export", extensions: ["txt"] }] + }; + const result = mainWindow ? await dialog.showSaveDialog(mainWindow, options) : await dialog.showSaveDialog(options); + if (result.canceled || !result.filePath) { + return { saved: false, packageCount: exported.packageCount, linkCount: exported.linkCount }; + } + await fs.promises.writeFile(result.filePath, exported.text, "utf8"); + return { saved: true, packageCount: exported.packageCount, linkCount: exported.linkCount, filePath: result.filePath }; + }); + ipcMain.handle(IPC_CHANNELS.RETRY_EXTRACTION, (_event: IpcMainInvokeEvent, packageId: string) => { + validateString(packageId, "packageId"); + return controller.retryExtraction(packageId); + }); + ipcMain.handle(IPC_CHANNELS.EXTRACT_NOW, (_event: IpcMainInvokeEvent, packageId: string) => { + validateString(packageId, "packageId"); + return controller.extractNow(packageId); + }); + ipcMain.handle(IPC_CHANNELS.RESET_PACKAGE, (_event: IpcMainInvokeEvent, packageId: string) => { + validateString(packageId, "packageId"); + return controller.resetPackage(packageId); + }); + ipcMain.handle(IPC_CHANNELS.SET_PACKAGE_PRIORITY, (_event: IpcMainInvokeEvent, packageId: string, priority: string) => { + validateString(packageId, "packageId"); + validateString(priority, "priority"); + if (priority !== "high" && priority !== "normal" && priority !== "low") { + throw new Error("priority muss 'high', 'normal' oder 'low' sein"); + } + return controller.setPackagePriority(packageId, priority); + }); + ipcMain.handle(IPC_CHANNELS.SKIP_ITEMS, (_event: IpcMainInvokeEvent, itemIds: string[]) => { + validateStringArray(itemIds ?? [], "itemIds"); + return controller.skipItems(itemIds ?? []); + }); + ipcMain.handle(IPC_CHANNELS.RESET_ITEMS, (_event: IpcMainInvokeEvent, itemIds: string[]) => { + validateStringArray(itemIds ?? [], "itemIds"); + return controller.resetItems(itemIds ?? []); + }); + ipcMain.handle(IPC_CHANNELS.GET_HISTORY, () => controller.getHistory()); + ipcMain.handle(IPC_CHANNELS.CLEAR_HISTORY, () => controller.clearHistory()); + ipcMain.handle(IPC_CHANNELS.REMOVE_HISTORY_ENTRY, (_event: IpcMainInvokeEvent, entryId: string) => { + validateString(entryId, "entryId"); + return controller.removeHistoryEntry(entryId); + }); + ipcMain.handle(IPC_CHANNELS.EXPORT_QUEUE, async () => { + const options = { + defaultPath: `rd-queue-export.json`, + filters: [{ name: "Queue Export", extensions: ["json"] }] + }; + const result = mainWindow ? await dialog.showSaveDialog(mainWindow, options) : await dialog.showSaveDialog(options); + if (result.canceled || !result.filePath) { + return { saved: false }; + } + const json = controller.exportQueue(); + await fs.promises.writeFile(result.filePath, json, "utf8"); + return { saved: true }; + }); + ipcMain.handle(IPC_CHANNELS.IMPORT_QUEUE, (_event: IpcMainInvokeEvent, json: string) => { + validateString(json, "json"); + const bytes = Buffer.byteLength(json, "utf8"); + if (bytes > IMPORT_QUEUE_MAX_BYTES) { + throw new Error(`Queue-Import zu groß (max ${IMPORT_QUEUE_MAX_BYTES} Bytes)`); + } + return controller.importQueue(json); + }); + ipcMain.handle(IPC_CHANNELS.TOGGLE_CLIPBOARD, () => { + const settings = controller.getSettings(); + const next = !settings.clipboardWatch; + controller.updateSettings({ clipboardWatch: next }); + updateClipboardWatcher(); + return next; + }); + ipcMain.handle(IPC_CHANNELS.PICK_FOLDER, async () => { + const options = { + properties: ["openDirectory", "createDirectory"] as Array<"openDirectory" | "createDirectory"> + }; + const result = mainWindow ? await dialog.showOpenDialog(mainWindow, options) : await dialog.showOpenDialog(options); + return result.canceled ? null : result.filePaths[0] || null; + }); + ipcMain.handle(IPC_CHANNELS.PICK_CONTAINERS, async () => { + const options = { + properties: ["openFile", "multiSelections"] as Array<"openFile" | "multiSelections">, + filters: [ + { name: "Container", extensions: ["dlc"] }, + { name: "Alle Dateien", extensions: ["*"] } + ] + }; + const result = mainWindow ? await dialog.showOpenDialog(mainWindow, options) : await dialog.showOpenDialog(options); + return result.canceled ? [] : result.filePaths; + }); + ipcMain.handle(IPC_CHANNELS.GET_SESSION_STATS, () => controller.getSessionStats()); + ipcMain.handle(IPC_CHANNELS.RESET_SESSION_STATS, () => controller.resetSessionStats()); + ipcMain.handle(IPC_CHANNELS.RESET_DOWNLOAD_STATS, () => controller.resetDownloadStats()); + + ipcMain.handle(IPC_CHANNELS.RESTART, () => { + app.relaunch(); + app.quit(); + }); + + ipcMain.handle(IPC_CHANNELS.QUIT, () => { + app.quit(); + }); + + ipcMain.handle(IPC_CHANNELS.EXPORT_BACKUP, async () => { + const options = { + defaultPath: `${new Date().toISOString().slice(0, 10).split("-").reverse().join("-")}-mdd-backup.mdd`, + filters: [{ name: "MDD Backup", extensions: ["mdd"] }] + }; + const result = mainWindow ? await dialog.showSaveDialog(mainWindow, options) : await dialog.showSaveDialog(options); + if (result.canceled || !result.filePath) { + return { saved: false }; + } + const encrypted = controller.exportBackup(); + await fs.promises.writeFile(result.filePath, encrypted); + return { saved: true }; + }); + + ipcMain.handle(IPC_CHANNELS.EXPORT_SUPPORT_BUNDLE, async () => { + const options = { + defaultPath: controller.getSupportBundleDefaultFileName(), + filters: [{ name: "Support Bundle", extensions: ["zip"] }] + }; + const result = mainWindow ? await dialog.showSaveDialog(mainWindow, options) : await dialog.showSaveDialog(options); + if (result.canceled || !result.filePath) { + return { saved: false }; + } + const exported = await controller.exportSupportBundle(); + await fs.promises.writeFile(result.filePath, exported.buffer); + return { saved: true, filePath: result.filePath }; + }); + + ipcMain.handle(IPC_CHANNELS.OPEN_LOG, async () => { + const logPath = getLogFilePath(); + await shell.openPath(logPath); + }); + + ipcMain.handle(IPC_CHANNELS.OPEN_AUDIT_LOG, async () => { + const logPath = controller.getAuditLogPath(); + if (logPath) { + await shell.openPath(logPath); + } + }); + + ipcMain.handle(IPC_CHANNELS.OPEN_RENAME_LOG, async () => { + const logPath = controller.getRenameLogPath(); + if (logPath) { + await shell.openPath(logPath); + } + }); + + ipcMain.handle(IPC_CHANNELS.OPEN_SESSION_LOG, async () => { + const logPath = controller.getSessionLogPath(); + if (logPath) { + await shell.openPath(logPath); + } + }); + + ipcMain.handle(IPC_CHANNELS.OPEN_TRACE_LOG, async () => { + const logPath = controller.getTraceLogPath(); + if (logPath) { + await shell.openPath(logPath); + } + }); + + ipcMain.handle(IPC_CHANNELS.OPEN_PACKAGE_LOG, async (_event: IpcMainInvokeEvent, packageId: string) => { + validateString(packageId, "packageId"); + const logPath = controller.getPackageLogPath(packageId); + if (logPath) { + await shell.openPath(logPath); + } + }); + + ipcMain.handle(IPC_CHANNELS.GET_DEBUG_SETUP_CHECK, async () => controller.getDebugSetupCheck()); + + ipcMain.handle(IPC_CHANNELS.GET_RECENT_ERRORS, async () => getRecentErrors()); + + ipcMain.handle(IPC_CHANNELS.TEST_NOTIFY, async (_event: IpcMainInvokeEvent, url: string, mention: string) => { + validateString(url, "url"); + return sendNotification(url, { + title: "🔔 Test-Benachrichtigung", + message: "Webhook funktioniert — Benachrichtigungen kommen hier an.", + mention: typeof mention === "string" ? mention : "" + }); + }); + + ipcMain.handle(IPC_CHANNELS.GET_TRACE_CONFIG, async () => controller.getTraceConfig()); + + ipcMain.handle(IPC_CHANNELS.SET_TRACE_ENABLED, async (_event: IpcMainInvokeEvent, enabled: boolean, note?: string, durationMinutes?: number) => { + if (typeof enabled !== "boolean") { + throw new Error("enabled muss ein Boolean sein"); + } + if (note !== undefined) { + validateString(note, "note"); + } + if (durationMinutes !== undefined && (!Number.isFinite(durationMinutes) || durationMinutes <= 0)) { + throw new Error("durationMinutes muss eine positive Zahl sein"); + } + return controller.setTraceEnabled(enabled, note, durationMinutes ? durationMinutes * 60 * 1000 : undefined); + }); + + ipcMain.handle(IPC_CHANNELS.ROTATE_DEBUG_TOKEN, async () => { + const rotated = controller.rotateDebugToken(); + return { path: rotated.path }; + }); + + ipcMain.handle(IPC_CHANNELS.GET_REMOTE_DIAGNOSTICS, async () => { + return controller.getRemoteDiagnostics(); + }); + + ipcMain.handle(IPC_CHANNELS.ENABLE_REMOTE_DIAGNOSTICS, async (_event: IpcMainInvokeEvent, input: EnableRemoteDiagnosticsInput) => { + if (!input || (input.hostMode !== "local" && input.hostMode !== "network")) { + throw new Error("hostMode muss 'local' oder 'network' sein"); + } + const allowlist = Array.isArray(input.allowlist) ? input.allowlist.map((entry) => String(entry)) : []; + return controller.enableRemoteDiagnostics({ + hostMode: input.hostMode, + publicHost: String(input.publicHost || ""), + port: input.port ? Number(input.port) : undefined, + allowlist, + name: input.name ? String(input.name) : undefined, + rotateToken: Boolean(input.rotateToken) + }); + }); + + ipcMain.handle(IPC_CHANNELS.DISABLE_REMOTE_DIAGNOSTICS, async () => { + return controller.disableRemoteDiagnostics(); + }); + + ipcMain.handle(IPC_CHANNELS.ROTATE_REMOTE_DIAGNOSTICS_TOKEN, async () => { + return controller.rotateRemoteDiagnosticsToken(); + }); + + ipcMain.handle(IPC_CHANNELS.OPEN_ITEM_LOG, async (_event: IpcMainInvokeEvent, itemId: string) => { + validateString(itemId, "itemId"); + const logPath = controller.getItemLogPath(itemId); + if (logPath) { + await shell.openPath(logPath); + } + }); + + ipcMain.handle(IPC_CHANNELS.OPEN_REALDEBRID_LOGIN, async () => { + await controller.openRealDebridLoginWindow(); + }); + + ipcMain.handle(IPC_CHANNELS.OPEN_ALLDEBRID_LOGIN, async () => { + await controller.openAllDebridLoginWindow(); + }); + + ipcMain.handle(IPC_CHANNELS.IMPORT_BESTDEBRID_COOKIES, async () => { + const options = { + properties: ["openFile"] as Array<"openFile">, + filters: [ + { name: "Cookie-Datei", extensions: ["txt"] }, + { name: "Alle Dateien", extensions: ["*"] } + ] + }; + const result = mainWindow ? await dialog.showOpenDialog(mainWindow, options) : await dialog.showOpenDialog(options); + if (result.canceled || result.filePaths.length === 0) { + return 0; + } + return controller.importBestDebridCookies(result.filePaths[0]); + }); + + ipcMain.handle(IPC_CHANNELS.GET_ALLDEBRID_HOST_INFO, async () => { + return controller.getAllDebridHostInfo(); + }); + + ipcMain.handle(IPC_CHANNELS.GET_DEBRIDLINK_HOST_LIMITS, async () => { + return controller.getDebridLinkHostLimits(); + }); + + ipcMain.handle(IPC_CHANNELS.CHECK_DEBRID_ACCOUNTS, async () => { + return controller.checkDebridAccounts(); + }); + + ipcMain.handle(IPC_CHANNELS.CHECK_MEGA_DEBRID_ACCOUNT, async (_event, login: string, password: string) => { + return controller.checkSingleMegaDebridAccount(String(login || ""), String(password || "")); + }); + + ipcMain.handle(IPC_CHANNELS.IMPORT_BACKUP, async () => { + const options = { + properties: ["openFile"] as Array<"openFile">, + filters: [ + { name: "MDD Backup", extensions: ["mdd"] }, + { name: "Legacy Backup (JSON)", extensions: ["json"] }, + { name: "Alle Dateien", extensions: ["*"] } + ] + }; + const result = mainWindow ? await dialog.showOpenDialog(mainWindow, options) : await dialog.showOpenDialog(options); + if (result.canceled || result.filePaths.length === 0) { + return { restored: false, message: "Abgebrochen" }; + } + const filePath = result.filePaths[0]; + const stat = await fs.promises.stat(filePath); + const BACKUP_MAX_BYTES = 50 * 1024 * 1024; + if (stat.size > BACKUP_MAX_BYTES) { + return { restored: false, message: `Backup-Datei zu groß (max 50 MB, Datei hat ${(stat.size / 1024 / 1024).toFixed(1)} MB)` }; + } + const data = await fs.promises.readFile(filePath); + const importResult = controller.importBackup(data); + // Only a full restore (queue swapped) needs the auto-relaunch. A settings- + // only import applied live — relaunching would be pointless and would drop + // the running queue. + if (importResult.restored && importResult.relaunch) { + setTimeout(() => { + app.relaunch(); + app.quit(); + }, 1500); + } + return importResult; + }); + + ipcMain.on(IPC_CHANNELS.LOG_RENDERER_ERROR, (_event, rawReport: unknown) => { + try { + logger.error(formatRendererErrorReport(rawReport)); + } catch (error) { + logger.error(`[Renderer] Fehlerbericht konnte nicht verarbeitet werden: ${String(error)}`); + } + }); + + controller.onState = (snapshot) => { + if (!mainWindow || mainWindow.isDestroyed()) { + return; + } + mainWindow.webContents.send(IPC_CHANNELS.STATE_UPDATE, snapshot); + }; +} + +function formatRendererErrorReport(rawReport: unknown): string { + const report = (rawReport && typeof rawReport === "object" ? rawReport : {}) as Record<string, unknown>; + const str = (value: unknown): string => (typeof value === "string" ? value : ""); + const num = (value: unknown): string => (typeof value === "number" && Number.isFinite(value) ? String(value) : ""); + const kind = str(report.kind) || "error"; + const message = (str(report.message) || "(ohne Nachricht)").slice(0, 2000); + const source = str(report.source); + const line = num(report.line); + const column = num(report.column); + const stack = str(report.stack).slice(0, 4000); + const componentStack = str(report.componentStack).slice(0, 4000); + + const parts: string[] = [`[Renderer:${kind}] ${message}`]; + if (source) { + parts.push(`@ ${source}${line ? `:${line}${column ? `:${column}` : ""}` : ""}`); + } + if (stack) { + parts.push(`| stack: ${stack.replace(/\s*\n\s*/g, " ⏎ ")}`); + } + if (componentStack) { + parts.push(`| react: ${componentStack.replace(/\s*\n\s*/g, " ⏎ ")}`); + } + return parts.join(" "); +} + +app.on("child-process-gone", (_event, details) => { + const killed = details.reason !== "clean-exit" && details.reason !== "killed"; + const line = `Subprozess beendet: type=${details.type} reason=${details.reason} exitCode=${details.exitCode ?? "?"}${details.name ? ` name=${details.name}` : ""}${details.serviceName ? ` service=${details.serviceName}` : ""}`; + if (killed) { + logger.error(line); + } else { + logger.warn(line); + } +}); + +app.on("second-instance", () => { + if (mainWindow) { + if (mainWindow.isMinimized()) { + mainWindow.restore(); + } + mainWindow.show(); + mainWindow.focus(); + } +}); + +app.whenReady().then(() => { + cleanupStaleSubstDrives(); + registerIpcHandlers(); + mainWindow = createWindow(); + bindMainWindowLifecycle(mainWindow); + updateClipboardWatcher(); + updateTray(); + // A scheduled start persists in the settings but its timer lived only in this + // process — without re-arming it here, any restart (auto-update, reboot, + // crash) silently swallowed the planned run. + armScheduledStart(controller.getSettings().scheduledStartEpochMs || 0, { startOnPast: false }); + + app.on("activate", () => { + if (BrowserWindow.getAllWindows().length === 0) { + mainWindow = createWindow(); + bindMainWindowLifecycle(mainWindow); + } + }); +}).catch((error) => { + console.error("App startup failed:", error); + app.quit(); +}); + +app.on("window-all-closed", () => { + if (process.platform !== "darwin") { + app.quit(); + } +}); + +app.on("before-quit", () => { + if (updateQuitTimer) { clearTimeout(updateQuitTimer); updateQuitTimer = null; } + stopClipboardWatcher(); + destroyTray(); + shutdownDaemon(); + try { + controller.shutdown(); + } catch (error) { + logger.error(`Fehler beim Shutdown: ${String(error)}`); + } +}); diff --git a/src/main/mega-public-api.ts b/src/main/mega-public-api.ts new file mode 100644 index 0000000..8b6faf9 --- /dev/null +++ b/src/main/mega-public-api.ts @@ -0,0 +1,129 @@ +import crypto from "node:crypto"; + +const MEGA_API_BASE = "https://g.api.mega.co.nz/cs"; +const MEGA_API_TIMEOUT_MS = 12_000; + +export interface MegaFileInfo { + name: string; + size: number; +} + +const NEW_FORMAT_RE = /^https?:\/\/mega\.(?:nz|co\.nz)\/file\/([A-Za-z0-9_-]+)#([A-Za-z0-9_-]+)/i; +const LEGACY_FORMAT_RE = /^https?:\/\/mega\.(?:nz|co\.nz)\/#!([A-Za-z0-9_-]+)!([A-Za-z0-9_-]+)/i; + +export function isMegaFileUrl(url: string): boolean { + const s = String(url || "").trim(); + return NEW_FORMAT_RE.test(s) || LEGACY_FORMAT_RE.test(s); +} + +function base64UrlDecode(s: string): Buffer | null { + let b64 = String(s || "").trim().replace(/-/g, "+").replace(/_/g, "/"); + while (b64.length % 4 !== 0) b64 += "="; + try { + return Buffer.from(b64, "base64"); + } catch { + return null; + } +} + +export interface ParsedMegaLink { + id: string; + rawKey: Buffer; +} + +export function parseMegaUrl(url: string): ParsedMegaLink | null { + const s = String(url || "").trim(); + const m = NEW_FORMAT_RE.exec(s) || LEGACY_FORMAT_RE.exec(s); + if (!m) return null; + const id = m[1]; + const rawKey = base64UrlDecode(m[2]); + if (!rawKey || rawKey.length !== 32) return null; + return { id, rawKey }; +} + +export function decryptMegaAttributes(encrypted: Buffer, aesKey: Buffer): Record<string, unknown> | null { + if (!Buffer.isBuffer(encrypted) || encrypted.length === 0 || encrypted.length % 16 !== 0) return null; + if (!Buffer.isBuffer(aesKey) || aesKey.length !== 16) return null; + let plain: Buffer; + try { + const decipher = crypto.createDecipheriv("aes-128-cbc", aesKey, Buffer.alloc(16)); + decipher.setAutoPadding(false); + plain = Buffer.concat([decipher.update(encrypted), decipher.final()]); + } catch { + return null; + } + const text = plain.toString("utf8").replace(/\0+$/, "").trim(); + if (!text.startsWith("MEGA{")) return null; + try { + return JSON.parse(text.slice(4)); + } catch { + return null; + } +} + +function withTimeoutSignal(parent: AbortSignal | undefined, timeoutMs: number): AbortSignal { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort("mega-api-timeout"), timeoutMs); + if (parent) { + if (parent.aborted) { + controller.abort(parent.reason); + } else { + parent.addEventListener("abort", () => controller.abort(parent.reason), { once: true }); + } + } + controller.signal.addEventListener("abort", () => clearTimeout(timer), { once: true }); + return controller.signal; +} + +export async function resolveMegaFilename( + url: string, + signal?: AbortSignal +): Promise<MegaFileInfo | null> { + const parsed = parseMegaUrl(url); + if (!parsed) return null; + const aesKey = parsed.rawKey.subarray(0, 16); + + const apiUrl = `${MEGA_API_BASE}?id=${Math.floor(Math.random() * 1e9)}`; + const body = JSON.stringify([{ a: "g", g: 1, p: parsed.id }]); + + let response: Response; + try { + response = await fetch(apiUrl, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body, + signal: withTimeoutSignal(signal, MEGA_API_TIMEOUT_MS) + }); + } catch { + return null; + } + if (!response.ok) return null; + + let payload: unknown; + try { + payload = await response.json(); + } catch { + return null; + } + + if (typeof payload === "number") return null; + if (!Array.isArray(payload) || payload.length === 0) return null; + + const first = payload[0]; + if (typeof first === "number") return null; + if (!first || typeof first !== "object") return null; + + const info = first as { s?: unknown; at?: unknown; e?: unknown }; + if (typeof info.e === "number" && info.e !== 0) return null; + + const size = typeof info.s === "number" && info.s > 0 ? info.s : 0; + if (typeof info.at !== "string" || !info.at.trim()) return null; + + const encryptedAttrs = base64UrlDecode(info.at); + if (!encryptedAttrs) return null; + + const attrs = decryptMegaAttributes(encryptedAttrs, aesKey); + if (!attrs || typeof attrs.n !== "string" || !attrs.n.trim()) return null; + + return { name: attrs.n.trim(), size }; +} diff --git a/src/main/mega-web-fallback.ts b/src/main/mega-web-fallback.ts new file mode 100644 index 0000000..347bf46 --- /dev/null +++ b/src/main/mega-web-fallback.ts @@ -0,0 +1,458 @@ +import { UnrestrictedLink } from "./realdebrid"; +import { compactErrorText, filenameFromUrl, sleep } from "./utils"; +import { traceConversionPhase } from "./conversion-trace"; + +type MegaCredentials = { + login: string; + password: string; +}; + +type CodeEntry = { + code: string; + linkHint: string; +}; + +const LOGIN_URL = "https://www.mega-debrid.eu/index.php?form=login"; +const DEBRID_URL = "https://www.mega-debrid.eu/index.php?form=debrid"; +const DEBRID_AJAX_URL = "https://www.mega-debrid.eu/index.php?ajax=debrid&json"; +const DEBRID_REFERER = "https://www.mega-debrid.eu/index.php?page=debrideur&lang=de"; + +export const MEGA_DEBRID_NO_SERVER_RE = /kein server f(?:ü|u)r diesen hoster|no server (?:is )?available for this host|aucun serveur disponible/i; + +function normalizeLink(link: string): string { + return link.trim().toLowerCase(); +} + +function parseSetCookieFromHeaders(headers: Headers): string { + const getSetCookie = (headers as unknown as { getSetCookie?: () => string[] }).getSetCookie; + if (typeof getSetCookie === "function") { + const values = getSetCookie.call(headers) + .map((entry) => entry.split(";")[0].trim()) + .filter(Boolean); + if (values.length > 0) { + return values.join("; "); + } + } + + const raw = headers.get("set-cookie") || ""; + if (!raw) { + return ""; + } + return raw + .split(/,(?=[^;=]+?=)/g) + .map((chunk) => chunk.split(";")[0].trim()) + .filter(Boolean) + .join("; "); +} + +const PERMANENT_HOSTER_ERRORS = [ + "hosternotavailable", + "filenotfound", + "file_unavailable", + "file not found", + "link is dead", + "file has been removed", + "file has been deleted", + "file was deleted", + "file was removed", + "not available", + "file is no longer available" +]; + +function parsePageErrors(html: string): string[] { + const errors: string[] = []; + const errorRegex = /class=["'][^"']*\berror\b[^"']*["'][^>]*>([^<]+)</gi; + let m: RegExpExecArray | null; + while ((m = errorRegex.exec(html)) !== null) { + const text = m[1].replace(/^Fehler:\s*/i, "").trim(); + if (text) { + errors.push(text); + } + } + return errors; +} + +function isPermanentHosterError(errors: string[]): string | null { + for (const err of errors) { + const lower = err.toLowerCase(); + for (const pattern of PERMANENT_HOSTER_ERRORS) { + if (lower.includes(pattern)) { + return err; + } + } + } + return null; +} + +function parseCodes(html: string): CodeEntry[] { + const entries: CodeEntry[] = []; + const cardRegex = /<div[^>]*class=['"][^'"]*acp-box[^'"]*['"][^>]*>[\s\S]*?<\/div>/gi; + let cardMatch: RegExpExecArray | null; + while ((cardMatch = cardRegex.exec(html)) !== null) { + const block = cardMatch[0]; + const linkTitle = (block.match(/<h3>\s*Link:\s*([^<]+)<\/h3>/i)?.[1] || "").trim(); + const code = block.match(/processDebrid\(\d+,'([^']+)',0\)/i)?.[1] || ""; + if (!code) { + continue; + } + entries.push({ code, linkHint: normalizeLink(linkTitle) }); + } + + if (entries.length === 0) { + const fallbackRegex = /processDebrid\(\d+,'([^']+)',0\)/gi; + let m: RegExpExecArray | null; + while ((m = fallbackRegex.exec(html)) !== null) { + entries.push({ code: m[1], linkHint: "" }); + } + } + + return entries; +} + +function pickCode(entries: CodeEntry[], link: string): string { + if (entries.length === 0) { + return ""; + } + const target = normalizeLink(link); + const match = entries.find((entry) => entry.linkHint && entry.linkHint.includes(target)); + return (match?.code || entries[0].code || "").trim(); +} + +function parseDebridJson(text: string): { link: string; text: string } | null { + try { + const parsed = JSON.parse(text) as { link?: string; text?: string }; + return { + link: String(parsed.link || ""), + text: String(parsed.text || "") + }; + } catch { + return null; + } +} + +function abortError(): Error { + return new Error("aborted:mega-web"); +} + +function withTimeoutSignal(signal: AbortSignal | undefined, timeoutMs: number): AbortSignal { + const timeoutSignal = AbortSignal.timeout(timeoutMs); + if (!signal) { + return timeoutSignal; + } + return AbortSignal.any([signal, timeoutSignal]); +} + +function throwIfAborted(signal?: AbortSignal): void { + if (signal?.aborted) { + throw abortError(); + } +} + +async function sleepWithSignal(ms: number, signal?: AbortSignal): Promise<void> { + if (!signal) { + await sleep(ms); + return; + } + if (signal.aborted) { + throw abortError(); + } + + await new Promise<void>((resolve, reject) => { + let timer: NodeJS.Timeout | null = setTimeout(() => { + timer = null; + signal.removeEventListener("abort", onAbort); + resolve(); + }, Math.max(0, ms)); + + const onAbort = (): void => { + if (timer) { + clearTimeout(timer); + timer = null; + } + signal.removeEventListener("abort", onAbort); + reject(abortError()); + }; + + signal.addEventListener("abort", onAbort, { once: true }); + }); +} + +async function raceWithAbort<T>(promise: Promise<T>, signal?: AbortSignal, abortErrorFactory: () => Error = abortError): Promise<T> { + if (!signal) { + return promise; + } + if (signal.aborted) { + throw abortErrorFactory(); + } + + return new Promise<T>((resolve, reject) => { + let settled = false; + + const onAbort = (): void => { + if (settled) { + return; + } + settled = true; + signal.removeEventListener("abort", onAbort); + reject(abortErrorFactory()); + }; + + signal.addEventListener("abort", onAbort, { once: true }); + + promise.then((value) => { + if (settled) { + return; + } + settled = true; + signal.removeEventListener("abort", onAbort); + resolve(value); + }, (error) => { + if (settled) { + return; + } + settled = true; + signal.removeEventListener("abort", onAbort); + reject(error); + }); + }); +} + +export class MegaWebFallback { + // Pro Account eine eigene Warteschlange: Umwandlungen auf DEMSELBEN Account laufen + // seriell (kein Doppel-Login, kein Hammern eines einzelnen Accounts), verschiedene + // Accounts laufen parallel. So koennen die Links eines Pakets ueber mehrere Accounts + // gleichzeitig umgewandelt werden statt global eine nach der anderen. + private queues = new Map<string, Promise<unknown>>(); + + private getCredentials: () => MegaCredentials; + + private sessions = new Map<string, { cookie: string; setAt: number }>(); + + public constructor(getCredentials: () => MegaCredentials) { + this.getCredentials = getCredentials; + } + + public async unrestrict( + link: string, + signal?: AbortSignal, + account?: { login: string; password: string } + ): Promise<UnrestrictedLink | null> { + const overallSignal = withTimeoutSignal(signal, 180000); + const creds = (account && account.login.trim() && account.password.trim()) + ? account + : this.getCredentials(); + if (!creds.login.trim() || !creds.password.trim()) { + return null; + } + const key = creds.login.trim().toLowerCase(); + return this.runExclusive(async () => { + throwIfAborted(overallSignal); + let cookie = await this.ensureSession(key, creds.login, creds.password, overallSignal); + + let generated = await this.generate(link, cookie, overallSignal); + if (!generated) { + this.sessions.delete(key); + cookie = await this.ensureSession(key, creds.login, creds.password, overallSignal); + generated = await this.generate(link, cookie, overallSignal); + if (!generated) { + return null; + } + } + return { + directUrl: generated.directUrl, + fileName: generated.fileName || filenameFromUrl(link), + fileSize: null, + retriesUsed: 0 + }; + }, key, overallSignal); + } + + private async ensureSession(key: string, login: string, password: string, signal?: AbortSignal): Promise<string> { + const existing = this.sessions.get(key); + if (existing && existing.cookie && Date.now() - existing.setAt <= 20 * 60 * 1000) { + return existing.cookie; + } + const cookie = await this.login(login, password, signal); + this.sessions.set(key, { cookie, setAt: Date.now() }); + return cookie; + } + + public invalidateSession(): void { + this.sessions.clear(); + } + + private async runExclusive<T>(job: () => Promise<T>, key: string, signal?: AbortSignal): Promise<T> { + const queuedAt = Date.now(); + const QUEUE_WAIT_TIMEOUT_MS = 90000; + let workStarted = false; + const guardedJob = async (): Promise<T> => { + throwIfAborted(signal); + const waited = Date.now() - queuedAt; + if (waited > QUEUE_WAIT_TIMEOUT_MS) { + traceConversionPhase({ phase: "web-queue", provider: "megadebrid-web", queueWaitMs: waited, outcome: "queue-timeout", detail: `${Math.floor(waited / 1000)}s in Web-Queue gewartet` }); + throw new Error(`Mega-Web Queue-Timeout (${Math.floor(waited / 1000)}s gewartet)`); + } + workStarted = true; + const workStartedAt = Date.now(); + try { + const result = await job(); + traceConversionPhase({ phase: "web-queue", provider: "megadebrid-web", queueWaitMs: waited, workMs: Date.now() - workStartedAt, outcome: "ok" }); + return result; + } catch (jobError) { + traceConversionPhase({ phase: "web-queue", provider: "megadebrid-web", queueWaitMs: waited, workMs: Date.now() - workStartedAt, outcome: "error", detail: compactErrorText(jobError).slice(0, 100) }); + throw jobError; + } + }; + const prev = this.queues.get(key) ?? Promise.resolve(); + const run = prev.then(guardedJob, guardedJob); + this.queues.set(key, run.then(() => undefined, () => undefined)); + return raceWithAbort(run, signal, () => + workStarted + ? abortError() + : new Error(`Mega-Web Queue-Timeout (abgebrochen nach ${Math.floor((Date.now() - queuedAt) / 1000)}s Wartezeit, Account war belegt)`) + ); + } + + private async login(login: string, password: string, signal?: AbortSignal): Promise<string> { + throwIfAborted(signal); + const response = await fetch(LOGIN_URL, { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + "User-Agent": "Mozilla/5.0" + }, + body: new URLSearchParams({ + login, + password, + remember: "on" + }), + redirect: "manual", + signal: withTimeoutSignal(signal, 30000) + }); + + const cookie = parseSetCookieFromHeaders(response.headers); + if (!cookie) { + throw new Error("Mega-Web Login liefert kein Session-Cookie"); + } + + const verify = await fetch(DEBRID_REFERER, { + method: "GET", + headers: { + "User-Agent": "Mozilla/5.0", + Cookie: cookie, + Referer: DEBRID_REFERER + }, + signal: withTimeoutSignal(signal, 30000) + }); + const verifyHtml = await verify.text(); + const hasDebridForm = /id=["']debridForm["']/i.test(verifyHtml) || /name=["']links["']/i.test(verifyHtml); + if (!hasDebridForm) { + throw new Error("Mega-Web Login ungültig oder Session blockiert"); + } + + return cookie; + } + + private async generate(link: string, cookie: string, signal?: AbortSignal): Promise<{ directUrl: string; fileName: string } | null> { + throwIfAborted(signal); + const page = await fetch(DEBRID_URL, { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + "User-Agent": "Mozilla/5.0", + Cookie: cookie, + Referer: DEBRID_REFERER + }, + body: new URLSearchParams({ + links: link, + password: "", + showLinks: "1" + }), + signal: withTimeoutSignal(signal, 30000) + }); + + const html = await page.text(); + + const pageErrors = parsePageErrors(html); + const permanentError = isPermanentHosterError(pageErrors); + if (permanentError) { + throw new Error(`Mega-Web: Link permanent ungültig (${permanentError})`); + } + + const noServerError = pageErrors.find((err) => MEGA_DEBRID_NO_SERVER_RE.test(err)); + if (noServerError) { + throw new Error(`Mega-Web: ${noServerError}`); + } + + const code = pickCode(parseCodes(html), link); + if (!code) { + return null; + } + + for (let attempt = 1; attempt <= 60; attempt += 1) { + throwIfAborted(signal); + const res = await fetch(DEBRID_AJAX_URL, { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + "User-Agent": "Mozilla/5.0", + Cookie: cookie, + Referer: DEBRID_REFERER + }, + body: new URLSearchParams({ + code, + autodl: "0" + }), + signal: withTimeoutSignal(signal, 15000) + }); + + const text = (await res.text()).trim(); + if (text === "reload") { + await sleepWithSignal(650, signal); + continue; + } + if (text === "false") { + return null; + } + + const parsed = parseDebridJson(text); + if (!parsed) { + return null; + } + + if (!parsed.link) { + if (/hoster does not respond correctly|could not be done for this moment/i.test(parsed.text || "")) { + await sleepWithSignal(1200, signal); + continue; + } + const serverMsg = (parsed.text || "").replace(/<[^>]*>/g, " ").replace(/\s+/g, " ").trim(); + if (serverMsg && MEGA_DEBRID_NO_SERVER_RE.test(serverMsg)) { + throw new Error(`Mega-Web: ${serverMsg}`); + } + return null; + } + + const fromText = parsed.text + .replace(/<[^>]*>/g, " ") + .replace(/\s+/g, " ") + .trim(); + + const nameMatch = fromText.match(/([\w .\-\[\]\(\)]+\.(?:rar|r\d{2}|zip|7z|mkv|mp4|avi|mp3|flac))/i); + const fileName = (nameMatch?.[1] || filenameFromUrl(link)).trim(); + return { + directUrl: parsed.link, + fileName + }; + } + + return null; + } + + public dispose(): void { + this.sessions.clear(); + } +} + +export function compactMegaWebError(error: unknown): string { + return compactErrorText(error); +} diff --git a/src/main/notify.ts b/src/main/notify.ts new file mode 100644 index 0000000..9f8c0b4 --- /dev/null +++ b/src/main/notify.ts @@ -0,0 +1,152 @@ +import { logger } from "./logger"; + +export interface NotifyPayload { + title: string; + message: string; + mention?: string; +} + +const NOTIFY_TIMEOUT_MS = 5000; +const WEBHOOK_USERNAME = "Real-Debrid Downloader"; +const MIN_SEND_GAP_MS = 450; +const RETRY_DELAYS_MS = [1000, 2500]; +const RATE_LIMIT_MAX_WAIT_MS = 15_000; +const CONTENT_MAX_CHARS = 2000; + +export function isNotifyUrlValid(url: string): boolean { + return /^https?:\/\/\S+$/i.test(String(url || "").trim()); +} + +// Accepts a bare Discord user ID (wrapped as <@id> so it actually pings), +// @everyone/@here, or an already-formed <@...>/<@&...> mention as-is. +export function normalizeDiscordMention(raw: string): string { + const text = String(raw || "").trim(); + if (!text) { + return ""; + } + if (/^\d{5,}$/.test(text)) { + return `<@${text}>`; + } + return text; +} + +// Discord counts the limit itself; slicing UTF-16 units can split a surrogate +// pair at the boundary, which Discord rejects as invalid content. +export function truncateContent(content: string, maxChars = CONTENT_MAX_CHARS): string { + if (content.length <= maxChars) { + return content; + } + let cut = content.slice(0, maxChars); + const last = cut.charCodeAt(cut.length - 1); + if (last >= 0xd800 && last <= 0xdbff) { + cut = cut.slice(0, -1); + } + return cut; +} + +export function buildNotifyRequest(url: string, payload: NotifyPayload): { url: string; init: RequestInit } { + const mention = normalizeDiscordMention(payload.mention || ""); + const content = truncateContent(`${mention ? `${mention} ` : ""}**${payload.title}**\n${payload.message}`); + return { + url: String(url || "").trim(), + init: { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ username: WEBHOOK_USERNAME, content }) + } + }; +} + +function delayMs(ms: number): Promise<void> { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +async function consumeBody(response: Response): Promise<string> { + try { + return await response.text(); + } catch { + return ""; + } +} + +function parseRetryAfterMs(response: Response, bodyText: string): number { + const headerSeconds = Number(response.headers.get("X-RateLimit-Reset-After") || response.headers.get("Retry-After") || ""); + if (Number.isFinite(headerSeconds) && headerSeconds > 0) { + return Math.ceil(headerSeconds * 1000); + } + try { + const parsed = JSON.parse(bodyText) as { retry_after?: number }; + if (typeof parsed.retry_after === "number" && parsed.retry_after > 0) { + return Math.ceil(parsed.retry_after * 1000); + } + } catch { + } + return 1500; +} + +async function sendOnce(url: string, payload: NotifyPayload, fetchFn: typeof fetch): Promise<{ ok: boolean; retryable: boolean; waitMs: number; detail: string }> { + try { + const request = buildNotifyRequest(url, payload); + const response = await fetchFn(request.url, { ...request.init, signal: AbortSignal.timeout(NOTIFY_TIMEOUT_MS) }); + const bodyText = await consumeBody(response); + if (response.ok) { + return { ok: true, retryable: false, waitMs: 0, detail: "" }; + } + if (response.status === 429) { + const waitMs = Math.min(RATE_LIMIT_MAX_WAIT_MS, parseRetryAfterMs(response, bodyText)); + return { ok: false, retryable: true, waitMs, detail: `HTTP 429 (Rate-Limit, warte ${waitMs}ms)` }; + } + if (response.status >= 500) { + return { ok: false, retryable: true, waitMs: 0, detail: `HTTP ${response.status}` }; + } + return { ok: false, retryable: false, waitMs: 0, detail: `HTTP ${response.status}` }; + } catch (error) { + return { ok: false, retryable: true, waitMs: 0, detail: String(error) }; + } +} + +// All sends share one chain: serialized with a minimum gap so burst completions +// (many packages finishing together) stay under Discord's 5-per-2s webhook +// bucket instead of getting dropped as 429s. +let sendChain: Promise<void> = Promise.resolve(); +let lastSendCompletedAt = 0; + +export async function sendNotification( + url: string, + payload: NotifyPayload, + fetchFn: typeof fetch = fetch, + sleepFn: (ms: number) => Promise<void> = delayMs +): Promise<boolean> { + if (!isNotifyUrlValid(url)) { + if (String(url || "").trim()) { + logger.warn(`Benachrichtigung nicht gesendet: ungueltige Webhook-URL (muss mit http(s):// beginnen): ${payload.title}`); + } + return false; + } + const result = sendChain.then(async () => { + const sinceLast = Date.now() - lastSendCompletedAt; + if (sinceLast < MIN_SEND_GAP_MS) { + await sleepFn(MIN_SEND_GAP_MS - sinceLast); + } + let lastDetail = ""; + for (let attempt = 0; ; attempt += 1) { + const outcome = await sendOnce(url, payload, fetchFn); + if (outcome.ok) { + return true; + } + lastDetail = outcome.detail; + if (!outcome.retryable || attempt >= RETRY_DELAYS_MS.length) { + break; + } + await sleepFn(outcome.waitMs > 0 ? outcome.waitMs : RETRY_DELAYS_MS[attempt]); + } + logger.warn(`Benachrichtigung fehlgeschlagen (${lastDetail}): ${payload.title}`); + return false; + }); + sendChain = result.then(() => { + lastSendCompletedAt = Date.now(); + }, () => { + lastSendCompletedAt = Date.now(); + }); + return result; +} diff --git a/src/main/package-log.ts b/src/main/package-log.ts new file mode 100644 index 0000000..95cea9c --- /dev/null +++ b/src/main/package-log.ts @@ -0,0 +1,230 @@ +import fs from "node:fs"; +import { logTimestamp } from "./log-timestamp"; +import path from "node:path"; +import crypto from "node:crypto"; + +const PACKAGE_LOG_FLUSH_INTERVAL_MS = 200; +const PACKAGE_LOG_RETENTION_DAYS = 30; + +type PackageLogLevel = "INFO" | "WARN" | "ERROR"; + +export interface PackageLogMeta { + packageId: string; + name: string; + outputDir: string; + extractDir: string; +} + +let packageLogsDir: string | null = null; +const knownLogPaths = new Map<string, string>(); +const pendingLinesByPackage = new Map<string, string[]>(); +const initializedThisProcess = new Set<string>(); +let flushTimer: NodeJS.Timeout | null = null; + +function normalizePackageId(packageId: string): string { + const trimmed = String(packageId || "").trim(); + if (!trimmed) { + return ""; + } + const safePrefix = trimmed + .replace(/[^a-zA-Z0-9._-]/g, "_") + .replace(/_+/g, "_") + .slice(0, 64) + .replace(/^_+|_+$/g, ""); + const hash = crypto.createHash("sha1").update(trimmed).digest("hex").slice(0, 12); + return `${safePrefix || "pkg"}_${hash}`; +} + +function sanitizeFieldValue(value: unknown): string { + if (value === undefined || value === null) { + return ""; + } + if (typeof value === "string") { + return value.replace(/\r?\n/g, "\\n"); + } + if (typeof value === "number" || typeof value === "boolean") { + return String(value); + } + try { + return JSON.stringify(value).replace(/\r?\n/g, "\\n"); + } catch { + return String(value); + } +} + +function formatFields(fields?: Record<string, unknown>): string { + if (!fields) { + return ""; + } + const parts = Object.entries(fields) + .filter(([, value]) => value !== undefined && value !== null && sanitizeFieldValue(value) !== "") + .map(([key, value]) => `${key}=${sanitizeFieldValue(value)}`); + return parts.length > 0 ? ` | ${parts.join(" | ")}` : ""; +} + +function getPackageLogFilePathFromNormalized(normalized: string): string | null { + if (!normalized || !packageLogsDir) { + return null; + } + const existing = knownLogPaths.get(normalized); + if (existing) { + return existing; + } + const logPath = path.join(packageLogsDir, `package_${normalized}.txt`); + knownLogPaths.set(normalized, logPath); + return logPath; +} + +function getPackageLogFilePath(packageId: string): string | null { + return getPackageLogFilePathFromNormalized(normalizePackageId(packageId)); +} + +function flushPending(): void { + for (const [packageId, lines] of pendingLinesByPackage.entries()) { + if (lines.length === 0) { + continue; + } + const logPath = getPackageLogFilePathFromNormalized(packageId); + if (!logPath) { + continue; + } + const chunk = lines.join(""); + pendingLinesByPackage.set(packageId, []); + try { + fs.appendFileSync(logPath, chunk, "utf8"); + } catch { + } + } +} + +function scheduleFlush(): void { + if (flushTimer) { + return; + } + flushTimer = setTimeout(() => { + flushTimer = null; + flushPending(); + }, PACKAGE_LOG_FLUSH_INTERVAL_MS); +} + +async function cleanupOldPackageLogs(dir: string): Promise<void> { + try { + const files = await fs.promises.readdir(dir); + const cutoff = Date.now() - PACKAGE_LOG_RETENTION_DAYS * 24 * 60 * 60 * 1000; + for (const file of files) { + if (!file.startsWith("package_") || !file.endsWith(".txt")) { + continue; + } + const filePath = path.join(dir, file); + try { + const stat = await fs.promises.stat(filePath); + if (stat.mtimeMs < cutoff) { + await fs.promises.unlink(filePath); + } + } catch { + } + } + } catch { + } +} + +function appendLine(packageId: string, line: string): void { + const normalized = normalizePackageId(packageId); + if (!normalized) { + return; + } + const lines = pendingLinesByPackage.get(normalized) || []; + lines.push(line); + pendingLinesByPackage.set(normalized, lines); + scheduleFlush(); +} + +export function initPackageLogs(baseDir: string): void { + packageLogsDir = path.join(baseDir, "package-logs"); + try { + fs.mkdirSync(packageLogsDir, { recursive: true }); + } catch { + packageLogsDir = null; + return; + } + void cleanupOldPackageLogs(packageLogsDir); +} + +export function ensurePackageLog(meta: PackageLogMeta): string | null { + const normalizedPackageId = normalizePackageId(meta.packageId); + const logPath = getPackageLogFilePath(meta.packageId); + if (!logPath) { + return null; + } + try { + fs.mkdirSync(path.dirname(logPath), { recursive: true }); + if (!fs.existsSync(logPath)) { + fs.writeFileSync(logPath, "", "utf8"); + } + if (!initializedThisProcess.has(normalizedPackageId)) { + initializedThisProcess.add(normalizedPackageId); + const startedAt = logTimestamp(); + fs.appendFileSync( + logPath, + `=== Paket-Log Start: ${startedAt} | packageId=${sanitizeFieldValue(String(meta.packageId || ""))} | logKey=${normalizedPackageId} | name=${sanitizeFieldValue(meta.name)} ===\n`, + "utf8" + ); + fs.appendFileSync( + logPath, + `${logTimestamp()} [INFO] Paket-Kontext initialisiert${formatFields({ + name: meta.name, + outputDir: meta.outputDir, + extractDir: meta.extractDir + })}\n`, + "utf8" + ); + } + } catch { + return null; + } + return logPath; +} + +export function logPackageEvent( + packageId: string, + level: PackageLogLevel, + message: string, + fields?: Record<string, unknown> +): void { + const logPath = getPackageLogFilePath(packageId); + if (!logPath) { + return; + } + const line = `${logTimestamp()} [${level}] ${message}${formatFields(fields)}\n`; + appendLine(packageId, line); +} + +export function getPackageLogPath(packageId: string): string | null { + const logPath = getPackageLogFilePath(packageId); + if (!logPath) { + return null; + } + return fs.existsSync(logPath) ? logPath : null; +} + +export function shutdownPackageLogs(): void { + if (flushTimer) { + clearTimeout(flushTimer); + flushTimer = null; + } + flushPending(); + for (const packageId of knownLogPaths.keys()) { + const logPath = getPackageLogFilePathFromNormalized(packageId); + if (!logPath) { + continue; + } + try { + fs.appendFileSync(logPath, `=== Paket-Log Ende: ${logTimestamp()} ===\n`, "utf8"); + } catch { + } + } + pendingLinesByPackage.clear(); + knownLogPaths.clear(); + initializedThisProcess.clear(); + packageLogsDir = null; +} diff --git a/src/main/realdebrid-web.ts b/src/main/realdebrid-web.ts new file mode 100644 index 0000000..3e3ca3d --- /dev/null +++ b/src/main/realdebrid-web.ts @@ -0,0 +1,477 @@ +import { BrowserWindow, session } from "electron"; +import { UnrestrictedLink } from "./realdebrid"; +import { filenameFromUrl, sleep } from "./utils"; +import { API_BASE_URL, REQUEST_RETRIES } from "./constants"; + +const RD_BASE_URL = "https://real-debrid.com"; +const RD_LOGIN_URL = RD_BASE_URL; +const RD_APITOKEN_URL = `${RD_BASE_URL}/apitoken`; +const RD_UNRESTRICT_API = `${API_BASE_URL}/unrestrict/link`; +const RD_PERSISTENT_PARTITION = "persist:realdebrid-web"; +const RD_TRANSIENT_PARTITION = "realdebrid-web"; +const RD_USER_AGENT = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/133.0.0.0 Safari/537.36"; + +type GenerateOutcome = + | { kind: "success"; value: UnrestrictedLink } + | { kind: "login_required" }; + +function abortError(): Error { + return new Error("aborted:realdebrid-web"); +} + +function withTimeoutSignal(signal: AbortSignal | undefined, timeoutMs: number): AbortSignal { + const timeoutSignal = AbortSignal.timeout(timeoutMs); + if (!signal) { + return timeoutSignal; + } + return AbortSignal.any([signal, timeoutSignal]); +} + +function throwIfAborted(signal?: AbortSignal): void { + if (signal?.aborted) { + throw abortError(); + } +} + +async function sleepWithSignal(ms: number, signal?: AbortSignal): Promise<void> { + if (!signal) { + await sleep(ms); + return; + } + if (signal.aborted) { + throw abortError(); + } + + await new Promise<void>((resolve, reject) => { + let timer: NodeJS.Timeout | null = setTimeout(() => { + timer = null; + signal.removeEventListener("abort", onAbort); + resolve(); + }, Math.max(0, ms)); + + const onAbort = (): void => { + if (timer) { + clearTimeout(timer); + timer = null; + } + signal.removeEventListener("abort", onAbort); + reject(abortError()); + }; + + signal.addEventListener("abort", onAbort, { once: true }); + }); +} + +function parseJson(text: string): Record<string, unknown> | null { + try { + const parsed = JSON.parse(text) as unknown; + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { + return null; + } + return parsed as Record<string, unknown>; + } catch { + return null; + } +} + +function looksLikeHtmlResponse(text: string): boolean { + const trimmed = text.trim(); + return trimmed.startsWith("<!") || trimmed.startsWith("<html") || trimmed.startsWith("<HTML"); +} + +export function extractPrivateTokenFromHtml(html: string): string | null { + const normalized = String(html || ""); + if (!normalized.trim()) { + return null; + } + + const patterns = [ + /private_token['"]\]\[0\]\.value\s*=\s*['"]([^'"]+)['"]/i, + /getElementsByName\(\s*['"]private_token['"]\s*\)\s*\[\s*0\s*\]\.value\s*=\s*['"]([^'"]+)['"]/i, + /querySelector(?:All)?\(\s*['"][^'"]*private_token[^'"]*['"]\s*\)(?:\s*\[\s*0\s*\])?\.value\s*=\s*['"]([^'"]+)['"]/i, + /name=['"]private_token['"][^>]*value=['"]([^'"]+)['"]/i, + /value=['"]([^'"]+)['"][^>]*name=['"]private_token['"]/i + ]; + + for (const pattern of patterns) { + const match = normalized.match(pattern); + const token = match?.[1]?.trim(); + if (token) { + return token; + } + } + + return null; +} + +export class RealDebridWebFallback { + private queue: Promise<unknown> = Promise.resolve(); + + private loginWindow: BrowserWindow | null = null; + + private loginWindowPartition = ""; + + private cachedToken = ""; + + private cachedTokenAt = 0; + + private getRememberSession: () => boolean; + + public constructor(getRememberSession: () => boolean) { + this.getRememberSession = getRememberSession; + } + + public async unrestrict(link: string, signal?: AbortSignal): Promise<UnrestrictedLink | null> { + const overallSignal = withTimeoutSignal(signal, 10 * 60 * 1000); + return this.runExclusive(async () => { + throwIfAborted(overallSignal); + if (!String(link || "").trim()) { + return null; + } + + const initial = await this.generate(link, overallSignal); + if (initial.kind === "success") { + return initial.value; + } + return this.waitForLoginAndGenerate(link, overallSignal); + }, overallSignal); + } + + public async openLoginWindow(): Promise<void> { + const window = await this.ensureLoginWindow(); + if (window.isMinimized()) { + window.restore(); + } + window.show(); + window.focus(); + void this.primeTokenFromWindow(window); + } + + public async clearSessions(): Promise<void> { + this.disposeLoginWindow(); + this.cachedToken = ""; + this.cachedTokenAt = 0; + for (const partition of [RD_PERSISTENT_PARTITION, RD_TRANSIENT_PARTITION]) { + const currentSession = session.fromPartition(partition); + try { + await currentSession.clearStorageData({ + storages: ["cookies", "indexdb", "localstorage", "serviceworkers", "cachestorage"] + }); + } catch { + } + try { + await currentSession.clearCache(); + } catch { + } + } + } + + public dispose(): void { + this.disposeLoginWindow(); + } + + private getPartition(): string { + return this.getRememberSession() ? RD_PERSISTENT_PARTITION : RD_TRANSIENT_PARTITION; + } + + private disposeLoginWindow(): void { + const current = this.loginWindow; + this.loginWindow = null; + this.loginWindowPartition = ""; + if (current && !current.isDestroyed()) { + current.close(); + } + } + + private async runExclusive<T>(job: () => Promise<T>, signal?: AbortSignal): Promise<T> { + const queuedAt = Date.now(); + const queueWaitTimeoutMs = 10 * 60 * 1000 + 30_000; + const guardedJob = async (): Promise<T> => { + throwIfAborted(signal); + const waited = Date.now() - queuedAt; + if (waited > queueWaitTimeoutMs) { + throw new Error(`Real-Debrid-Web Queue-Timeout (${Math.floor(waited / 1000)}s gewartet)`); + } + return job(); + }; + const run = this.queue.then(guardedJob, guardedJob); + this.queue = run.then(() => undefined, () => undefined); + return run; + } + + private async ensureLoginWindow(): Promise<BrowserWindow> { + const partition = this.getPartition(); + const existing = this.loginWindow; + if (existing && !existing.isDestroyed() && this.loginWindowPartition === partition) { + return existing; + } + + if (existing && !existing.isDestroyed()) { + existing.close(); + } + + const window = new BrowserWindow({ + width: 1120, + height: 900, + minWidth: 980, + minHeight: 760, + autoHideMenuBar: true, + title: "Real-Debrid Web-Login", + webPreferences: { + partition, + contextIsolation: true, + nodeIntegration: false + } + }); + window.setMenuBarVisibility(false); + window.webContents.setUserAgent(RD_USER_AGENT); + const primeFromWindow = (): void => { + void this.primeTokenFromWindow(window); + }; + window.webContents.on("did-finish-load", primeFromWindow); + window.webContents.on("did-navigate", primeFromWindow); + window.webContents.on("did-navigate-in-page", primeFromWindow); + window.on("close", () => { + void this.primeTokenFromWindow(window); + }); + window.on("closed", () => { + if (this.loginWindow === window) { + this.loginWindow = null; + this.loginWindowPartition = ""; + } + }); + this.loginWindow = window; + this.loginWindowPartition = partition; + await window.loadURL(RD_LOGIN_URL); + return window; + } + + private rememberToken(token: string): string { + this.cachedToken = token; + this.cachedTokenAt = Date.now(); + return token; + } + + private getActiveLoginWindow(): BrowserWindow | null { + const window = this.loginWindow; + if (!window || window.isDestroyed()) { + return null; + } + if (this.loginWindowPartition !== this.getPartition()) { + return null; + } + return window; + } + + private async extractApiTokenFromWindow(window: BrowserWindow, signal?: AbortSignal): Promise<string | null> { + throwIfAborted(signal); + + try { + const rawResult = await window.webContents.executeJavaScript(` + (async () => { + const readTokenFromHtml = (html) => { + const text = String(html || ""); + const patterns = [ + /private_token['"]\\]\\[0\\]\\.value\\s*=\\s*['"]([^'"]+)['"]/i, + /getElementsByName\\(\\s*['"]private_token['"]\\s*\\)\\s*\\[\\s*0\\s*\\]\\.value\\s*=\\s*['"]([^'"]+)['"]/i, + /querySelector(?:All)?\\(\\s*['"][^'"]*private_token[^'"]*['"]\\s*\\)(?:\\s*\\[\\s*0\\s*\\])?\\.value\\s*=\\s*['"]([^'"]+)['"]/i, + /name=['"]private_token['"][^>]*value=['"]([^'"]+)['"]/i, + /value=['"]([^'"]+)['"][^>]*name=['"]private_token['"]/i + ]; + for (const pattern of patterns) { + const match = text.match(pattern); + if (match && match[1]) { + return String(match[1]).trim(); + } + } + return ""; + }; + + const directInput = document.querySelector('input[name="private_token"]'); + if (directInput instanceof HTMLInputElement && directInput.value.trim()) { + return directInput.value.trim(); + } + + const html = document.documentElement ? document.documentElement.outerHTML : ""; + const directToken = readTokenFromHtml(html); + if (directToken) { + return directToken; + } + + try { + const response = await fetch(${JSON.stringify(RD_APITOKEN_URL)}, { + credentials: "include", + cache: "no-store", + headers: { + "X-Requested-With": "XMLHttpRequest" + } + }); + const tokenHtml = await response.text(); + return readTokenFromHtml(tokenHtml); + } catch { + return ""; + } + })(); + `, true); + const token = String(rawResult || "").trim(); + if (token) { + return this.rememberToken(token); + } + } catch { + } + + return null; + } + + private async primeTokenFromWindow(window: BrowserWindow): Promise<void> { + try { + await this.extractApiTokenFromWindow(window); + } catch { + } + } + + private async extractApiToken(signal?: AbortSignal): Promise<string | null> { + throwIfAborted(signal); + + if (this.cachedToken && Date.now() - this.cachedTokenAt < 30 * 60 * 1000) { + return this.cachedToken; + } + + const activeLoginWindow = this.getActiveLoginWindow(); + if (activeLoginWindow) { + const windowToken = await this.extractApiTokenFromWindow(activeLoginWindow, signal); + if (windowToken) { + return windowToken; + } + } + + const currentSession = session.fromPartition(this.getPartition()); + const response = await currentSession.fetch(RD_APITOKEN_URL, { + headers: { + Accept: "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + Referer: RD_BASE_URL + "/", + "User-Agent": RD_USER_AGENT + }, + signal: withTimeoutSignal(signal, 30_000) + }); + const html = await response.text(); + + if (!response.ok || response.status === 403) { + return null; + } + + const token = extractPrivateTokenFromHtml(html); + if (token) { + return this.rememberToken(token); + } + + return null; + } + + private async generate(link: string, signal?: AbortSignal): Promise<GenerateOutcome> { + throwIfAborted(signal); + + const token = await this.extractApiToken(signal); + if (!token) { + return { kind: "login_required" }; + } + + for (let attempt = 1; attempt <= REQUEST_RETRIES; attempt += 1) { + throwIfAborted(signal); + try { + const body = new URLSearchParams({ link }); + const response = await fetch(RD_UNRESTRICT_API, { + method: "POST", + headers: { + Authorization: `Bearer ${token}`, + "Content-Type": "application/x-www-form-urlencoded", + "User-Agent": RD_USER_AGENT + }, + body, + signal: withTimeoutSignal(signal, 30_000) + }); + + const text = await response.text(); + + if (response.status === 401 || response.status === 403) { + this.cachedToken = ""; + this.cachedTokenAt = 0; + return { kind: "login_required" }; + } + + if (!response.ok) { + if ((response.status === 429 || response.status >= 500) && attempt < REQUEST_RETRIES) { + await sleepWithSignal(Math.min(5000, 400 * 2 ** attempt), signal); + continue; + } + throw new Error(`Real-Debrid Web HTTP ${response.status}: ${text.slice(0, 200)}`); + } + + if (looksLikeHtmlResponse(text)) { + throw new Error("Real-Debrid Web lieferte HTML statt JSON"); + } + + const payload = parseJson(text.trim()); + if (!payload) { + throw new Error("Ungültige JSON-Antwort von Real-Debrid Web"); + } + + const directUrl = String(payload.download || payload.link || "").trim(); + if (!directUrl) { + throw new Error("Real-Debrid Web: Antwort ohne Download-URL"); + } + + const fileName = String(payload.filename || "").trim() || filenameFromUrl(directUrl) || filenameFromUrl(link); + const fileSizeRaw = Number(payload.filesize ?? NaN); + return { + kind: "success", + value: { + directUrl, + fileName, + fileSize: Number.isFinite(fileSizeRaw) && fileSizeRaw > 0 ? Math.floor(fileSizeRaw) : null, + retriesUsed: attempt - 1 + } + }; + } catch (error) { + if (signal?.aborted) { + throw abortError(); + } + if (attempt >= REQUEST_RETRIES) { + throw error; + } + await sleepWithSignal(Math.min(5000, 400 * 2 ** attempt), signal); + } + } + + throw new Error("Real-Debrid Web: Unrestrict fehlgeschlagen"); + } + + private async waitForLoginAndGenerate(link: string, signal?: AbortSignal): Promise<UnrestrictedLink | null> { + const window = await this.ensureLoginWindow(); + if (window.isMinimized()) { + window.restore(); + } + window.show(); + window.focus(); + + const startedAt = Date.now(); + while (Date.now() - startedAt < 10 * 60 * 1000) { + throwIfAborted(signal); + if (window.isDestroyed()) { + throw new Error("Real-Debrid Web-Login abgebrochen"); + } + + const outcome = await this.generate(link, signal); + if (outcome.kind === "success") { + if (!window.isDestroyed()) { + window.close(); + } + return outcome.value; + } + + await sleepWithSignal(1_500, signal); + } + + throw new Error("Real-Debrid Web-Login Timeout"); + } +} diff --git a/src/main/realdebrid.ts b/src/main/realdebrid.ts new file mode 100644 index 0000000..e903e65 --- /dev/null +++ b/src/main/realdebrid.ts @@ -0,0 +1,204 @@ +import { API_BASE_URL, APP_VERSION, REQUEST_RETRIES } from "./constants"; +import { compactErrorText, sleep } from "./utils"; + +const DEBRID_USER_AGENT = `RD-Node-Downloader/${APP_VERSION}`; + +export interface UnrestrictedLink { + fileName: string; + directUrl: string; + fileSize: number | null; + retriesUsed: number; + skipTlsVerify?: boolean; + sourceLabel?: string; + sourceAccountId?: string; + sourceAccountLabel?: string; +} + +function shouldRetryStatus(status: number): boolean { + return status === 429 || status >= 500; +} + +function retryDelay(attempt: number): number { + return Math.min(5000, 400 * 2 ** attempt); +} + +function parseRetryAfterMs(value: string | null): number { + const text = String(value || "").trim(); + if (!text) { + return 0; + } + + const asSeconds = Number(text); + if (Number.isFinite(asSeconds) && asSeconds >= 0) { + return Math.min(120000, Math.floor(asSeconds * 1000)); + } + + const asDate = Date.parse(text); + if (Number.isFinite(asDate)) { + return Math.min(120000, Math.max(0, asDate - Date.now())); + } + + return 0; +} + +function retryDelayForResponse(response: Response, attempt: number): number { + if (response.status !== 429) { + return retryDelay(attempt); + } + const fromHeader = parseRetryAfterMs(response.headers.get("retry-after")); + return fromHeader > 0 ? fromHeader : retryDelay(attempt); +} + +function readHttpStatusFromErrorText(text: string): number { + const match = String(text || "").match(/HTTP\s+(\d{3})/i); + return match ? Number(match[1]) : 0; +} + +function isRetryableErrorText(text: string): boolean { + const status = readHttpStatusFromErrorText(text); + if (status === 429 || status >= 500) { + return true; + } + const lower = String(text || "").toLowerCase(); + return lower.includes("timeout") + || lower.includes("network") + || lower.includes("fetch failed") + || lower.includes("aborted") + || lower.includes("econnreset") + || lower.includes("enotfound") + || lower.includes("etimedout") + || lower.includes("html statt json"); +} + +function withTimeoutSignal(signal: AbortSignal | undefined, timeoutMs: number): AbortSignal { + if (!signal) { + return AbortSignal.timeout(timeoutMs); + } + return AbortSignal.any([signal, AbortSignal.timeout(timeoutMs)]); +} + +async function sleepWithSignal(ms: number, signal?: AbortSignal): Promise<void> { + if (!signal) { + await sleep(ms); + return; + } + if (signal.aborted) { + throw new Error("aborted"); + } + await new Promise<void>((resolve, reject) => { + let timer: NodeJS.Timeout | null = setTimeout(() => { + timer = null; + signal.removeEventListener("abort", onAbort); + resolve(); + }, Math.max(0, ms)); + + const onAbort = (): void => { + if (timer) { + clearTimeout(timer); + timer = null; + } + signal.removeEventListener("abort", onAbort); + reject(new Error("aborted")); + }; + + signal.addEventListener("abort", onAbort, { once: true }); + }); +} + +function looksLikeHtmlResponse(contentType: string, body: string): boolean { + const type = String(contentType || "").toLowerCase(); + if (type.includes("text/html") || type.includes("application/xhtml+xml")) { + return true; + } + return /^\s*<(!doctype\s+html|html\b)/i.test(String(body || "")); +} + +function parseErrorBody(status: number, body: string, contentType: string): string { + if (looksLikeHtmlResponse(contentType, body)) { + return `Real-Debrid lieferte HTML statt JSON (HTTP ${status})`; + } + const clean = compactErrorText(body); + return clean || `HTTP ${status}`; +} + +export class RealDebridClient { + private token: string; + + public constructor(token: string) { + this.token = token; + } + + public async unrestrictLink(link: string, signal?: AbortSignal): Promise<UnrestrictedLink> { + let lastError = ""; + for (let attempt = 1; attempt <= REQUEST_RETRIES; attempt += 1) { + try { + const body = new URLSearchParams({ link }); + const response = await fetch(`${API_BASE_URL}/unrestrict/link`, { + method: "POST", + headers: { + Authorization: `Bearer ${this.token}`, + "Content-Type": "application/x-www-form-urlencoded", + "User-Agent": DEBRID_USER_AGENT + }, + body, + signal: withTimeoutSignal(signal, 30000) + }); + + const text = await response.text(); + const contentType = String(response.headers.get("content-type") || ""); + if (!response.ok) { + const parsed = parseErrorBody(response.status, text, contentType); + if (shouldRetryStatus(response.status) && attempt < REQUEST_RETRIES) { + await sleepWithSignal(retryDelayForResponse(response, attempt), signal); + continue; + } + throw new Error(parsed); + } + + if (looksLikeHtmlResponse(contentType, text)) { + throw new Error("Real-Debrid lieferte HTML statt JSON"); + } + + let payload: Record<string, unknown>; + try { + payload = JSON.parse(text) as Record<string, unknown>; + } catch { + throw new Error("Ungültige JSON-Antwort von Real-Debrid"); + } + const directUrl = String(payload.download || payload.link || "").trim(); + if (!directUrl) { + throw new Error("Unrestrict ohne Download-URL"); + } + try { + const parsedUrl = new URL(directUrl); + if (parsedUrl.protocol !== "https:" && parsedUrl.protocol !== "http:") { + throw new Error(`Ungültiges Download-URL-Protokoll (${parsedUrl.protocol})`); + } + } catch (urlError) { + if (urlError instanceof Error && urlError.message.includes("Protokoll")) throw urlError; + throw new Error("Real-Debrid Antwort enthält keine gültige Download-URL"); + } + + const fileName = String(payload.filename || "download.bin").trim() || "download.bin"; + const fileSizeRaw = Number(payload.filesize ?? NaN); + return { + fileName, + directUrl, + fileSize: Number.isFinite(fileSizeRaw) && fileSizeRaw > 0 ? Math.floor(fileSizeRaw) : null, + retriesUsed: attempt - 1 + }; + } catch (error) { + lastError = compactErrorText(error); + if (signal?.aborted || (/aborted/i.test(lastError) && !/timeout/i.test(lastError))) { + break; + } + if (attempt >= REQUEST_RETRIES || !isRetryableErrorText(lastError)) { + break; + } + await sleepWithSignal(retryDelay(attempt), signal); + } + } + + throw new Error(String(lastError || "Unrestrict fehlgeschlagen").replace(/^Error:\s*/i, "")); + } +} diff --git a/src/main/rename-log.ts b/src/main/rename-log.ts new file mode 100644 index 0000000..9414c0c --- /dev/null +++ b/src/main/rename-log.ts @@ -0,0 +1,119 @@ +import fs from "node:fs"; +import { logTimestamp } from "./log-timestamp"; +import path from "node:path"; + +type RenameLogLevel = "INFO" | "WARN" | "ERROR"; + +const RENAME_LOG_MAX_FILE_BYTES = Number(process.env.RD_RENAME_LOG_MAX_BYTES || 10 * 1024 * 1024); +const RENAME_LOG_RETENTION_DAYS = Number(process.env.RD_RENAME_LOG_RETENTION_DAYS || 30); + +let renameLogPath: string | null = null; + +function sanitizeFieldValue(value: unknown): string { + if (value === undefined || value === null) { + return ""; + } + if (typeof value === "string") { + return value.replace(/\r?\n/g, "\\n"); + } + if (typeof value === "number" || typeof value === "boolean") { + return String(value); + } + try { + return JSON.stringify(value).replace(/\r?\n/g, "\\n"); + } catch { + return String(value); + } +} + +function formatFields(fields?: Record<string, unknown>): string { + if (!fields) { + return ""; + } + const parts = Object.entries(fields) + .filter(([, value]) => value !== undefined && value !== null && sanitizeFieldValue(value) !== "") + .map(([key, value]) => `${key}=${sanitizeFieldValue(value)}`); + return parts.length > 0 ? ` | ${parts.join(" | ")}` : ""; +} + +function rotateIfNeeded(filePath: string): void { + try { + const stat = fs.statSync(filePath); + if (stat.size < RENAME_LOG_MAX_FILE_BYTES) { + return; + } + const backup = `${filePath}.old`; + try { + fs.rmSync(backup, { force: true }); + } catch { + } + fs.renameSync(filePath, backup); + } catch { + } +} + +function cleanupOldBackup(filePath: string): void { + const backup = `${filePath}.old`; + try { + const stat = fs.statSync(backup); + const cutoff = Date.now() - RENAME_LOG_RETENTION_DAYS * 24 * 60 * 60 * 1000; + if (stat.mtimeMs < cutoff) { + fs.rmSync(backup, { force: true }); + } + } catch { + } +} + +export function initRenameLog(baseDir: string): void { + renameLogPath = path.join(baseDir, "rename.log"); + try { + fs.mkdirSync(path.dirname(renameLogPath), { recursive: true }); + cleanupOldBackup(renameLogPath); + if (!fs.existsSync(renameLogPath)) { + fs.writeFileSync(renameLogPath, "", "utf8"); + } + rotateIfNeeded(renameLogPath); + if (!fs.existsSync(renameLogPath)) { + fs.writeFileSync(renameLogPath, "", "utf8"); + } + fs.appendFileSync(renameLogPath, `=== Rename-Log Start: ${logTimestamp()} ===\n`, "utf8"); + } catch { + renameLogPath = null; + } +} + +export function logRenameEvent(level: RenameLogLevel, message: string, fields?: Record<string, unknown>): void { + if (!renameLogPath) { + return; + } + try { + rotateIfNeeded(renameLogPath); + if (!fs.existsSync(renameLogPath)) { + fs.writeFileSync(renameLogPath, "", "utf8"); + } + fs.appendFileSync( + renameLogPath, + `${logTimestamp()} [${level}] ${message}${formatFields(fields)}\n`, + "utf8" + ); + } catch { + } +} + +export function getRenameLogPath(): string | null { + if (!renameLogPath) { + return null; + } + return fs.existsSync(renameLogPath) ? renameLogPath : null; +} + +export function shutdownRenameLog(): void { + if (!renameLogPath) { + return; + } + try { + fs.appendFileSync(renameLogPath, `=== Rename-Log Ende: ${logTimestamp()} ===\n`, "utf8"); + } catch { + } + renameLogPath = null; +} diff --git a/src/main/session-log.ts b/src/main/session-log.ts new file mode 100644 index 0000000..51d1d8a --- /dev/null +++ b/src/main/session-log.ts @@ -0,0 +1,123 @@ +import fs from "node:fs"; +import { logTimestamp } from "./log-timestamp"; +import path from "node:path"; +import { setLogListener } from "./logger"; + +const SESSION_LOG_FLUSH_INTERVAL_MS = 200; + +let sessionLogPath: string | null = null; +let sessionLogsDir: string | null = null; +let pendingLines: string[] = []; +let flushTimer: NodeJS.Timeout | null = null; + +function formatTimestamp(): string { + const now = new Date(); + const y = now.getFullYear(); + const mo = String(now.getMonth() + 1).padStart(2, "0"); + const d = String(now.getDate()).padStart(2, "0"); + const h = String(now.getHours()).padStart(2, "0"); + const mi = String(now.getMinutes()).padStart(2, "0"); + const s = String(now.getSeconds()).padStart(2, "0"); + return `${y}-${mo}-${d}_${h}-${mi}-${s}`; +} + +function flushPending(): void { + if (pendingLines.length === 0 || !sessionLogPath) { + return; + } + const chunk = pendingLines.join(""); + pendingLines = []; + try { + fs.appendFileSync(sessionLogPath, chunk, "utf8"); + } catch { + } +} + +function scheduleFlush(): void { + if (flushTimer) { + return; + } + flushTimer = setTimeout(() => { + flushTimer = null; + flushPending(); + }, SESSION_LOG_FLUSH_INTERVAL_MS); +} + +function appendToSessionLog(line: string): void { + if (!sessionLogPath) { + return; + } + pendingLines.push(line); + scheduleFlush(); +} + +async function cleanupOldSessionLogs(dir: string, maxAgeDays: number): Promise<void> { + try { + const files = await fs.promises.readdir(dir); + const cutoff = Date.now() - maxAgeDays * 24 * 60 * 60 * 1000; + for (const file of files) { + if (!file.startsWith("session_") || !file.endsWith(".txt")) { + continue; + } + const filePath = path.join(dir, file); + try { + const stat = await fs.promises.stat(filePath); + if (stat.mtimeMs < cutoff) { + await fs.promises.unlink(filePath); + } + } catch { + } + } + } catch { + } +} + +export function initSessionLog(baseDir: string): void { + sessionLogsDir = path.join(baseDir, "session-logs"); + try { + fs.mkdirSync(sessionLogsDir, { recursive: true }); + } catch { + sessionLogsDir = null; + return; + } + + const timestamp = formatTimestamp(); + sessionLogPath = path.join(sessionLogsDir, `session_${timestamp}.txt`); + + const isoTimestamp = logTimestamp(); + try { + fs.writeFileSync(sessionLogPath, `=== Session gestartet: ${isoTimestamp} ===\n`, "utf8"); + } catch { + sessionLogPath = null; + return; + } + + setLogListener((line) => appendToSessionLog(line)); + + void cleanupOldSessionLogs(sessionLogsDir, 7); +} + +export function getSessionLogPath(): string | null { + return sessionLogPath; +} + +export function shutdownSessionLog(): void { + if (!sessionLogPath) { + return; + } + + if (flushTimer) { + clearTimeout(flushTimer); + flushTimer = null; + } + flushPending(); + + const isoTimestamp = logTimestamp(); + try { + fs.appendFileSync(sessionLogPath, `=== Session beendet: ${isoTimestamp} ===\n`, "utf8"); + } catch { + } + + setLogListener(null); + sessionLogPath = null; +} diff --git a/src/main/startup-health-check.ts b/src/main/startup-health-check.ts new file mode 100644 index 0000000..9dd73d4 --- /dev/null +++ b/src/main/startup-health-check.ts @@ -0,0 +1,195 @@ +import fs from "node:fs"; +import path from "node:path"; +import { AppSettings } from "../shared/types"; +import { parseDebridLinkApiKeys } from "../shared/debrid-link-keys"; +import { parseMegaDebridAccounts } from "../shared/mega-debrid-accounts"; +import { StoragePaths } from "./storage"; + +export type HealthCheckSeverity = "INFO" | "WARN" | "ERROR"; + +export interface HealthCheckFinding { + severity: HealthCheckSeverity; + code: string; + message: string; + hint?: string; +} + +export interface HealthCheckReport { + findings: HealthCheckFinding[]; + errorCount: number; + warnCount: number; + infoCount: number; +} + +const LOW_DISK_SPACE_BYTES = 5 * 1024 * 1024 * 1024; +const LARGE_STATE_FILE_BYTES = 50 * 1024 * 1024; + +function safeExists(p: string): boolean { + try { + return fs.existsSync(p); + } catch { + return false; + } +} + +function getFileSizeBytes(p: string): number { + try { + const stat = fs.statSync(p); + return stat.size; + } catch { + return 0; + } +} + +function isWritable(dir: string): boolean { + const probe = path.join(dir, `.rddl-health-probe-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`); + try { + fs.writeFileSync(probe, "x", { encoding: "utf8" }); + fs.rmSync(probe, { force: true }); + return true; + } catch { + return false; + } +} + +function getFreeDiskSpaceBytes(target: string): number | null { + try { + const statfs = (fs as unknown as { statfsSync?: (p: string) => { bavail: bigint; bsize: bigint } }).statfsSync; + if (typeof statfs !== "function") { + return null; + } + const result = statfs(target); + const bavail = BigInt(result.bavail); + const bsize = BigInt(result.bsize); + const free = bavail * bsize; + if (free > BigInt(Number.MAX_SAFE_INTEGER)) { + return Number.MAX_SAFE_INTEGER; + } + return Number(free); + } catch { + return null; + } +} + +function countConfiguredProviders(settings: AppSettings): { count: number; providers: string[] } { + const providers: string[] = []; + if (settings.token?.trim() || settings.realDebridUseWebLogin) { + providers.push("Real-Debrid"); + } + if (settings.allDebridToken?.trim() || settings.allDebridUseWebLogin) { + providers.push("AllDebrid"); + } + if (settings.bestToken?.trim() || settings.bestDebridUseWebLogin) { + providers.push("BestDebrid"); + } + if (settings.oneFichierApiKey?.trim()) { + providers.push("1Fichier"); + } + if (settings.ddownloadLogin?.trim() && settings.ddownloadPassword?.trim()) { + providers.push("DDownload"); + } + if (settings.linkSnappyLogin?.trim() && settings.linkSnappyPassword?.trim()) { + providers.push("LinkSnappy"); + } + const dlKeys = parseDebridLinkApiKeys(settings.debridLinkApiKeys || ""); + if (dlKeys.length > 0) { + providers.push(`Debrid-Link (${dlKeys.length} Key${dlKeys.length === 1 ? "" : "s"})`); + } + const megaAccounts = parseMegaDebridAccounts(settings.megaCredentials || ""); + const legacyMegaConfigured = Boolean(settings.megaLogin?.trim() && settings.megaPassword?.trim()); + if (megaAccounts.length > 0) { + providers.push(`Mega-Debrid (${megaAccounts.length} Acc)`); + } else if (legacyMegaConfigured) { + providers.push("Mega-Debrid"); + } + return { count: providers.length, providers }; +} + +export function runStartupHealthCheck(settings: AppSettings, storagePaths: StoragePaths): HealthCheckReport { + const findings: HealthCheckFinding[] = []; + + const outputDir = String(settings.outputDir || "").trim(); + if (!outputDir) { + findings.push({ + severity: "WARN", + code: "outputDir_missing", + message: "Kein Download-Ziel-Verzeichnis konfiguriert", + hint: "In den Einstellungen unter 'Downloads' einen Ziel-Ordner setzen, sonst koennen keine Downloads starten." + }); + } else if (!safeExists(outputDir)) { + findings.push({ + severity: "WARN", + code: "outputDir_not_found", + message: `Download-Ziel-Ordner existiert nicht: ${outputDir}`, + hint: "Der Ordner wird beim ersten Download automatisch erstellt, sofern der Elternordner existiert und beschreibbar ist." + }); + } else if (!isWritable(outputDir)) { + findings.push({ + severity: "ERROR", + code: "outputDir_not_writable", + message: `Download-Ziel-Ordner ist NICHT beschreibbar: ${outputDir}`, + hint: "Rechte pruefen oder anderen Ordner waehlen. Downloads werden sonst direkt scheitern." + }); + } else { + const freeBytes = getFreeDiskSpaceBytes(outputDir); + if (freeBytes !== null && freeBytes < LOW_DISK_SPACE_BYTES) { + const freeMb = Math.round(freeBytes / (1024 * 1024)); + findings.push({ + severity: "WARN", + code: "low_disk_space", + message: `Wenig freier Speicher im Download-Ordner: ~${freeMb} MB verfuegbar (Schwelle ${LOW_DISK_SPACE_BYTES / (1024 * 1024 * 1024)} GB)`, + hint: "Groessere Downloads koennen auf halbem Weg fehlschlagen. Vorher Platz schaffen oder anderen Ordner waehlen." + }); + } + } + + const { count, providers } = countConfiguredProviders(settings); + if (count === 0) { + findings.push({ + severity: "WARN", + code: "no_provider_configured", + message: "Kein Debrid-Provider konfiguriert — Downloads werden nicht funktionieren", + hint: "In den Einstellungen mindestens einen Provider (Real-Debrid, Mega-Debrid, Debrid-Link, ...) einrichten." + }); + } else { + findings.push({ + severity: "INFO", + code: "providers_configured", + message: `Konfigurierte Provider: ${providers.join(", ")}` + }); + } + + if (safeExists(storagePaths.sessionFile)) { + const sizeBytes = getFileSizeBytes(storagePaths.sessionFile); + if (sizeBytes > LARGE_STATE_FILE_BYTES) { + const sizeMb = Math.round(sizeBytes / (1024 * 1024)); + findings.push({ + severity: "WARN", + code: "large_state_file", + message: `State-Datei ist sehr gross: ${sizeMb} MB (${path.basename(storagePaths.sessionFile)})`, + hint: "Alte abgeschlossene Pakete aus der Queue entfernen, damit Startup + Save schneller werden." + }); + } + } + + if (!safeExists(storagePaths.baseDir)) { + findings.push({ + severity: "ERROR", + code: "baseDir_missing", + message: `Runtime-Verzeichnis existiert nicht: ${storagePaths.baseDir}`, + hint: "Ohne Runtime-Verzeichnis koennen weder Settings noch Session-State persistiert werden." + }); + } else if (!isWritable(storagePaths.baseDir)) { + findings.push({ + severity: "ERROR", + code: "baseDir_not_writable", + message: `Runtime-Verzeichnis ist NICHT beschreibbar: ${storagePaths.baseDir}`, + hint: "Rechte auf das Runtime-Verzeichnis pruefen (%APPDATA%/Real-Debrid-Downloader/runtime)." + }); + } + + const errorCount = findings.filter((f) => f.severity === "ERROR").length; + const warnCount = findings.filter((f) => f.severity === "WARN").length; + const infoCount = findings.filter((f) => f.severity === "INFO").length; + return { findings, errorCount, warnCount, infoCount }; +} diff --git a/src/main/storage.ts b/src/main/storage.ts new file mode 100644 index 0000000..97cc306 --- /dev/null +++ b/src/main/storage.ts @@ -0,0 +1,1340 @@ +import fs from "node:fs"; +import fsp from "node:fs/promises"; +import path from "node:path"; +import { getDebridLinkApiKeyIds } from "../shared/debrid-link-keys"; +import { getMegaDebridAccountIds } from "../shared/mega-debrid-accounts"; +import { AppSettings, AudioStripSummary, BandwidthScheduleEntry, DebridAccountStatus, DebridFallbackProvider, DebridProvider, DownloadItem, DownloadStatus, HistoryEntry, HistoryRetentionMode, PackageEntry, PackagePriority, SessionState } from "../shared/types"; +import { getProviderUsageDayKey } from "../shared/provider-daily-limits"; +import { defaultSettings } from "./constants"; +import { logger } from "./logger"; + +const VALID_PRIMARY_PROVIDERS = new Set(["realdebrid", "megadebrid-api", "megadebrid-web", "bestdebrid", "alldebrid", "ddownload", "onefichier", "debridlink", "linksnappy"]); +const VALID_FALLBACK_PROVIDERS = new Set(["none", "realdebrid", "megadebrid-api", "megadebrid-web", "bestdebrid", "alldebrid", "ddownload", "onefichier", "debridlink", "linksnappy"]); +const VALID_CLEANUP_MODES = new Set(["none", "trash", "delete"]); +const VALID_CONFLICT_MODES = new Set(["overwrite", "skip", "rename", "ask"]); +const VALID_FINISHED_POLICIES = new Set(["never", "immediate", "on_start", "package_done"]); +const VALID_SPEED_MODES = new Set(["global", "per_download"]); +const VALID_THEMES = new Set(["dark", "light"]); +const VALID_EXTRACT_CPU_PRIORITIES = new Set(["high", "middle", "low"]); +const VALID_HISTORY_RETENTION_MODES = new Set<HistoryRetentionMode>(["never", "session", "permanent"]); +const VALID_PACKAGE_PRIORITIES = new Set<string>(["high", "normal", "low"]); +const VALID_DOWNLOAD_STATUSES = new Set<DownloadStatus>([ + "queued", "validating", "downloading", "paused", "reconnect_wait", "extracting", "integrity_check", "completed", "failed", "cancelled" +]); +const VALID_ITEM_PROVIDERS = new Set<DebridProvider>(["realdebrid", "megadebrid", "megadebrid-api", "megadebrid-web", "bestdebrid", "alldebrid", "ddownload", "onefichier", "debridlink"]); +const VALID_ONLINE_STATUSES = new Set(["online", "offline", "checking"]); +const SAFE_SESSION_ID_RE = /^[A-Za-z0-9._-]{1,128}$/; + +function asText(value: unknown): string { + return String(value ?? "").trim(); +} + +function normalizeSessionId(value: unknown): string { + const text = asText(value); + if (!text || !SAFE_SESSION_ID_RE.test(text)) { + return ""; + } + return text; +} + +function isPathInsideDir(filePath: string, dirPath: string): boolean { + try { + const resolvedFile = path.resolve(filePath); + const resolvedDir = path.resolve(dirPath); + const normalizedFile = process.platform === "win32" ? resolvedFile.toLowerCase() : resolvedFile; + const normalizedDir = process.platform === "win32" ? resolvedDir.toLowerCase() : resolvedDir; + return normalizedFile === normalizedDir || normalizedFile.startsWith(`${normalizedDir}${path.sep}`); + } catch { + return false; + } +} + +function normalizeSessionTargetPath(value: unknown, packageOutputDir: string): string { + const targetPath = asText(value); + if (!targetPath || !packageOutputDir || !path.isAbsolute(targetPath)) { + return ""; + } + if (!isPathInsideDir(targetPath, packageOutputDir)) { + return ""; + } + return path.resolve(targetPath); +} + +function clampNumber(value: unknown, fallback: number, min: number, max: number): number { + const num = Number(value); + if (!Number.isFinite(num)) { + return fallback; + } + return Math.max(min, Math.min(max, Math.floor(num))); +} + +function createScheduleId(index: number): string { + return `sched-${Date.now().toString(36)}-${index.toString(36)}-${Math.random().toString(36).slice(2, 8)}`; +} + +function normalizeBandwidthSchedules(raw: unknown): BandwidthScheduleEntry[] { + if (!Array.isArray(raw)) { + return []; + } + + const normalized: BandwidthScheduleEntry[] = []; + for (let index = 0; index < raw.length; index += 1) { + const entry = raw[index]; + if (!entry || typeof entry !== "object") { + continue; + } + const value = entry as Partial<BandwidthScheduleEntry>; + const rawId = typeof value.id === "string" ? value.id.trim() : ""; + normalized.push({ + id: rawId || createScheduleId(index), + startHour: clampNumber(value.startHour, 0, 0, 23), + endHour: clampNumber(value.endHour, 8, 0, 23), + speedLimitKbps: clampNumber(value.speedLimitKbps, 0, 0, 500000), + enabled: value.enabled === undefined ? true : Boolean(value.enabled) + }); + } + return normalized; +} + +function normalizeAbsoluteDir(value: unknown, fallback: string): string { + const text = asText(value); + if (!text || !path.isAbsolute(text)) { + return path.resolve(fallback); + } + return path.resolve(text); +} + +const DEFAULT_COLUMN_ORDER = ["name", "size", "progress", "hoster", "account", "prio", "status", "speed"]; +const ALL_VALID_COLUMNS = new Set([...DEFAULT_COLUMN_ORDER, "added"]); + +function normalizeColumnOrder(raw: unknown): string[] { + if (!Array.isArray(raw) || raw.length === 0) { + return [...DEFAULT_COLUMN_ORDER]; + } + const valid = ALL_VALID_COLUMNS; + const seen = new Set<string>(); + const result: string[] = []; + for (const col of raw) { + if (typeof col === "string" && valid.has(col) && !seen.has(col)) { + seen.add(col); + result.push(col); + } + } + if (!seen.has("name")) { + result.unshift("name"); + } + return result; +} + +function getPreferredMegaDebridProvider(megaDebridPreferApi: boolean, megaDebridApiEnabled: boolean, megaDebridWebEnabled: boolean): DebridProvider { + if (megaDebridApiEnabled && !megaDebridWebEnabled) { + return "megadebrid-api"; + } + if (megaDebridWebEnabled && !megaDebridApiEnabled) { + return "megadebrid-web"; + } + return megaDebridPreferApi ? "megadebrid-api" : "megadebrid-web"; +} + +function normalizeConfiguredProvider(raw: unknown, megaDebridPreferApi: boolean, megaDebridApiEnabled: boolean, megaDebridWebEnabled: boolean): DebridProvider | null { + const provider = String(raw ?? "").trim(); + if (!provider) { + return null; + } + if (provider === "megadebrid") { + return getPreferredMegaDebridProvider(megaDebridPreferApi, megaDebridApiEnabled, megaDebridWebEnabled); + } + return VALID_PRIMARY_PROVIDERS.has(provider) ? provider as DebridProvider : null; +} + +function normalizeFallbackProvider(raw: unknown, megaDebridPreferApi: boolean, megaDebridApiEnabled: boolean, megaDebridWebEnabled: boolean): DebridFallbackProvider { + const provider = String(raw ?? "").trim(); + if (!provider || provider === "none") { + return "none"; + } + const normalized = normalizeConfiguredProvider(provider, megaDebridPreferApi, megaDebridApiEnabled, megaDebridWebEnabled); + return normalized || "none"; +} + +function normalizeDisabledProviders(raw: unknown): DebridProvider[] { + if (!Array.isArray(raw)) { + return []; + } + const seen = new Set<DebridProvider>(); + const result: DebridProvider[] = []; + for (const entry of raw) { + const provider = String(entry ?? "").trim(); + const candidates: DebridProvider[] = provider === "megadebrid" + ? ["megadebrid-api", "megadebrid-web"] + : (VALID_PRIMARY_PROVIDERS.has(provider) ? [provider as DebridProvider] : []); + for (const candidate of candidates) { + if (seen.has(candidate)) { + continue; + } + seen.add(candidate); + result.push(candidate); + } + } + return result; +} + +function normalizeProviderByteMap( + raw: unknown, + megaDebridPreferApi: boolean, + megaDebridApiEnabled: boolean, + megaDebridWebEnabled: boolean, + mergeMode: "max" | "sum" +): Partial<Record<DebridProvider, number>> { + if (!raw || typeof raw !== "object" || Array.isArray(raw)) { + return {}; + } + + const result: Partial<Record<DebridProvider, number>> = {}; + for (const [key, value] of Object.entries(raw as Record<string, unknown>)) { + const provider = normalizeConfiguredProvider(key, megaDebridPreferApi, megaDebridApiEnabled, megaDebridWebEnabled); + if (!provider) { + continue; + } + const bytes = clampNumber(value, 0, 0, Number.MAX_SAFE_INTEGER); + if (bytes <= 0) { + continue; + } + if (mergeMode === "sum") { + result[provider] = (result[provider] || 0) + bytes; + } else { + result[provider] = Math.max(result[provider] || 0, bytes); + } + } + return result; +} + +function normalizeNamedByteMap(raw: unknown, allowedKeys: readonly string[]): Record<string, number> { + if (!raw || typeof raw !== "object" || Array.isArray(raw)) { + return {}; + } + + const allowed = new Set(allowedKeys); + const result: Record<string, number> = {}; + for (const [key, value] of Object.entries(raw as Record<string, unknown>)) { + const normalizedKey = String(key || "").trim(); + if (!normalizedKey || !allowed.has(normalizedKey)) { + continue; + } + const bytes = clampNumber(value, 0, 0, Number.MAX_SAFE_INTEGER); + if (bytes <= 0) { + continue; + } + result[normalizedKey] = bytes; + } + return result; +} + +function normalizeDebridAccountStatuses( + value: unknown, + megaIds: string[], + debridLinkIds: string[] +): Record<string, DebridAccountStatus> { + const allowed = new Set([...megaIds, ...debridLinkIds]); + const result: Record<string, DebridAccountStatus> = {}; + if (value && typeof value === "object" && !Array.isArray(value)) { + for (const [key, raw] of Object.entries(value as Record<string, unknown>)) { + if (!allowed.has(key) || !raw || typeof raw !== "object") { + continue; + } + const entry = raw as Partial<DebridAccountStatus>; + if (typeof entry.accountId !== "string" || typeof entry.checkedAt !== "number") { + continue; + } + result[key] = { + accountId: entry.accountId, + provider: entry.provider === "debridlink" ? "debridlink" : "megadebrid", + label: String(entry.label || ""), + maskedLogin: String(entry.maskedLogin || ""), + valid: Boolean(entry.valid), + isPremium: Boolean(entry.isPremium), + premiumUntilMs: typeof entry.premiumUntilMs === "number" ? entry.premiumUntilMs : null, + email: typeof entry.email === "string" ? entry.email : undefined, + message: String(entry.message || ""), + checkedAt: entry.checkedAt + }; + } + } + return result; +} + +function normalizeStringList(raw: unknown, allowedKeys: readonly string[]): string[] { + if (!Array.isArray(raw)) { + return []; + } + + const allowed = new Set(allowedKeys); + const seen = new Set<string>(); + const result: string[] = []; + for (const entry of raw) { + const normalized = String(entry || "").trim(); + if (!normalized || !allowed.has(normalized) || seen.has(normalized)) { + continue; + } + seen.add(normalized); + result.push(normalized); + } + return result; +} + +function normalizeHosterRouting(raw: unknown, megaDebridPreferApi: boolean, megaDebridApiEnabled: boolean, megaDebridWebEnabled: boolean): Record<string, DebridProvider> { + if (!raw || typeof raw !== "object" || Array.isArray(raw)) return {}; + const result: Record<string, DebridProvider> = {}; + for (const [key, value] of Object.entries(raw as Record<string, unknown>)) { + const hoster = String(key).trim().toLowerCase(); + const provider = normalizeConfiguredProvider(value, megaDebridPreferApi, megaDebridApiEnabled, megaDebridWebEnabled); + if (hoster && provider) { + result[hoster] = provider; + } + } + return result; +} + +function normalizeProviderOrder( + raw: unknown, + megaDebridPreferApi: boolean, + megaDebridApiEnabled: boolean, + megaDebridWebEnabled: boolean, + legacyPrimary: unknown, + legacySecondary: unknown, + legacyTertiary: unknown +): DebridProvider[] { + let list: unknown[] = []; + + if (Array.isArray(raw) && raw.length > 0) { + list = raw; + } else { + const candidates = [legacyPrimary, legacySecondary, legacyTertiary].filter( + (v) => v && String(v).trim() && String(v).trim() !== "none" + ); + if (candidates.length > 0) { + list = candidates; + } + } + + const seen = new Set<DebridProvider>(); + const result: DebridProvider[] = []; + for (const entry of list) { + const provider = normalizeConfiguredProvider(entry, megaDebridPreferApi, megaDebridApiEnabled, megaDebridWebEnabled); + if (provider && !seen.has(provider)) { + seen.add(provider); + result.push(provider); + } + } + return result; +} + +const DEPRECATED_UPDATE_REPOS = new Set([ + "sucukdeluxe/real-debrid-downloader" +]); + +function migrateUpdateRepo(raw: string, fallback: string): string { + const trimmed = raw.trim(); + if (!trimmed || DEPRECATED_UPDATE_REPOS.has(trimmed.toLowerCase())) { + return fallback; + } + return trimmed; +} + +export function normalizeSettings(settings: AppSettings): AppSettings { + const defaults = defaultSettings(); + const currentUsageDay = getProviderUsageDayKey(); + const megaLogin = asText(settings.megaLogin); + const megaPassword = asText(settings.megaPassword); + let megaCredentials = String(settings.megaCredentials ?? "").replace(/\r\n|\r/g, "\n").trim(); + if (!megaCredentials && megaLogin && megaPassword) { + megaCredentials = `${megaLogin}:${megaPassword}`; + } + const megaDebridAccountIds = getMegaDebridAccountIds(megaCredentials); + const megaDebridPreferApi = settings.megaDebridPreferApi !== undefined ? Boolean(settings.megaDebridPreferApi) : true; + const hasMegaCreds = Boolean(megaLogin && megaPassword); + const megaDebridApiEnabled = settings.megaDebridApiEnabled !== undefined + ? Boolean(settings.megaDebridApiEnabled) + : (hasMegaCreds ? megaDebridPreferApi : defaults.megaDebridApiEnabled); + const megaDebridWebEnabled = settings.megaDebridWebEnabled !== undefined + ? Boolean(settings.megaDebridWebEnabled) + : (hasMegaCreds ? !megaDebridPreferApi : defaults.megaDebridWebEnabled); + const providerDailyUsageDayRaw = asText(settings.providerDailyUsageDay); + const providerDailyUsageDay = /^\d{4}-\d{2}-\d{2}$/.test(providerDailyUsageDayRaw) + ? providerDailyUsageDayRaw + : currentUsageDay; + const debridLinkApiKeyIds = getDebridLinkApiKeyIds(String(settings.debridLinkApiKeys ?? "")); + const providerDailyUsageBytes = normalizeProviderByteMap( + settings.providerDailyUsageBytes, + megaDebridPreferApi, megaDebridApiEnabled, megaDebridWebEnabled, + "sum" + ); + const providerTotalUsageBytes = normalizeProviderByteMap( + settings.providerTotalUsageBytes, + megaDebridPreferApi, megaDebridApiEnabled, megaDebridWebEnabled, + "sum" + ); + const debridLinkApiKeyDailyLimitBytes = normalizeNamedByteMap( + settings.debridLinkApiKeyDailyLimitBytes, + debridLinkApiKeyIds + ); + const debridLinkApiKeyDailyUsageBytes = normalizeNamedByteMap( + settings.debridLinkApiKeyDailyUsageBytes, + debridLinkApiKeyIds + ); + const debridLinkApiKeyTotalUsageBytes = normalizeNamedByteMap( + settings.debridLinkApiKeyTotalUsageBytes, + debridLinkApiKeyIds + ); + const debridLinkDisabledKeyIds = normalizeStringList(settings.debridLinkDisabledKeyIds, debridLinkApiKeyIds); + const normalized: AppSettings = { + token: asText(settings.token), + realDebridUseWebLogin: Boolean(settings.realDebridUseWebLogin), + megaLogin, + megaPassword, + megaCredentials, + megaDebridApiEnabled, + megaDebridWebEnabled, + megaDebridPreferApi, + bestToken: asText(settings.bestToken), + bestDebridUseWebLogin: Boolean(settings.bestDebridUseWebLogin), + allDebridToken: asText(settings.allDebridToken), + allDebridUseWebLogin: Boolean(settings.allDebridUseWebLogin), + ddownloadLogin: asText(settings.ddownloadLogin), + ddownloadPassword: asText(settings.ddownloadPassword), + oneFichierApiKey: asText(settings.oneFichierApiKey), + debridLinkApiKeys: String(settings.debridLinkApiKeys ?? "").replace(/\r\n|\r/g, "\n").trim(), + debridLinkDisabledKeyIds, + linkSnappyLogin: asText(settings.linkSnappyLogin), + linkSnappyPassword: asText(settings.linkSnappyPassword), + archivePasswordList: String(settings.archivePasswordList ?? "").replace(/\r\n|\r/g, "\n"), + rememberToken: Boolean(settings.rememberToken), + providerOrder: normalizeProviderOrder( + settings.providerOrder, + megaDebridPreferApi, megaDebridApiEnabled, megaDebridWebEnabled, + settings.providerPrimary, settings.providerSecondary, settings.providerTertiary + ), + providerPrimary: normalizeConfiguredProvider(settings.providerPrimary, megaDebridPreferApi, megaDebridApiEnabled, megaDebridWebEnabled) || defaults.providerPrimary, + providerSecondary: normalizeFallbackProvider(settings.providerSecondary, megaDebridPreferApi, megaDebridApiEnabled, megaDebridWebEnabled), + providerTertiary: normalizeFallbackProvider(settings.providerTertiary, megaDebridPreferApi, megaDebridApiEnabled, megaDebridWebEnabled), + autoProviderFallback: Boolean(settings.autoProviderFallback), + outputDir: normalizeAbsoluteDir(settings.outputDir, defaults.outputDir), + packageName: asText(settings.packageName), + autoExtract: Boolean(settings.autoExtract), + autoRename4sf4sj: Boolean(settings.autoRename4sf4sj), + keepGermanAudioOnly: Boolean(settings.keepGermanAudioOnly), + germanAudioMode: settings.germanAudioMode === "first" ? "first" : "tag", + extractDir: normalizeAbsoluteDir(settings.extractDir, defaults.extractDir), + collectMkvToLibrary: Boolean(settings.collectMkvToLibrary), + mkvLibraryDir: normalizeAbsoluteDir(settings.mkvLibraryDir, defaults.mkvLibraryDir), + createExtractSubfolder: Boolean(settings.createExtractSubfolder), + hybridExtract: Boolean(settings.hybridExtract), + cleanupMode: settings.cleanupMode, + extractConflictMode: settings.extractConflictMode, + removeLinkFilesAfterExtract: Boolean(settings.removeLinkFilesAfterExtract), + removeSamplesAfterExtract: Boolean(settings.removeSamplesAfterExtract), + enableIntegrityCheck: Boolean(settings.enableIntegrityCheck), + autoResumeOnStart: Boolean(settings.autoResumeOnStart), + autoReconnect: Boolean(settings.autoReconnect), + maxParallel: clampNumber(settings.maxParallel, defaults.maxParallel, 1, 50), + maxParallelExtract: clampNumber(settings.maxParallelExtract, defaults.maxParallelExtract, 1, 8), + retryLimit: clampNumber(settings.retryLimit, defaults.retryLimit, 0, 99), + reconnectWaitSeconds: clampNumber(settings.reconnectWaitSeconds, defaults.reconnectWaitSeconds, 10, 600), + completedCleanupPolicy: settings.completedCleanupPolicy, + speedLimitEnabled: Boolean(settings.speedLimitEnabled), + speedLimitKbps: clampNumber(settings.speedLimitKbps, defaults.speedLimitKbps, 0, 500000), + speedLimitMode: settings.speedLimitMode, + autoUpdateCheck: Boolean(settings.autoUpdateCheck), + updateRepo: migrateUpdateRepo(asText(settings.updateRepo), defaults.updateRepo), + clipboardWatch: Boolean(settings.clipboardWatch), + minimizeToTray: Boolean(settings.minimizeToTray), + collapseNewPackages: settings.collapseNewPackages !== undefined ? Boolean(settings.collapseNewPackages) : defaults.collapseNewPackages, + historyRetentionMode: VALID_HISTORY_RETENTION_MODES.has(settings.historyRetentionMode) + ? settings.historyRetentionMode + : defaults.historyRetentionMode, + historyMaxEntries: clampNumber(settings.historyMaxEntries, defaults.historyMaxEntries, 50, 100000), + historyMaxAgeDays: clampNumber(settings.historyMaxAgeDays, defaults.historyMaxAgeDays, 0, 3650), + accountListShowDetailedDebridLinkKeys: settings.accountListShowDetailedDebridLinkKeys !== undefined + ? Boolean(settings.accountListShowDetailedDebridLinkKeys) + : defaults.accountListShowDetailedDebridLinkKeys, + autoSortPackagesByProgress: settings.autoSortPackagesByProgress !== undefined ? Boolean(settings.autoSortPackagesByProgress) : defaults.autoSortPackagesByProgress, + autoSkipExtracted: settings.autoSkipExtracted !== undefined ? Boolean(settings.autoSkipExtracted) : defaults.autoSkipExtracted, + hideExtractedItems: settings.hideExtractedItems !== undefined ? Boolean(settings.hideExtractedItems) : defaults.hideExtractedItems, + confirmDeleteSelection: settings.confirmDeleteSelection !== undefined ? Boolean(settings.confirmDeleteSelection) : defaults.confirmDeleteSelection, + backupIncludeDownloads: settings.backupIncludeDownloads !== undefined ? Boolean(settings.backupIncludeDownloads) : defaults.backupIncludeDownloads, + backupIncludeRemoteDiagnostics: settings.backupIncludeRemoteDiagnostics !== undefined ? Boolean(settings.backupIncludeRemoteDiagnostics) : defaults.backupIncludeRemoteDiagnostics, + notifyUrl: asText(settings.notifyUrl) || defaults.notifyUrl, + notifyMention: asText(settings.notifyMention) || defaults.notifyMention, + notifyOnPackageCompleted: settings.notifyOnPackageCompleted !== undefined ? Boolean(settings.notifyOnPackageCompleted) : defaults.notifyOnPackageCompleted, + notifyOnPackageFailed: settings.notifyOnPackageFailed !== undefined ? Boolean(settings.notifyOnPackageFailed) : defaults.notifyOnPackageFailed, + notifyOnRunFinished: settings.notifyOnRunFinished !== undefined ? Boolean(settings.notifyOnRunFinished) : defaults.notifyOnRunFinished, + totalDownloadedAllTime: typeof settings.totalDownloadedAllTime === "number" && settings.totalDownloadedAllTime >= 0 ? settings.totalDownloadedAllTime : defaults.totalDownloadedAllTime, + totalCompletedFilesAllTime: typeof settings.totalCompletedFilesAllTime === "number" && settings.totalCompletedFilesAllTime >= 0 ? settings.totalCompletedFilesAllTime : defaults.totalCompletedFilesAllTime, + totalRuntimeAllTimeMs: typeof settings.totalRuntimeAllTimeMs === "number" && settings.totalRuntimeAllTimeMs >= 0 ? settings.totalRuntimeAllTimeMs : defaults.totalRuntimeAllTimeMs, + theme: VALID_THEMES.has(settings.theme) ? settings.theme : defaults.theme, + bandwidthSchedules: normalizeBandwidthSchedules(settings.bandwidthSchedules), + columnOrder: normalizeColumnOrder(settings.columnOrder), + extractCpuPriority: settings.extractCpuPriority, + autoExtractWhenStopped: settings.autoExtractWhenStopped !== undefined ? Boolean(settings.autoExtractWhenStopped) : defaults.autoExtractWhenStopped, + disabledProviders: normalizeDisabledProviders(settings.disabledProviders), + hosterRouting: normalizeHosterRouting(settings.hosterRouting, megaDebridPreferApi, megaDebridApiEnabled, megaDebridWebEnabled), + providerDailyLimitBytes: normalizeProviderByteMap( + settings.providerDailyLimitBytes, + megaDebridPreferApi, megaDebridApiEnabled, megaDebridWebEnabled, + "max" + ), + providerDailyUsageBytes: providerDailyUsageDay === currentUsageDay ? providerDailyUsageBytes : {}, + providerTotalUsageBytes, + debridLinkApiKeyDailyLimitBytes, + debridLinkApiKeyDailyUsageBytes: providerDailyUsageDay === currentUsageDay ? debridLinkApiKeyDailyUsageBytes : {}, + debridLinkApiKeyTotalUsageBytes, + megaDebridDisabledAccountIds: normalizeStringList(settings.megaDebridDisabledAccountIds, megaDebridAccountIds), + megaDebridAccountDailyLimitBytes: normalizeNamedByteMap(settings.megaDebridAccountDailyLimitBytes, megaDebridAccountIds), + megaDebridAccountDailyUsageBytes: providerDailyUsageDay === currentUsageDay + ? normalizeNamedByteMap(settings.megaDebridAccountDailyUsageBytes, megaDebridAccountIds) + : {}, + megaDebridAccountTotalUsageBytes: normalizeNamedByteMap(settings.megaDebridAccountTotalUsageBytes, megaDebridAccountIds), + debridAccountStatuses: normalizeDebridAccountStatuses(settings.debridAccountStatuses, megaDebridAccountIds, debridLinkApiKeyIds), + providerDailyUsageDay: providerDailyUsageDay === currentUsageDay ? providerDailyUsageDay : currentUsageDay, + scheduledStartEpochMs: clampNumber(settings.scheduledStartEpochMs, defaults.scheduledStartEpochMs, 0, Number.MAX_SAFE_INTEGER) + }; + + if (!VALID_PRIMARY_PROVIDERS.has(normalized.providerPrimary)) { + normalized.providerPrimary = defaults.providerPrimary; + } + if (!VALID_FALLBACK_PROVIDERS.has(normalized.providerSecondary)) { + normalized.providerSecondary = "none"; + } + if (!VALID_FALLBACK_PROVIDERS.has(normalized.providerTertiary)) { + normalized.providerTertiary = "none"; + } + if (normalized.providerSecondary === normalized.providerPrimary) { + normalized.providerSecondary = "none"; + } + if (normalized.providerTertiary === normalized.providerPrimary || normalized.providerTertiary === normalized.providerSecondary) { + normalized.providerTertiary = "none"; + } + if (!VALID_CLEANUP_MODES.has(normalized.cleanupMode)) { + normalized.cleanupMode = defaults.cleanupMode; + } + if (!VALID_CONFLICT_MODES.has(normalized.extractConflictMode)) { + normalized.extractConflictMode = defaults.extractConflictMode; + } + if (!VALID_FINISHED_POLICIES.has(normalized.completedCleanupPolicy)) { + normalized.completedCleanupPolicy = defaults.completedCleanupPolicy; + } + if (!VALID_SPEED_MODES.has(normalized.speedLimitMode)) { + normalized.speedLimitMode = defaults.speedLimitMode; + } + if (!VALID_EXTRACT_CPU_PRIORITIES.has(normalized.extractCpuPriority)) { + normalized.extractCpuPriority = defaults.extractCpuPriority; + } + + return normalized; +} + +function sanitizeCredentialPersistence(settings: AppSettings): AppSettings { + if (settings.rememberToken) { + return settings; + } + return { + ...settings, + token: "", + realDebridUseWebLogin: settings.realDebridUseWebLogin, + megaLogin: "", + megaPassword: "", + megaCredentials: "", + bestToken: "", + bestDebridUseWebLogin: settings.bestDebridUseWebLogin, + allDebridToken: "", + ddownloadLogin: "", + ddownloadPassword: "", + oneFichierApiKey: "", + debridLinkApiKeys: "", + linkSnappyLogin: "", + linkSnappyPassword: "" + }; +} + +export interface StoragePaths { + baseDir: string; + configFile: string; + sessionFile: string; + historyFile: string; +} + +export function createStoragePaths(baseDir: string): StoragePaths { + return { + baseDir, + configFile: path.join(baseDir, "rd_downloader_config.json"), + sessionFile: path.join(baseDir, "rd_session_state.json"), + historyFile: path.join(baseDir, "rd_history.json") + }; +} + +function ensureBaseDir(baseDir: string): void { + try { + fs.mkdirSync(baseDir, { recursive: true }); + } catch (error) { + const code = (error as NodeJS.ErrnoException)?.code || ""; + if (code === "EACCES" || code === "EPERM") { + logger.error(`AppData-Ordner kann nicht erstellt werden (${code}): ${baseDir} - pruefe Schreibrechte fuer Benutzer ${process.env.USERNAME || process.env.USER || "?"}`); + } + throw error; + } +} + +function safeJsonReplacer(_key: string, value: unknown): unknown { + if (typeof value === "number" && !Number.isFinite(value)) { + return null; + } + return value; +} + +function asRecord(value: unknown): Record<string, unknown> | null { + if (!value || typeof value !== "object" || Array.isArray(value)) { + return null; + } + return value as Record<string, unknown>; +} + +function normalizeAudioStripSummary(raw: unknown): AudioStripSummary | undefined { + const parsed = asRecord(raw); + if (!parsed) { + return undefined; + } + const files = Array.isArray(parsed.files) + ? parsed.files.slice(0, 100).flatMap((entry) => { + const file = asRecord(entry); + if (!file) { + return []; + } + const name = asText(file.name); + if (!name) { + return []; + } + const languages = asText(file.languages); + return [{ name, action: asText(file.action), reason: asText(file.reason), ...(languages ? { languages } : {}) }]; + }) + : []; + return { + at: clampNumber(parsed.at, 0, 0, Number.MAX_SAFE_INTEGER), + candidates: clampNumber(parsed.candidates, 0, 0, 1_000_000), + remuxed: clampNumber(parsed.remuxed, 0, 0, 1_000_000), + keptSingle: clampNumber(parsed.keptSingle, 0, 0, 1_000_000), + skippedNoGerman: clampNumber(parsed.skippedNoGerman, 0, 0, 1_000_000), + skippedNoTool: clampNumber(parsed.skippedNoTool, 0, 0, 1_000_000), + failed: clampNumber(parsed.failed, 0, 0, 1_000_000), + files + }; +} + +function migrateLegacyMegaEnableFlags(parsed: AppSettings): AppSettings { + if (parsed.megaDebridApiEnabled !== undefined || parsed.megaDebridWebEnabled !== undefined) { + return parsed; + } + const hasMegaCreds = Boolean(asText(parsed.megaLogin) && asText(parsed.megaPassword)); + if (!hasMegaCreds) { + return parsed; + } + const preferApi = parsed.megaDebridPreferApi !== undefined ? Boolean(parsed.megaDebridPreferApi) : true; + return { ...parsed, megaDebridApiEnabled: preferApi, megaDebridWebEnabled: !preferApi }; +} + +function readSettingsFile(filePath: string): AppSettings | null { + try { + const parsed = JSON.parse(fs.readFileSync(filePath, "utf8")) as AppSettings; + const merged = normalizeSettings({ + ...defaultSettings(), + ...migrateLegacyMegaEnableFlags(parsed) + }); + return sanitizeCredentialPersistence(merged); + } catch (error) { + const code = (error as NodeJS.ErrnoException)?.code || ""; + if (code === "ENOENT") { + } else if (code === "EACCES" || code === "EPERM") { + logger.error(`Settings-Datei nicht zugreifbar (${code}): ${filePath} - pruefe Datei-/Ordner-Berechtigungen fuer Benutzer ${process.env.USERNAME || process.env.USER || "?"}`); + } else { + logger.warn(`Settings-Datei nicht lesbar: ${filePath}: ${String(error)}`); + } + return null; + } +} + +export function normalizeLoadedSession(raw: unknown): SessionState { + const fallback = emptySession(); + const parsed = asRecord(raw); + if (!parsed) { + return fallback; + } + + const now = Date.now(); + const itemsById: Record<string, DownloadItem> = {}; + const rawItems = asRecord(parsed.items) ?? {}; + for (const [entryId, rawItem] of Object.entries(rawItems)) { + const item = asRecord(rawItem); + if (!item) { + continue; + } + const id = normalizeSessionId(item.id) || normalizeSessionId(entryId); + const packageId = normalizeSessionId(item.packageId); + const url = asText(item.url); + if (!id || !packageId || !url) { + continue; + } + + const statusRaw = asText(item.status) as DownloadStatus; + const status: DownloadStatus = VALID_DOWNLOAD_STATUSES.has(statusRaw) ? statusRaw : "queued"; + const providerRaw = asText(item.provider) as DebridProvider; + + const onlineStatusRaw = asText(item.onlineStatus); + + itemsById[id] = { + id, + packageId, + url, + provider: VALID_ITEM_PROVIDERS.has(providerRaw) ? providerRaw : null, + providerLabel: asText(item.providerLabel) || undefined, + providerAccountId: asText(item.providerAccountId) || undefined, + providerAccountLabel: asText(item.providerAccountLabel) || undefined, + status, + retries: clampNumber(item.retries, 0, 0, 1_000_000), + speedBps: clampNumber(item.speedBps, 0, 0, 10_000_000_000), + downloadedBytes: clampNumber(item.downloadedBytes, 0, 0, 10_000_000_000_000), + totalBytes: item.totalBytes == null ? null : clampNumber(item.totalBytes, 0, 0, 10_000_000_000_000), + progressPercent: clampNumber(item.progressPercent, 0, 0, 100), + fileName: asText(item.fileName) || "download.bin", + targetPath: asText(item.targetPath), + resumable: item.resumable === undefined ? true : Boolean(item.resumable), + attempts: clampNumber(item.attempts, 0, 0, 10_000), + lastError: asText(item.lastError), + fullStatus: asText(item.fullStatus), + onlineStatus: VALID_ONLINE_STATUSES.has(onlineStatusRaw) ? onlineStatusRaw as "online" | "offline" | "checking" : undefined, + createdAt: clampNumber(item.createdAt, now, 0, Number.MAX_SAFE_INTEGER), + updatedAt: clampNumber(item.updatedAt, now, 0, Number.MAX_SAFE_INTEGER) + }; + } + + const packagesById: Record<string, PackageEntry> = {}; + const rawPackages = asRecord(parsed.packages) ?? {}; + for (const [entryId, rawPkg] of Object.entries(rawPackages)) { + const pkg = asRecord(rawPkg); + if (!pkg) { + continue; + } + const id = normalizeSessionId(pkg.id) || normalizeSessionId(entryId); + if (!id) { + continue; + } + const statusRaw = asText(pkg.status) as DownloadStatus; + const status: DownloadStatus = VALID_DOWNLOAD_STATUSES.has(statusRaw) ? statusRaw : "queued"; + const rawItemIds = Array.isArray(pkg.itemIds) ? pkg.itemIds : []; + packagesById[id] = { + id, + name: asText(pkg.name) || "Paket", + outputDir: asText(pkg.outputDir), + extractDir: asText(pkg.extractDir), + status, + itemIds: rawItemIds + .map((value) => normalizeSessionId(value)) + .filter((value) => value.length > 0), + cancelled: Boolean(pkg.cancelled), + enabled: pkg.enabled === undefined ? true : Boolean(pkg.enabled), + priority: VALID_PACKAGE_PRIORITIES.has(asText(pkg.priority)) ? asText(pkg.priority) as PackagePriority : "normal", + audioStripSummary: normalizeAudioStripSummary(pkg.audioStripSummary), + downloadStartedAt: clampNumber(pkg.downloadStartedAt, 0, 0, Number.MAX_SAFE_INTEGER), + downloadCompletedAt: clampNumber(pkg.downloadCompletedAt, 0, 0, Number.MAX_SAFE_INTEGER), + createdAt: clampNumber(pkg.createdAt, now, 0, Number.MAX_SAFE_INTEGER), + updatedAt: clampNumber(pkg.updatedAt, now, 0, Number.MAX_SAFE_INTEGER) + }; + } + + let orphanedItemCount = 0; + for (const [itemId, item] of Object.entries(itemsById)) { + if (!packagesById[item.packageId]) { + orphanedItemCount += 1; + delete itemsById[itemId]; + } + } + if (orphanedItemCount > 0) { + logger.warn(`normalizeLoadedSession: ${orphanedItemCount} verwaiste Items entfernt (fehlende Pakete)`); + } + + let droppedUnsafeTargetPathCount = 0; + for (const item of Object.values(itemsById)) { + const pkg = packagesById[item.packageId]; + if (!pkg) { + continue; + } + const safeTargetPath = normalizeSessionTargetPath(item.targetPath, pkg.outputDir); + if (!safeTargetPath && asText(item.targetPath)) { + droppedUnsafeTargetPathCount += 1; + } + item.targetPath = safeTargetPath; + } + if (droppedUnsafeTargetPathCount > 0) { + logger.warn(`normalizeLoadedSession: ${droppedUnsafeTargetPathCount} unsichere targetPath-Eintraege verworfen`); + } + + for (const pkg of Object.values(packagesById)) { + pkg.itemIds = pkg.itemIds.filter((itemId) => { + const item = itemsById[itemId]; + return Boolean(item) && item.packageId === pkg.id; + }); + } + + const rawOrder = Array.isArray(parsed.packageOrder) ? parsed.packageOrder : []; + const seenOrder = new Set<string>(); + const packageOrder = rawOrder + .map((entry) => normalizeSessionId(entry)) + .filter((id) => { + if (!(id in packagesById) || seenOrder.has(id)) { + return false; + } + seenOrder.add(id); + return true; + }); + for (const packageId of Object.keys(packagesById)) { + if (!seenOrder.has(packageId)) { + seenOrder.add(packageId); + packageOrder.push(packageId); + } + } + + return { + ...fallback, + version: clampNumber(parsed.version, fallback.version, 1, 10), + packageOrder, + packages: packagesById, + items: itemsById, + runStartedAt: clampNumber(parsed.runStartedAt, 0, 0, Number.MAX_SAFE_INTEGER), + totalDownloadedBytes: clampNumber(parsed.totalDownloadedBytes, 0, 0, Number.MAX_SAFE_INTEGER), + summaryText: asText(parsed.summaryText), + reconnectUntil: clampNumber(parsed.reconnectUntil, 0, 0, Number.MAX_SAFE_INTEGER), + reconnectReason: asText(parsed.reconnectReason), + paused: Boolean(parsed.paused), + running: Boolean(parsed.running), + updatedAt: clampNumber(parsed.updatedAt, now, 0, Number.MAX_SAFE_INTEGER) + }; +} + +export function loadSettings(paths: StoragePaths): AppSettings { + ensureBaseDir(paths.baseDir); + if (!fs.existsSync(paths.configFile)) { + return defaultSettings(); + } + const loaded = readSettingsFile(paths.configFile); + if (loaded) { + return loaded; + } + + const backupFile = `${paths.configFile}.bak`; + const backupLoaded = fs.existsSync(backupFile) ? readSettingsFile(backupFile) : null; + if (backupLoaded) { + logger.warn("Konfiguration defekt, Backup-Datei wird verwendet"); + try { + const payload = JSON.stringify(backupLoaded, safeJsonReplacer, 2); + const tempPath = `${paths.configFile}.tmp`; + fs.writeFileSync(tempPath, payload, "utf8"); + syncRenameWithExdevFallback(tempPath, paths.configFile); + } catch { + } + return backupLoaded; + } + + logger.error("Konfiguration konnte nicht geladen werden (auch Backup fehlgeschlagen)"); + return defaultSettings(); +} + +function syncRenameWithExdevFallback(tempPath: string, targetPath: string): void { + try { + fs.renameSync(tempPath, targetPath); + } catch (renameError: unknown) { + if (renameError && typeof renameError === "object" && "code" in renameError && (renameError as NodeJS.ErrnoException).code === "EXDEV") { + fs.copyFileSync(tempPath, targetPath); + try { fs.rmSync(tempPath, { force: true }); } catch {} + } else { + throw renameError; + } + } +} + +function sessionTempPath(sessionFile: string, kind: "sync" | "async"): string { + return `${sessionFile}.${kind}.tmp`; +} + +function sessionBackupPath(sessionFile: string): string { + return `${sessionFile}.bak`; +} + +export function normalizeLoadedSessionTransientFields(session: SessionState): SessionState { + const ACTIVE_STATUSES = new Set(["downloading", "validating", "extracting", "integrity_check", "paused", "reconnect_wait"]); + for (const item of Object.values(session.items)) { + if (ACTIVE_STATUSES.has(item.status)) { + item.status = "queued"; + item.lastError = ""; + } + item.speedBps = 0; + } + + const ACTIVE_PKG_STATUSES = new Set(["downloading", "validating", "extracting", "integrity_check", "paused", "reconnect_wait"]); + for (const pkg of Object.values(session.packages)) { + if (ACTIVE_PKG_STATUSES.has(pkg.status)) { + pkg.status = "queued"; + } + pkg.postProcessLabel = undefined; + } + + session.running = false; + session.paused = false; + + return session; +} + +const TRANSIENT_READ_CODES = new Set(["EBUSY", "EPERM", "EAGAIN"]); + +function sleepSyncMs(ms: number): void { + if (ms <= 0) { + return; + } + Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms); +} + +function readSessionFile(filePath: string): SessionState | null { + let raw: string | null = null; + const maxAttempts = 5; + for (let attempt = 1; attempt <= maxAttempts; attempt += 1) { + try { + raw = fs.readFileSync(filePath, "utf8"); + break; + } catch (error) { + const code = (error as NodeJS.ErrnoException)?.code || ""; + if (TRANSIENT_READ_CODES.has(code) && attempt < maxAttempts) { + const backoffMs = 100 * 2 ** (attempt - 1); + logger.warn(`Session-Datei vorübergehend gesperrt (${code}), Versuch ${attempt}/${maxAttempts}, warte ${backoffMs}ms: ${filePath}`); + sleepSyncMs(backoffMs); + continue; + } + if (code === "EACCES" || code === "EPERM") { + logger.error(`Session-Datei nicht zugreifbar (${code}): ${filePath} - pruefe Datei-/Ordner-Berechtigungen fuer Benutzer ${process.env.USERNAME || process.env.USER || "?"}`); + } else { + logger.error(`Session-Datei nicht lesbar (${code || "?"}): ${filePath}: ${String(error)}`); + } + return null; + } + } + if (raw === null) { + return null; + } + try { + const parsed = JSON.parse(raw) as unknown; + const session = normalizeLoadedSessionTransientFields(normalizeLoadedSession(parsed)); + const pkgCount = Object.keys(session.packages).length; + const itemCount = Object.keys(session.items).length; + logger.info(`Session geladen: ${filePath} (${pkgCount} Pakete, ${itemCount} Items)`); + return session; + } catch (error) { + logger.error(`Session-Datei beschädigt (JSON ungültig): ${filePath}: ${String(error)}`); + return null; + } +} + +export function saveSettings(paths: StoragePaths, settings: AppSettings): void { + syncSettingsSaveGeneration += 1; + ensureBaseDir(paths.baseDir); + if (fs.existsSync(paths.configFile)) { + try { + fs.copyFileSync(paths.configFile, `${paths.configFile}.bak`); + } catch { + } + } + const persisted = sanitizeCredentialPersistence(normalizeSettings(settings)); + const payload = JSON.stringify(persisted, safeJsonReplacer, 2); + const tempPath = `${paths.configFile}.tmp`; + try { + fs.writeFileSync(tempPath, payload, "utf8"); + syncRenameWithExdevFallback(tempPath, paths.configFile); + } catch (error) { + try { fs.rmSync(tempPath, { force: true }); } catch { } + throw error; + } +} + +let asyncSettingsSaveRunning = false; +let asyncSettingsSaveQueued: { paths: StoragePaths; payload: string; generation: number } | null = null; +let syncSettingsSaveGeneration = 0; + +async function writeSettingsPayload(paths: StoragePaths, payload: string, generation: number): Promise<void> { + await fs.promises.mkdir(paths.baseDir, { recursive: true }); + await fsp.copyFile(paths.configFile, `${paths.configFile}.bak`).catch(() => {}); + const tempPath = `${paths.configFile}.settings.tmp`; + await fsp.writeFile(tempPath, payload, "utf8"); + if (generation < syncSettingsSaveGeneration) { + await fsp.rm(tempPath, { force: true }).catch(() => {}); + return; + } + try { + await fsp.rename(tempPath, paths.configFile); + } catch (renameError: unknown) { + if (renameError && typeof renameError === "object" && "code" in renameError && (renameError as NodeJS.ErrnoException).code === "EXDEV") { + if (generation < syncSettingsSaveGeneration) { + await fsp.rm(tempPath, { force: true }).catch(() => {}); + return; + } + await fsp.copyFile(tempPath, paths.configFile); + await fsp.rm(tempPath, { force: true }).catch(() => {}); + } else { + await fsp.rm(tempPath, { force: true }).catch(() => {}); + throw renameError; + } + } +} + +async function saveSettingsPayloadAsync(paths: StoragePaths, payload: string, generation: number): Promise<void> { + if (asyncSettingsSaveRunning) { + asyncSettingsSaveQueued = { paths, payload, generation }; + return; + } + asyncSettingsSaveRunning = true; + try { + await writeSettingsPayload(paths, payload, generation); + } catch (error) { + logger.error(`Async Settings-Save fehlgeschlagen: ${String(error)}`); + } finally { + asyncSettingsSaveRunning = false; + if (asyncSettingsSaveQueued) { + const queued = asyncSettingsSaveQueued; + asyncSettingsSaveQueued = null; + void saveSettingsPayloadAsync(queued.paths, queued.payload, queued.generation); + } + } +} + +export async function saveSettingsAsync(paths: StoragePaths, settings: AppSettings): Promise<void> { + const generation = syncSettingsSaveGeneration; + const persisted = sanitizeCredentialPersistence(normalizeSettings(settings)); + const payload = JSON.stringify(persisted, safeJsonReplacer, 2); + await saveSettingsPayloadAsync(paths, payload, generation); +} + +export function emptySession(): SessionState { + return { + version: 2, + packageOrder: [], + packages: {}, + items: {}, + runStartedAt: 0, + totalDownloadedBytes: 0, + summaryText: "", + reconnectUntil: 0, + reconnectReason: "", + paused: false, + running: false, + updatedAt: Date.now() + }; +} + +export type SessionLoadStatus = + | "ok" + | "recovered-backup" + | "recovered-temp" + | "empty-fresh" + | "empty-unreadable"; + +export interface SessionLoadResult { + session: SessionState; + status: SessionLoadStatus; +} + +export function loadSessionWithStatus(paths: StoragePaths): SessionLoadResult { + ensureBaseDir(paths.baseDir); + const backupFile = sessionBackupPath(paths.sessionFile); + const syncTempFile = sessionTempPath(paths.sessionFile, "sync"); + const asyncTempFile = sessionTempPath(paths.sessionFile, "async"); + const primaryExists = fs.existsSync(paths.sessionFile); + const backupExists = fs.existsSync(backupFile); + const anyTempExists = fs.existsSync(syncTempFile) || fs.existsSync(asyncTempFile); + + if (!primaryExists) { + if (!backupExists && !anyTempExists) { + logger.info("Keine Session-Datei vorhanden, starte mit leerer Session"); + return { session: emptySession(), status: "empty-fresh" }; + } + logger.warn("Session-Primaerdatei fehlt, aber Backup/Temp vorhanden — Wiederherstellung wird versucht"); + } + + const primary = primaryExists ? readSessionFile(paths.sessionFile) : null; + + if (primary) { + const primaryPkgCount = Object.keys(primary.packages).length; + if (primaryPkgCount === 0 && backupExists) { + const backup = readSessionFile(backupFile); + if (backup) { + const backupPkgCount = Object.keys(backup.packages).length; + if (backupPkgCount > 0) { + logger.warn(`Session-Datei ist leer (0 Pakete), aber Backup hat ${backupPkgCount} Pakete — verwende Backup`); + try { + const payload = JSON.stringify({ ...backup, updatedAt: Date.now() }, safeJsonReplacer); + fs.writeFileSync(syncTempFile, payload, "utf8"); + syncRenameWithExdevFallback(syncTempFile, paths.sessionFile); + } catch { + } + return { session: backup, status: "recovered-backup" }; + } + } + } + return { session: primary, status: "ok" }; + } + + const backup = backupExists ? readSessionFile(backupFile) : null; + if (backup) { + logger.warn("Session defekt, Backup-Datei wird verwendet"); + try { + const payload = JSON.stringify({ ...backup, updatedAt: Date.now() }, safeJsonReplacer); + fs.writeFileSync(syncTempFile, payload, "utf8"); + syncRenameWithExdevFallback(syncTempFile, paths.sessionFile); + } catch { + } + return { session: backup, status: "recovered-backup" }; + } + + for (const kind of ["sync", "async"] as const) { + const tmpPath = sessionTempPath(paths.sessionFile, kind); + if (fs.existsSync(tmpPath)) { + const tmpSession = readSessionFile(tmpPath); + if (tmpSession && Object.keys(tmpSession.packages).length > 0) { + logger.warn(`Session aus temporaerer Datei wiederhergestellt: ${tmpPath} (${Object.keys(tmpSession.packages).length} Pakete)`); + try { + const payload = JSON.stringify({ ...tmpSession, updatedAt: Date.now() }, safeJsonReplacer); + fs.writeFileSync(paths.sessionFile, payload, "utf8"); + } catch { + } + return { session: tmpSession, status: "recovered-temp" }; + } + } + } + + if (primaryExists || backupExists || anyTempExists) { + logger.error("Session konnte nicht geladen werden (Primary, Backup und Temp-Dateien fehlgeschlagen) — Schutz gegen leeres Ueberschreiben aktiv"); + return { session: emptySession(), status: "empty-unreadable" }; + } + + return { session: emptySession(), status: "empty-fresh" }; +} + +export function loadSession(paths: StoragePaths): SessionState { + return loadSessionWithStatus(paths).session; +} + +export function saveSession(paths: StoragePaths, session: SessionState): void { + syncSaveGeneration += 1; + ensureBaseDir(paths.baseDir); + if (fs.existsSync(paths.sessionFile)) { + try { + fs.copyFileSync(paths.sessionFile, sessionBackupPath(paths.sessionFile)); + } catch { + } + } + const payload = JSON.stringify({ ...session, updatedAt: Date.now() }, safeJsonReplacer); + const tempPath = sessionTempPath(paths.sessionFile, "sync"); + try { + const fd = fs.openSync(tempPath, "w"); + try { + fs.writeSync(fd, payload); + fs.fsyncSync(fd); + } finally { + fs.closeSync(fd); + } + syncRenameWithExdevFallback(tempPath, paths.sessionFile); + } catch (error) { + try { fs.rmSync(tempPath, { force: true }); } catch { } + throw error; + } +} + +let asyncSaveRunning = false; +let asyncSaveQueued: { paths: StoragePaths; payload: string; generation: number } | null = null; +let syncSaveGeneration = 0; + +async function writeSessionPayload(paths: StoragePaths, payload: string, generation: number): Promise<void> { + await fs.promises.mkdir(paths.baseDir, { recursive: true }); + await fsp.copyFile(paths.sessionFile, sessionBackupPath(paths.sessionFile)).catch(() => {}); + const tempPath = sessionTempPath(paths.sessionFile, "async"); + const handle = await fsp.open(tempPath, "w"); + try { + await handle.writeFile(payload, "utf8"); + await handle.sync(); + } finally { + await handle.close(); + } + if (generation < syncSaveGeneration) { + await fsp.rm(tempPath, { force: true }).catch(() => {}); + return; + } + try { + await fsp.rename(tempPath, paths.sessionFile); + } catch (renameError: unknown) { + if (renameError && typeof renameError === "object" && "code" in renameError && (renameError as NodeJS.ErrnoException).code === "EXDEV") { + if (generation < syncSaveGeneration) { + await fsp.rm(tempPath, { force: true }).catch(() => {}); + return; + } + await fsp.copyFile(tempPath, paths.sessionFile); + await fsp.rm(tempPath, { force: true }).catch(() => {}); + } else { + await fsp.rm(tempPath, { force: true }).catch(() => {}); + throw renameError; + } + } +} + +async function saveSessionPayloadAsync(paths: StoragePaths, payload: string, generation: number): Promise<void> { + if (asyncSaveRunning) { + asyncSaveQueued = { paths, payload, generation }; + return; + } + asyncSaveRunning = true; + try { + await writeSessionPayload(paths, payload, generation); + } catch (error) { + logger.error(`Async Session-Save fehlgeschlagen: ${String(error)}`); + } finally { + asyncSaveRunning = false; + if (asyncSaveQueued) { + const queued = asyncSaveQueued; + asyncSaveQueued = null; + void saveSessionPayloadAsync(queued.paths, queued.payload, queued.generation); + } + } +} + +export function cancelPendingAsyncSaves(): void { + asyncSaveQueued = null; + asyncSettingsSaveQueued = null; + syncSaveGeneration += 1; + syncSettingsSaveGeneration += 1; +} + +export async function saveSessionAsync(paths: StoragePaths, session: SessionState): Promise<void> { + const generation = syncSaveGeneration; + const payload = JSON.stringify({ ...session, updatedAt: Date.now() }, safeJsonReplacer); + await saveSessionPayloadAsync(paths, payload, generation); +} + +const MAX_HISTORY_ENTRIES = 500; +const HISTORY_HARD_CAP = 100000; + +export interface HistoryLimits { + maxEntries: number; + maxAgeDays: number; +} + +function pruneHistoryEntries(entries: HistoryEntry[], limits?: HistoryLimits, now = Date.now()): HistoryEntry[] { + const maxEntries = limits && limits.maxEntries > 0 ? Math.min(limits.maxEntries, HISTORY_HARD_CAP) : MAX_HISTORY_ENTRIES; + const maxAgeDays = limits && limits.maxAgeDays > 0 ? limits.maxAgeDays : 0; + let result = entries; + if (maxAgeDays > 0) { + const cutoff = now - maxAgeDays * 24 * 60 * 60 * 1000; + result = result.filter((entry) => entry.completedAt >= cutoff); + } + return result.length > maxEntries ? result.slice(0, maxEntries) : result; +} + +export function normalizeHistoryEntry(raw: unknown, index: number): HistoryEntry | null { + const entry = asRecord(raw); + if (!entry) return null; + + const id = asText(entry.id) || `hist-${Date.now().toString(36)}-${index}`; + const name = asText(entry.name) || "Unbenannt"; + const providerRaw = asText(entry.provider); + + return { + id, + name, + totalBytes: clampNumber(entry.totalBytes, 0, 0, Number.MAX_SAFE_INTEGER), + downloadedBytes: clampNumber(entry.downloadedBytes, 0, 0, Number.MAX_SAFE_INTEGER), + fileCount: clampNumber(entry.fileCount, 0, 0, 100000), + provider: VALID_ITEM_PROVIDERS.has(providerRaw as DebridProvider) ? providerRaw as DebridProvider : null, + completedAt: clampNumber(entry.completedAt, Date.now(), 0, Number.MAX_SAFE_INTEGER), + durationSeconds: clampNumber(entry.durationSeconds, 0, 0, Number.MAX_SAFE_INTEGER), + status: entry.status === "deleted" ? "deleted" : "completed", + outputDir: asText(entry.outputDir), + urls: Array.isArray(entry.urls) ? (entry.urls as unknown[]).map(String).filter(Boolean) : undefined + }; +} + +export function loadHistory(paths: StoragePaths, limits?: HistoryLimits): HistoryEntry[] { + ensureBaseDir(paths.baseDir); + if (!fs.existsSync(paths.historyFile)) { + return []; + } + + try { + const raw = JSON.parse(fs.readFileSync(paths.historyFile, "utf8")) as unknown; + if (!Array.isArray(raw)) return []; + + const entries: HistoryEntry[] = []; + for (let i = 0; i < raw.length && entries.length < HISTORY_HARD_CAP; i++) { + const normalized = normalizeHistoryEntry(raw[i], i); + if (normalized) entries.push(normalized); + } + return pruneHistoryEntries(entries, limits); + } catch { + return []; + } +} + +export function saveHistory(paths: StoragePaths, entries: HistoryEntry[], limits?: HistoryLimits): void { + ensureBaseDir(paths.baseDir); + const trimmed = pruneHistoryEntries(entries, limits); + const payload = JSON.stringify(trimmed, safeJsonReplacer, 2); + const tempPath = `${paths.historyFile}.tmp`; + try { + fs.writeFileSync(tempPath, payload, "utf8"); + syncRenameWithExdevFallback(tempPath, paths.historyFile); + } catch (error) { + try { fs.rmSync(tempPath, { force: true }); } catch { } + throw error; + } +} + +export function addHistoryEntry(paths: StoragePaths, entry: HistoryEntry, limits?: HistoryLimits): HistoryEntry[] { + const existing = loadHistory(paths, limits); + const updated = pruneHistoryEntries([entry, ...existing], limits); + saveHistory(paths, updated, limits); + return updated; +} + +export function loadHistoryForRetention(paths: StoragePaths, retentionMode: HistoryRetentionMode, limits?: HistoryLimits): HistoryEntry[] { + return retentionMode === "never" ? [] : loadHistory(paths, limits); +} + +export function addHistoryEntryForRetention(paths: StoragePaths, retentionMode: HistoryRetentionMode, entry: HistoryEntry, limits?: HistoryLimits): HistoryEntry[] { + if (retentionMode === "never") { + return []; + } + return addHistoryEntry(paths, entry, limits); +} + +export function resetHistoryForRetention(paths: StoragePaths, retentionMode: HistoryRetentionMode): void { + if (retentionMode === "permanent") { + return; + } + clearHistory(paths); +} + +export function removeHistoryEntry(paths: StoragePaths, entryId: string): HistoryEntry[] { + const existing = loadHistory(paths); + const updated = existing.filter(e => e.id !== entryId); + saveHistory(paths, updated); + return updated; +} + +export function clearHistory(paths: StoragePaths): void { + ensureBaseDir(paths.baseDir); + if (fs.existsSync(paths.historyFile)) { + try { + fs.unlinkSync(paths.historyFile); + } catch { + } + } +} diff --git a/src/main/support-bundle.ts b/src/main/support-bundle.ts new file mode 100644 index 0000000..e1ae524 --- /dev/null +++ b/src/main/support-bundle.ts @@ -0,0 +1,224 @@ +import { promises as fsp } from "node:fs"; +import path from "node:path"; +import AdmZip from "adm-zip"; +import { APP_VERSION } from "./constants"; +import { getAccountRotationLogPath } from "./account-rotation-log"; +import { getConversionLogPath } from "./conversion-trace"; +import { getAuditLogPath } from "./audit-log"; +import { getDebugSetupCheck } from "./debug-setup"; +import { getLogFilePath } from "./logger"; +import { getRecentErrors } from "./error-ring"; +import { getPackageLogPath } from "./package-log"; +import { getRenameLogPath } from "./rename-log"; +import { getDesktopRenameLogPath } from "./desktop-rename-log"; +import { getSessionLogPath } from "./session-log"; +import { createStoragePaths, loadHistory, loadSettings } from "./storage"; +import { buildAccountSummary, buildRedactedSettingsPayload, buildStatsPayload, summarizeHistoryEntry } from "./support-data"; +import { getTraceConfig, getTraceConfigPath, getTraceLogPath } from "./trace-log"; +import { getCachedWindowsHostDiagnostics, getWindowsHostDiagnostics } from "./windows-host-diagnostics"; +import type { DownloadManager } from "./download-manager"; + +const SUPPORT_MANIFEST_FILE = "debug_support_manifest.json"; + +async function safeReadJson(filePath: string): Promise<unknown> { + try { + return JSON.parse(await fsp.readFile(filePath, "utf8")) as unknown; + } catch { + return null; + } +} + +function addJson(zip: AdmZip, zipPath: string, value: unknown): void { + zip.addFile(zipPath, Buffer.from(`${JSON.stringify(value, null, 2)}\n`, "utf8")); +} + +async function addFileIfExists(zip: AdmZip, sourcePath: string | null, zipPath: string): Promise<void> { + if (!sourcePath) { + return; + } + try { + const buffer = await fsp.readFile(sourcePath); + zip.addFile(zipPath, buffer); + } catch { + } +} + +async function addDirectoryIfExists(zip: AdmZip, dirPath: string, zipRoot: string): Promise<void> { + let entries; + try { + entries = await fsp.readdir(dirPath, { withFileTypes: true }); + } catch { + return; + } + for (const entry of entries) { + const fullPath = path.join(dirPath, entry.name); + const zipPath = path.posix.join(zipRoot, entry.name); + if (entry.isDirectory()) { + await addDirectoryIfExists(zip, fullPath, zipPath); + continue; + } + await addFileIfExists(zip, fullPath, zipPath); + } +} + +async function addRecentDirectoryFiles(zip: AdmZip, dirPath: string, zipRoot: string, maxAgeMs: number): Promise<number> { + let entries; + try { + entries = await fsp.readdir(dirPath, { withFileTypes: true }); + } catch { + return 0; + } + const cutoff = Date.now() - maxAgeMs; + let added = 0; + for (const entry of entries) { + if (!entry.isFile()) continue; + const fullPath = path.join(dirPath, entry.name); + try { + if ((await fsp.stat(fullPath)).mtimeMs >= cutoff) { + await addFileIfExists(zip, fullPath, path.posix.join(zipRoot, entry.name)); + added += 1; + } + } catch { } + } + return added; +} + +function formatTimestampForFileName(date: Date): string { + const y = date.getFullYear(); + const mo = String(date.getMonth() + 1).padStart(2, "0"); + const d = String(date.getDate()).padStart(2, "0"); + const h = String(date.getHours()).padStart(2, "0"); + const mi = String(date.getMinutes()).padStart(2, "0"); + const s = String(date.getSeconds()).padStart(2, "0"); + return `${y}-${mo}-${d}_${h}-${mi}-${s}`; +} + +export function getSupportBundleDefaultFileName(): string { + return `rd-support-bundle-${formatTimestampForFileName(new Date())}.zip`; +} + +type HostDiagnosticsMode = "full" | "cached" | "none"; + +interface BuildSupportBundleOptions { + hostDiagnosticsMode?: HostDiagnosticsMode; +} + +function createDeferredHostDiagnostics(reason: string): unknown { + return { + collectedAt: new Date().toISOString(), + supported: process.platform === "win32", + platform: process.platform, + crashControl: null, + recentKernelPower: [], + recentWerKernel: [], + recentKernelDump: [], + recentAppCrashes: [], + recentMinidumps: [], + assessmentHints: [ + reason + ], + errors: [] + }; +} + +function resolveHostDiagnostics(mode: HostDiagnosticsMode): unknown { + if (mode === "none") { + return createDeferredHostDiagnostics("Host-Diagnose wurde fuer diesen Bundle-Export deaktiviert."); + } + if (mode === "cached") { + const cached = getCachedWindowsHostDiagnostics(); + if (cached) { + return cached; + } + return createDeferredHostDiagnostics("Host-Diagnose wurde uebersprungen, um den Export nicht zu blockieren. Fuer eine Voll-Diagnose /host/diagnostics nutzen."); + } + return getWindowsHostDiagnostics(); +} + +export async function buildSupportBundle(manager: DownloadManager, baseDir: string, options: BuildSupportBundleOptions = {}): Promise<Buffer> { + const zip = new AdmZip(); + const hostDiagnosticsMode = options.hostDiagnosticsMode || "full"; + const storagePaths = createStoragePaths(baseDir); + const settings = loadSettings(storagePaths); + const history = loadHistory(storagePaths); + const snapshot = manager.getSnapshot(); + const packageIds = Object.keys(snapshot.session.packages); + const itemIds = Object.keys(snapshot.session.items); + const debugSetup = getDebugSetupCheck(baseDir); + + addJson(zip, "overview/meta.json", { + appVersion: APP_VERSION, + generatedAt: new Date().toISOString(), + runtimeBaseDir: baseDir, + packageCount: packageIds.length, + itemCount: itemIds.length + }); + addJson(zip, "overview/status.json", snapshot.session); + addJson(zip, "overview/settings.json", buildRedactedSettingsPayload(settings)); + addJson(zip, "overview/accounts.json", buildAccountSummary(settings)); + addJson(zip, "overview/stats.json", { + ...buildStatsPayload(snapshot), + allTime: { + totalDownloadedAllTime: settings.totalDownloadedAllTime, + totalCompletedFilesAllTime: settings.totalCompletedFilesAllTime, + totalRuntimeAllTimeMs: settings.totalRuntimeAllTimeMs + } + }); + addJson(zip, "overview/debug-setup.json", debugSetup); + addJson(zip, "overview/self-check.json", debugSetup); + addJson(zip, "overview/history.json", { + total: history.length, + entries: history.map((entry) => summarizeHistoryEntry(entry)) + }); + addJson(zip, "overview/packages.json", { + count: packageIds.length, + packages: packageIds.map((packageId) => snapshot.session.packages[packageId]).filter(Boolean) + }); + addJson(zip, "overview/items.json", { + count: itemIds.length, + items: itemIds.map((itemId) => snapshot.session.items[itemId]).filter(Boolean) + }); + addJson(zip, "overview/host-diagnostics.json", resolveHostDiagnostics(hostDiagnosticsMode)); + addJson(zip, "overview/trace-config.json", getTraceConfig()); + const recentErrors = getRecentErrors(); + addJson(zip, "overview/recent-errors.json", { count: recentErrors.length, entries: recentErrors }); + + await addFileIfExists(zip, path.join(baseDir, SUPPORT_MANIFEST_FILE), `runtime/${SUPPORT_MANIFEST_FILE}`); + await addFileIfExists(zip, path.join(baseDir, "debug_host.txt"), "runtime/debug_host.txt"); + await addFileIfExists(zip, path.join(baseDir, "debug_port.txt"), "runtime/debug_port.txt"); + await addFileIfExists(zip, getTraceConfigPath(), "runtime/trace_config.json"); + + await addFileIfExists(zip, getLogFilePath(), "logs/rd_downloader.log"); + await addFileIfExists(zip, `${getLogFilePath()}.old`, "logs/rd_downloader.log.old"); + await addFileIfExists(zip, getAuditLogPath(), "logs/audit.log"); + await addFileIfExists(zip, getAuditLogPath() ? `${getAuditLogPath()}.old` : null, "logs/audit.log.old"); + await addFileIfExists(zip, getRenameLogPath(), "logs/rename.log"); + await addFileIfExists(zip, getRenameLogPath() ? `${getRenameLogPath()}.old` : null, "logs/rename.log.old"); + await addFileIfExists(zip, getDesktopRenameLogPath(), "logs/rename-session-desktop.txt"); + await addFileIfExists(zip, getSessionLogPath(), "logs/session.log"); + await addFileIfExists(zip, getTraceLogPath(), "logs/trace.log"); + await addFileIfExists(zip, getTraceLogPath() ? `${getTraceLogPath()}.old` : null, "logs/trace.log.old"); + await addFileIfExists(zip, getAccountRotationLogPath(), "logs/account-rotation.log"); + await addFileIfExists(zip, getAccountRotationLogPath() ? `${getAccountRotationLogPath()}.old` : null, "logs/account-rotation.log.old"); + await addFileIfExists(zip, getConversionLogPath(), "logs/conversion.log"); + await addFileIfExists(zip, getConversionLogPath() ? `${getConversionLogPath()}.old` : null, "logs/conversion.log.old"); + + const SUPPORT_BUNDLE_LOG_WINDOW_MS = 8 * 60 * 60 * 1000; + await addDirectoryIfExists(zip, path.join(baseDir, "session-logs"), "logs/session-logs"); + await addRecentDirectoryFiles(zip, path.join(baseDir, "package-logs"), "logs/package-logs", SUPPORT_BUNDLE_LOG_WINDOW_MS); + await addRecentDirectoryFiles(zip, path.join(baseDir, "item-logs"), "logs/item-logs", SUPPORT_BUNDLE_LOG_WINDOW_MS); + + for (const packageId of packageIds) { + await addFileIfExists(zip, manager.getPackageLogPath(packageId) || getPackageLogPath(packageId), `logs/live/package-${packageId}.txt`); + } + for (const itemId of itemIds) { + await addFileIfExists(zip, manager.getItemLogPath(itemId), `logs/live/item-${itemId}.txt`); + } + + const supportManifest = await safeReadJson(path.join(baseDir, SUPPORT_MANIFEST_FILE)); + if (supportManifest) { + addJson(zip, "overview/support-manifest.json", supportManifest); + } + + return zip.toBuffer(); +} diff --git a/src/main/support-data.ts b/src/main/support-data.ts new file mode 100644 index 0000000..007421f --- /dev/null +++ b/src/main/support-data.ts @@ -0,0 +1,188 @@ +import { getDebridLinkApiKeyIds } from "../shared/debrid-link-keys"; +import { isNotifyUrlValid } from "./notify"; +import type { AppSettings, HistoryEntry, UiSnapshot } from "../shared/types"; + +function hasText(value: unknown): boolean { + return String(value || "").trim().length > 0; +} + +export function buildAccountSummary(settings: AppSettings): Record<string, unknown> { + const debridLinkKeyIds = getDebridLinkApiKeyIds(settings.debridLinkApiKeys); + const disabledDebridLinkIds = new Set(settings.debridLinkDisabledKeyIds || []); + + return { + realDebrid: { + configured: hasText(settings.token) || settings.realDebridUseWebLogin, + tokenConfigured: hasText(settings.token), + webLoginEnabled: settings.realDebridUseWebLogin, + rememberToken: settings.rememberToken + }, + megaDebrid: { + configured: (hasText(settings.megaLogin) && hasText(settings.megaPassword)) + || settings.megaDebridApiEnabled + || settings.megaDebridWebEnabled, + loginConfigured: hasText(settings.megaLogin) && hasText(settings.megaPassword), + apiEnabled: settings.megaDebridApiEnabled, + webEnabled: settings.megaDebridWebEnabled, + preferApi: settings.megaDebridPreferApi + }, + bestDebrid: { + configured: hasText(settings.bestToken) || settings.bestDebridUseWebLogin, + tokenConfigured: hasText(settings.bestToken), + webLoginEnabled: settings.bestDebridUseWebLogin + }, + allDebrid: { + configured: hasText(settings.allDebridToken) || settings.allDebridUseWebLogin, + tokenConfigured: hasText(settings.allDebridToken), + webLoginEnabled: settings.allDebridUseWebLogin + }, + ddownload: { + configured: hasText(settings.ddownloadLogin) && hasText(settings.ddownloadPassword) + }, + oneFichier: { + configured: hasText(settings.oneFichierApiKey) + }, + debridLink: { + configured: debridLinkKeyIds.length > 0, + keyCount: debridLinkKeyIds.length, + enabledKeyCount: debridLinkKeyIds.filter((id) => !disabledDebridLinkIds.has(id)).length, + disabledKeyCount: debridLinkKeyIds.filter((id) => disabledDebridLinkIds.has(id)).length + }, + linkSnappy: { + configured: hasText(settings.linkSnappyLogin) && hasText(settings.linkSnappyPassword) + }, + disabledProviders: [...(settings.disabledProviders || [])] + }; +} + +export function diffAccountSummary(previous: AppSettings, next: AppSettings): Record<string, unknown> { + const before = buildAccountSummary(previous); + const after = buildAccountSummary(next); + const changes: Record<string, unknown> = {}; + for (const key of Object.keys(after)) { + const beforeJson = JSON.stringify(before[key]); + const afterJson = JSON.stringify(after[key]); + if (beforeJson !== afterJson) { + changes[key] = after[key]; + } + } + return changes; +} + +export function buildRedactedSettingsPayload(settings: AppSettings): Record<string, unknown> { + return { + paths: { + outputDir: settings.outputDir, + extractDir: settings.extractDir, + mkvLibraryDir: settings.mkvLibraryDir + }, + providers: { + providerOrder: settings.providerOrder, + providerPrimary: settings.providerPrimary, + providerSecondary: settings.providerSecondary, + providerTertiary: settings.providerTertiary, + autoProviderFallback: settings.autoProviderFallback, + disabledProviders: settings.disabledProviders, + hosterRouting: settings.hosterRouting + }, + extraction: { + autoExtract: settings.autoExtract, + autoExtractWhenStopped: settings.autoExtractWhenStopped, + hybridExtract: settings.hybridExtract, + createExtractSubfolder: settings.createExtractSubfolder, + cleanupMode: settings.cleanupMode, + extractConflictMode: settings.extractConflictMode, + removeLinkFilesAfterExtract: settings.removeLinkFilesAfterExtract, + removeSamplesAfterExtract: settings.removeSamplesAfterExtract, + enableIntegrityCheck: settings.enableIntegrityCheck, + archivePasswordCount: String(settings.archivePasswordList || "") + .split(/\r?\n/) + .map((line) => line.trim()) + .filter(Boolean) + .length, + extractCpuPriority: settings.extractCpuPriority, + maxParallelExtract: settings.maxParallelExtract + }, + downloads: { + maxParallel: settings.maxParallel, + retryLimit: settings.retryLimit, + autoResumeOnStart: settings.autoResumeOnStart, + autoReconnect: settings.autoReconnect, + reconnectWaitSeconds: settings.reconnectWaitSeconds, + autoSkipExtracted: settings.autoSkipExtracted, + completedCleanupPolicy: settings.completedCleanupPolicy + }, + ui: { + packageName: settings.packageName, + theme: settings.theme, + collapseNewPackages: settings.collapseNewPackages, + hideExtractedItems: settings.hideExtractedItems, + confirmDeleteSelection: settings.confirmDeleteSelection, + clipboardWatch: settings.clipboardWatch, + minimizeToTray: settings.minimizeToTray, + columnOrder: settings.columnOrder + }, + bandwidth: { + speedLimitEnabled: settings.speedLimitEnabled, + speedLimitKbps: settings.speedLimitKbps, + speedLimitMode: settings.speedLimitMode, + bandwidthSchedules: settings.bandwidthSchedules + }, + updates: { + updateRepo: settings.updateRepo, + autoUpdateCheck: settings.autoUpdateCheck + }, + notifications: { + notifyUrlConfigured: Boolean(String(settings.notifyUrl || "").trim()), + notifyUrlLooksValid: isNotifyUrlValid(settings.notifyUrl), + notifyMentionConfigured: Boolean(String(settings.notifyMention || "").trim()), + notifyOnPackageCompleted: settings.notifyOnPackageCompleted, + notifyOnPackageFailed: settings.notifyOnPackageFailed, + notifyOnRunFinished: settings.notifyOnRunFinished + }, + statistics: { + totalDownloadedAllTime: settings.totalDownloadedAllTime, + totalCompletedFilesAllTime: settings.totalCompletedFilesAllTime, + totalRuntimeAllTimeMs: settings.totalRuntimeAllTimeMs, + providerDailyLimitBytes: settings.providerDailyLimitBytes, + providerDailyUsageBytes: settings.providerDailyUsageBytes, + providerTotalUsageBytes: settings.providerTotalUsageBytes, + debridLinkApiKeyDailyLimitBytes: settings.debridLinkApiKeyDailyLimitBytes, + debridLinkApiKeyDailyUsageBytes: settings.debridLinkApiKeyDailyUsageBytes, + debridLinkApiKeyTotalUsageBytes: settings.debridLinkApiKeyTotalUsageBytes, + providerDailyUsageDay: settings.providerDailyUsageDay + }, + accounts: buildAccountSummary(settings) + }; +} + +export function buildStatsPayload(snapshot: UiSnapshot): Record<string, unknown> { + return { + session: snapshot.stats, + totals: { + totalPackages: Object.keys(snapshot.session.packages).length, + totalItems: Object.keys(snapshot.session.items).length, + speedText: snapshot.speedText, + etaText: snapshot.etaText, + canStart: snapshot.canStart, + canStop: snapshot.canStop, + canPause: snapshot.canPause + } + }; +} + +export function summarizeHistoryEntry(entry: HistoryEntry): Record<string, unknown> { + return { + id: entry.id, + name: entry.name, + status: entry.status, + provider: entry.provider, + fileCount: entry.fileCount, + totalBytes: entry.totalBytes, + downloadedBytes: entry.downloadedBytes, + durationSeconds: entry.durationSeconds, + completedAt: entry.completedAt, + outputDir: entry.outputDir, + urlCount: Array.isArray(entry.urls) ? entry.urls.length : 0 + }; +} diff --git a/src/main/trace-log.ts b/src/main/trace-log.ts new file mode 100644 index 0000000..cc44509 --- /dev/null +++ b/src/main/trace-log.ts @@ -0,0 +1,312 @@ +import fs from "node:fs"; +import { logTimestamp } from "./log-timestamp"; +import path from "node:path"; +import { addLogListener, removeLogListener } from "./logger"; +import type { SupportTraceConfig } from "../shared/types"; + +type TraceLevel = "INFO" | "WARN" | "ERROR"; + +const TRACE_LOG_FLUSH_INTERVAL_MS = 200; +const TRACE_CONFIG_FILE = "trace_config.json"; +const TRACE_LOG_MAX_FILE_BYTES = Number(process.env.RD_TRACE_LOG_MAX_BYTES || 10 * 1024 * 1024); +const TRACE_LOG_RETENTION_DAYS = Number(process.env.RD_TRACE_LOG_RETENTION_DAYS || 30); +const TRACE_DEFAULT_AUTO_DISABLE_MS = Number(process.env.RD_TRACE_AUTO_DISABLE_MS || 2 * 60 * 60 * 1000); + +const DEFAULT_TRACE_CONFIG: SupportTraceConfig = { + enabled: false, + includeMainLog: true, + includeAudit: true, + logDebugRequests: true, + autoDisableAt: null, + updatedAt: new Date(0).toISOString() +}; + +let traceLogPath: string | null = null; +let traceConfigPath: string | null = null; +let traceConfig: SupportTraceConfig = { ...DEFAULT_TRACE_CONFIG }; +let pendingLines: string[] = []; +let flushTimer: NodeJS.Timeout | null = null; +let autoDisableTimer: NodeJS.Timeout | null = null; + +function sanitizeFieldValue(value: unknown): string { + if (value === undefined || value === null) { + return ""; + } + if (typeof value === "string") { + return value.replace(/\r?\n/g, "\\n"); + } + if (typeof value === "number" || typeof value === "boolean") { + return String(value); + } + try { + return JSON.stringify(value).replace(/\r?\n/g, "\\n"); + } catch { + return String(value); + } +} + +function formatFields(fields?: Record<string, unknown>): string { + if (!fields) { + return ""; + } + const parts = Object.entries(fields) + .filter(([, value]) => value !== undefined && value !== null && sanitizeFieldValue(value) !== "") + .map(([key, value]) => `${key}=${sanitizeFieldValue(value)}`); + return parts.length > 0 ? ` | ${parts.join(" | ")}` : ""; +} + +function flushPending(): void { + if (!traceLogPath || pendingLines.length === 0) { + return; + } + const chunk = pendingLines.join(""); + pendingLines = []; + try { + fs.appendFileSync(traceLogPath, chunk, "utf8"); + } catch { + } +} + +function rotateIfNeeded(filePath: string): void { + try { + const stat = fs.statSync(filePath); + if (stat.size < TRACE_LOG_MAX_FILE_BYTES) { + return; + } + const backup = `${filePath}.old`; + try { + fs.rmSync(backup, { force: true }); + } catch { + } + fs.renameSync(filePath, backup); + } catch { + } +} + +function cleanupOldBackup(filePath: string): void { + const backup = `${filePath}.old`; + try { + const stat = fs.statSync(backup); + const cutoff = Date.now() - TRACE_LOG_RETENTION_DAYS * 24 * 60 * 60 * 1000; + if (stat.mtimeMs < cutoff) { + fs.rmSync(backup, { force: true }); + } + } catch { + } +} + +function scheduleFlush(): void { + if (flushTimer) { + return; + } + flushTimer = setTimeout(() => { + flushTimer = null; + flushPending(); + }, TRACE_LOG_FLUSH_INTERVAL_MS); +} + +function appendTraceLine(line: string): void { + if (!traceLogPath) { + return; + } + rotateIfNeeded(traceLogPath); + if (!fs.existsSync(traceLogPath)) { + try { + fs.writeFileSync(traceLogPath, "", "utf8"); + } catch { + return; + } + } + pendingLines.push(line); + scheduleFlush(); +} + +function normalizeTraceConfig(raw: unknown): SupportTraceConfig { + if (!raw || typeof raw !== "object" || Array.isArray(raw)) { + return { ...DEFAULT_TRACE_CONFIG }; + } + const value = raw as Partial<SupportTraceConfig>; + return { + enabled: Boolean(value.enabled), + includeMainLog: value.includeMainLog === undefined ? DEFAULT_TRACE_CONFIG.includeMainLog : Boolean(value.includeMainLog), + includeAudit: value.includeAudit === undefined ? DEFAULT_TRACE_CONFIG.includeAudit : Boolean(value.includeAudit), + logDebugRequests: value.logDebugRequests === undefined ? DEFAULT_TRACE_CONFIG.logDebugRequests : Boolean(value.logDebugRequests), + autoDisableAt: typeof value.autoDisableAt === "string" && value.autoDisableAt.trim() + ? value.autoDisableAt + : null, + updatedAt: typeof value.updatedAt === "string" && value.updatedAt.trim() + ? value.updatedAt + : DEFAULT_TRACE_CONFIG.updatedAt + }; +} + +function loadTraceConfig(): SupportTraceConfig { + if (!traceConfigPath) { + return { ...DEFAULT_TRACE_CONFIG }; + } + try { + const parsed = JSON.parse(fs.readFileSync(traceConfigPath, "utf8")) as unknown; + return normalizeTraceConfig(parsed); + } catch { + return { ...DEFAULT_TRACE_CONFIG }; + } +} + +function persistTraceConfig(): void { + if (!traceConfigPath) { + return; + } + try { + fs.writeFileSync(traceConfigPath, `${JSON.stringify(traceConfig, null, 2)}\n`, "utf8"); + } catch { + } +} + +const mainLogListener = (line: string): void => { + if (!traceConfig.enabled || !traceConfig.includeMainLog) { + return; + } + appendTraceLine(line); +}; + +function clearAutoDisableTimer(): void { + if (autoDisableTimer) { + clearTimeout(autoDisableTimer); + autoDisableTimer = null; + } +} + +function disableTraceDueToExpiry(): void { + clearAutoDisableTimer(); + if (!traceConfig.enabled) { + return; + } + traceConfig = normalizeTraceConfig({ + ...traceConfig, + enabled: false, + autoDisableAt: null, + updatedAt: logTimestamp() + }); + persistTraceConfig(); + appendTraceLine(`${logTimestamp()} [INFO] [trace] Support-Trace automatisch deaktiviert | reason=expired\n`); +} + +function scheduleAutoDisable(): void { + clearAutoDisableTimer(); + if (!traceConfig.enabled || !traceConfig.autoDisableAt) { + return; + } + const until = Date.parse(traceConfig.autoDisableAt); + if (!Number.isFinite(until)) { + return; + } + const remainingMs = until - Date.now(); + if (remainingMs <= 0) { + disableTraceDueToExpiry(); + return; + } + autoDisableTimer = setTimeout(() => { + autoDisableTimer = null; + disableTraceDueToExpiry(); + }, Math.min(remainingMs, 2_147_483_647)); +} + +export function initTraceLog(baseDir: string): void { + traceLogPath = path.join(baseDir, "trace.log"); + traceConfigPath = path.join(baseDir, TRACE_CONFIG_FILE); + try { + fs.mkdirSync(baseDir, { recursive: true }); + cleanupOldBackup(traceLogPath); + if (!fs.existsSync(traceLogPath)) { + fs.writeFileSync(traceLogPath, "", "utf8"); + } + rotateIfNeeded(traceLogPath); + if (!fs.existsSync(traceLogPath)) { + fs.writeFileSync(traceLogPath, "", "utf8"); + } + traceConfig = loadTraceConfig(); + persistTraceConfig(); + fs.appendFileSync(traceLogPath, `=== Trace-Log Start: ${logTimestamp()} ===\n`, "utf8"); + } catch { + traceLogPath = null; + traceConfigPath = null; + traceConfig = { ...DEFAULT_TRACE_CONFIG }; + return; + } + addLogListener(mainLogListener); + scheduleAutoDisable(); +} + +export function getTraceLogPath(): string | null { + if (!traceLogPath) { + return null; + } + return fs.existsSync(traceLogPath) ? traceLogPath : null; +} + +export function getTraceConfigPath(): string | null { + if (!traceConfigPath) { + return null; + } + return fs.existsSync(traceConfigPath) ? traceConfigPath : null; +} + +export function getTraceConfig(): SupportTraceConfig { + return { ...traceConfig }; +} + +export function updateTraceConfig(patch: Partial<SupportTraceConfig>): SupportTraceConfig { + traceConfig = normalizeTraceConfig({ + ...traceConfig, + ...patch, + updatedAt: logTimestamp() + }); + persistTraceConfig(); + scheduleAutoDisable(); + appendTraceLine(`${logTimestamp()} [INFO] [trace] Konfiguration aktualisiert${formatFields(traceConfig as unknown as Record<string, unknown>)}\n`); + return getTraceConfig(); +} + +export function setTraceEnabled(enabled: boolean, note = "", durationMs: number = TRACE_DEFAULT_AUTO_DISABLE_MS): SupportTraceConfig { + const autoDisableAt = enabled && durationMs > 0 + ? new Date(Date.now() + durationMs).toISOString() + : null; + const next = updateTraceConfig({ enabled, autoDisableAt }); + appendTraceLine(`${logTimestamp()} [INFO] [trace] Support-Trace ${enabled ? "aktiviert" : "deaktiviert"}${formatFields({ note, autoDisableAt })}\n`); + return next; +} + +export function logTraceEvent( + level: TraceLevel, + category: string, + message: string, + fields?: Record<string, unknown> +): void { + if (!traceConfig.enabled) { + return; + } + if (category === "audit" && !traceConfig.includeAudit) { + return; + } + appendTraceLine(`${logTimestamp()} [${level}] [${category}] ${message}${formatFields(fields)}\n`); +} + +export function shutdownTraceLog(): void { + removeLogListener(mainLogListener); + clearAutoDisableTimer(); + if (!traceLogPath) { + return; + } + if (flushTimer) { + clearTimeout(flushTimer); + flushTimer = null; + } + flushPending(); + try { + fs.appendFileSync(traceLogPath, `=== Trace-Log Ende: ${logTimestamp()} ===\n`, "utf8"); + } catch { + } + traceLogPath = null; + traceConfigPath = null; + traceConfig = { ...DEFAULT_TRACE_CONFIG }; +} diff --git a/src/main/update-install-flow.ts b/src/main/update-install-flow.ts new file mode 100644 index 0000000..c0a59c6 --- /dev/null +++ b/src/main/update-install-flow.ts @@ -0,0 +1,34 @@ +export interface InstallResumeManager { + isSessionRunning(): boolean; + stop(options: { parkForRestart: boolean }): void; + persistNowSync(): void; + start(): Promise<void> | void; +} + +export async function runInstallWithResume<T extends { started: boolean }>( + manager: InstallResumeManager, + doInstall: () => Promise<T> +): Promise<T> { + const wasRunning = manager.isSessionRunning(); + if (wasRunning) { + manager.stop({ parkForRestart: true }); + } + manager.persistNowSync(); + + const resumeIfParked = async (): Promise<void> => { + if (wasRunning && !manager.isSessionRunning()) { + await manager.start(); + } + }; + + try { + const result = await doInstall(); + if (!result.started) { + await resumeIfParked(); + } + return result; + } catch (error) { + await resumeIfParked(); + throw error; + } +} diff --git a/src/main/update.ts b/src/main/update.ts new file mode 100644 index 0000000..852c765 --- /dev/null +++ b/src/main/update.ts @@ -0,0 +1,1041 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import crypto from "node:crypto"; +import * as childProcess from "node:child_process"; +import { APP_VERSION, DEFAULT_UPDATE_REPO } from "./constants"; +import { UpdateCheckResult, UpdateInstallProgress, UpdateInstallResult } from "../shared/types"; +import { compactErrorText, humanSize } from "./utils"; +import { logger } from "./logger"; + +const RELEASE_FETCH_TIMEOUT_MS = 12_000; +const CONNECT_TIMEOUT_MS = 30_000; +const DOWNLOAD_BODY_IDLE_TIMEOUT_MS = 45_000; +const RETRIES_PER_CANDIDATE = 3; +const RETRY_DELAY_MS = 1_500; +const MAX_DOWNLOAD_PASSES = 3; +const USER_AGENT = `RD-Node-Downloader/${APP_VERSION}`; + +type UpdateSource = { + name: string; + webBase: string; + apiBase: string; +}; + +type ReleaseAsset = { + name: string; + browser_download_url: string; + digest: string; +}; + +type UpdateProgressCallback = (progress: UpdateInstallProgress) => void; + +type ExpectedDigest = { + algorithm: "sha256" | "sha512"; + digest: string; + encoding: "hex" | "base64"; +}; + +const UPDATE_SOURCES: UpdateSource[] = [ + { name: "github", webBase: "https://github.com", apiBase: "https://api.github.com" } +]; + +const PRIMARY_SOURCE = UPDATE_SOURCES[0]; +const WEB_BASE = PRIMARY_SOURCE.webBase; +const API_BASE = PRIMARY_SOURCE.apiBase; + +let activeAbortController: AbortController | null = null; + +function emitProgress(cb: UpdateProgressCallback | undefined, progress: UpdateInstallProgress): void { + if (!cb) return; + try { + cb(progress); + } catch { + } +} + +export function parseVersionParts(version: string): number[] { + const cleaned = version.replace(/^v/i, "").trim(); + return cleaned.split(".").map((part) => Number(part.replace(/[^0-9].*$/, "") || "0")); +} + +export function isRemoteNewer(currentVersion: string, latestVersion: string): boolean { + const current = parseVersionParts(currentVersion); + const latest = parseVersionParts(latestVersion); + const len = Math.max(current.length, latest.length); + for (let i = 0; i < len; i += 1) { + const a = current[i] ?? 0; + const b = latest[i] ?? 0; + if (b > a) return true; + if (b < a) return false; + } + return false; +} + +function isValidRepoPart(value: string): boolean { + const part = String(value || "").trim(); + if (!part || part === "." || part === ".." || part.includes("..")) return false; + return /^[A-Za-z0-9][A-Za-z0-9._-]{0,99}$/.test(part); +} + +function extractOwnerRepo(input: string): string { + const cleaned = input + .replace(/^https?:\/\/(?:www\.)?(?:codeberg\.org|github\.com)\//i, "") + .replace(/^(?:www\.)?(?:codeberg\.org|github\.com)\//i, "") + .replace(/^git@(?:codeberg\.org|github\.com):/i, "") + .replace(/\.git$/i, "") + .replace(/^\/+|\/+$/g, ""); + const parts = cleaned.split("/").filter(Boolean); + if (parts.length >= 2 && isValidRepoPart(parts[0]) && isValidRepoPart(parts[1])) { + return `${parts[0]}/${parts[1]}`; + } + return ""; +} + +export function normalizeUpdateRepo(repo: string): string { + const raw = String(repo || "").trim(); + if (!raw) return DEFAULT_UPDATE_REPO; + + try { + const url = new URL(raw); + const host = url.hostname.toLowerCase(); + if ( + host === "codeberg.org" + || host === "www.codeberg.org" + || host === "github.com" + || host === "www.github.com" + ) { + const result = extractOwnerRepo(url.pathname); + if (result) return result; + } + } catch { + } + + const result = extractOwnerRepo(raw); + return result || DEFAULT_UPDATE_REPO; +} + +function timeoutController(ms: number): { signal: AbortSignal; clear: () => void } { + const ctrl = new AbortController(); + const timer = setTimeout(() => ctrl.abort(new Error(`timeout:${ms}`)), ms); + return { signal: ctrl.signal, clear: () => clearTimeout(timer) }; +} + +function combineSignals(primary: AbortSignal, secondary?: AbortSignal): AbortSignal { + if (!secondary) return primary; + return AbortSignal.any([primary, secondary]); +} + +async function readJsonBody(response: Response, timeoutMs: number): Promise<Record<string, unknown> | null> { + let timer: NodeJS.Timeout | null = null; + const timeoutPromise = new Promise<never>((_resolve, reject) => { + timer = setTimeout(() => { + void response.body?.cancel().catch(() => undefined); + reject(new Error(`timeout:${timeoutMs}`)); + }, timeoutMs); + }); + + try { + const data = await Promise.race([ + response.json().catch(() => null) as Promise<unknown>, + timeoutPromise, + ]); + if (!data || typeof data !== "object" || Array.isArray(data)) return null; + return data as Record<string, unknown>; + } finally { + if (timer) clearTimeout(timer); + } +} + +async function readTextBody(response: Response, timeoutMs: number): Promise<string> { + let timer: NodeJS.Timeout | null = null; + const timeoutPromise = new Promise<never>((_resolve, reject) => { + timer = setTimeout(() => { + void response.body?.cancel().catch(() => undefined); + reject(new Error(`timeout:${timeoutMs}`)); + }, timeoutMs); + }); + + try { + return String(await Promise.race([response.text(), timeoutPromise]) || ""); + } finally { + if (timer) clearTimeout(timer); + } +} + +function getBodyIdleTimeout(): number { + const env = Number(process.env.RD_UPDATE_BODY_IDLE_TIMEOUT_MS ?? NaN); + if (Number.isFinite(env) && env >= 1000 && env <= 30 * 60 * 1000) return Math.floor(env); + return DOWNLOAD_BODY_IDLE_TIMEOUT_MS; +} + +function normalizeBase64(raw: string): string { + return String(raw || "") + .trim() + .replace(/-/g, "+") + .replace(/_/g, "/") + .replace(/=+$/g, ""); +} + +export function parseExpectedDigest(raw: string): ExpectedDigest | null { + const text = String(raw || "").trim(); + if (!text) return null; + + const pre256hex = text.match(/^sha256:([a-fA-F0-9]{64})$/i); + if (pre256hex) { + return { algorithm: "sha256", digest: pre256hex[1].toLowerCase(), encoding: "hex" }; + } + + const pre256b64 = text.match(/^sha256:([A-Za-z0-9+/_-]{43,44}={0,1})$/i); + if (pre256b64) { + return { algorithm: "sha256", digest: normalizeBase64(pre256b64[1]), encoding: "base64" }; + } + + const pre512hex = text.match(/^sha512:([a-fA-F0-9]{128})$/i); + if (pre512hex) { + return { algorithm: "sha512", digest: pre512hex[1].toLowerCase(), encoding: "hex" }; + } + + const pre512b64 = text.match(/^sha512:([A-Za-z0-9+/_-]{86,88}={0,2})$/i); + if (pre512b64) { + return { algorithm: "sha512", digest: normalizeBase64(pre512b64[1]), encoding: "base64" }; + } + + if (/^[a-fA-F0-9]{64}$/.test(text)) { + return { algorithm: "sha256", digest: text.toLowerCase(), encoding: "hex" }; + } + if (/^[a-fA-F0-9]{128}$/.test(text)) { + return { algorithm: "sha512", digest: text.toLowerCase(), encoding: "hex" }; + } + + const plain512b64 = text.match(/^([A-Za-z0-9+/_-]{86,88}={0,2})$/); + if (plain512b64) { + return { algorithm: "sha512", digest: normalizeBase64(plain512b64[1]), encoding: "base64" }; + } + + const plain256b64 = text.match(/^([A-Za-z0-9+/_-]{43,44}={0,1})$/); + if (plain256b64) { + return { algorithm: "sha256", digest: normalizeBase64(plain256b64[1]), encoding: "base64" }; + } + + logger.warn(`Unrecognized digest format (${text.length} chars): ${text.slice(0, 40)}...`); + return null; +} + +async function hashFile(filePath: string, algorithm: "sha256" | "sha512", encoding: "hex" | "base64"): Promise<string> { + const hash = crypto.createHash(algorithm); + const stream = fs.createReadStream(filePath, { highWaterMark: 1024 * 1024 }); + return new Promise<string>((resolve, reject) => { + stream.on("data", (chunk: string | Buffer) => { + hash.update(typeof chunk === "string" ? Buffer.from(chunk) : chunk); + }); + stream.on("error", reject); + stream.on("end", () => { + const result = hash.digest(encoding); + resolve(encoding === "hex" ? result.toLowerCase() : result); + }); + }); +} + +function normalizeNameForMatch(value: string): string { + const name = String(value || "").trim().split(/[\\/]/g).filter(Boolean).pop() || ""; + return name.toLowerCase().replace(/[^a-z0-9]/g, ""); +} + +function stripYamlQuotes(raw: string): string { + return String(raw || "").trim().replace(/^['"]+|['"]+$/g, "").trim(); +} + +function extractSha512Value(raw: string): string { + const stripped = stripYamlQuotes(raw); + const b64 = stripped.match(/^([A-Za-z0-9+/_-]{86,88}={0,2})$/); + if (b64) return b64[1]; + const hex = stripped.match(/^([a-fA-F0-9]{128})$/); + if (hex) return hex[1]; + return ""; +} + +function parseSha512FromLatestYml(content: string, setupAssetName: string): string { + const lines = String(content || "").split(/\r?\n/); + const target = normalizeNameForMatch(setupAssetName); + + let topLevelPath = ""; + let topLevelSha = ""; + let currentFileUrl = ""; + let firstFileSha = ""; + + for (const rawLine of lines) { + const line = String(rawLine); + + const fileUrlItem = line.match(/^\s*-\s*url\s*:\s*(.+)\s*$/i); + if (fileUrlItem?.[1]) { + currentFileUrl = stripYamlQuotes(fileUrlItem[1]); + continue; + } + + const urlMatch = line.match(/^\s*url\s*:\s*(.+)\s*$/i); + if (urlMatch?.[1]) { + currentFileUrl = stripYamlQuotes(urlMatch[1]); + continue; + } + + const pathMatch = line.match(/^\s*path\s*:\s*(.+)\s*$/i); + if (pathMatch?.[1]) { + topLevelPath = stripYamlQuotes(pathMatch[1]); + continue; + } + + const shaMatch = line.match(/^\s*sha512\s*:\s*(.+)\s*$/i); + if (!shaMatch?.[1]) continue; + + const sha = extractSha512Value(shaMatch[1]); + if (!sha) continue; + + if (currentFileUrl) { + if (!firstFileSha) firstFileSha = sha; + if (target && normalizeNameForMatch(currentFileUrl) === target) { + return sha; + } + currentFileUrl = ""; + continue; + } + + if (!topLevelSha) topLevelSha = sha; + } + + if (target && topLevelPath && topLevelSha) { + if (normalizeNameForMatch(topLevelPath) === target) { + return topLevelSha; + } + } + + return topLevelSha || firstFileSha || ""; +} + +async function verifyBinaryShape(filePath: string): Promise<void> { + const stats = await fs.promises.stat(filePath); + if (!Number.isFinite(stats.size) || stats.size < 128 * 1024) { + throw new Error("Update-Installer ungültig (Datei zu klein)"); + } + + const handle = await fs.promises.open(filePath, "r"); + try { + const header = Buffer.alloc(2); + const result = await handle.read(header, 0, 2, 0); + if (result.bytesRead < 2 || header[0] !== 0x4d || header[1] !== 0x5a) { + throw new Error("Update-Installer ungültig (keine EXE-Datei)"); + } + } finally { + await handle.close(); + } +} + +async function verifyDownloadedInstaller(filePath: string, digestRaw: string): Promise<void> { + await verifyBinaryShape(filePath); + + const expected = parseExpectedDigest(digestRaw); + if (!expected) { + if (String(process.env.RD_ALLOW_UNSIGNED_UPDATE || "").trim() === "1") { + logger.warn("Update-Asset ohne gültigen SHA-Digest (RD_ALLOW_UNSIGNED_UPDATE=1) - nur EXE-Basisprüfung durchgeführt"); + return; + } + throw new Error("Update-Asset ohne gültigen SHA-Digest"); + } + + const actualRaw = await hashFile(filePath, expected.algorithm, expected.encoding); + const actual = expected.encoding === "base64" ? normalizeBase64(actualRaw) : actualRaw; + const expectedNorm = expected.encoding === "base64" ? normalizeBase64(expected.digest) : expected.digest; + + if (actual !== expectedNorm) { + const algo = expected.algorithm.toUpperCase(); + logger.error( + `${algo} mismatch!\n Erwartet: ${expectedNorm.slice(0, 40)}...\n Tatsächlich: ${actual.slice(0, 40)}...` + ); + throw new Error(`Update-Integritätsprüfung fehlgeschlagen (${algo} mismatch)`); + } + + logger.info(`${expected.algorithm.toUpperCase()} Integrität bestätigt`); +} + +async function fetchRelease(repo: string, endpoint: string): Promise<{ + ok: boolean; + status: number; + payload: Record<string, unknown> | null; +}> { + const tc = timeoutController(RELEASE_FETCH_TIMEOUT_MS); + try { + const response = await fetch(`${API_BASE}/repos/${repo}/${endpoint}`, { + headers: { Accept: "application/vnd.github+json", "User-Agent": USER_AGENT }, + signal: tc.signal, + }); + const payload = await readJsonBody(response, RELEASE_FETCH_TIMEOUT_MS); + return { ok: response.ok, status: response.status, payload }; + } finally { + tc.clear(); + } +} + +function readAssets(payload: Record<string, unknown>): ReleaseAsset[] { + const raw = Array.isArray(payload.assets) ? (payload.assets as Array<Record<string, unknown>>) : []; + return raw + .map((a) => ({ + name: String(a.name || ""), + browser_download_url: String(a.browser_download_url || ""), + digest: String(a.digest || "").trim(), + })) + .filter((a) => a.name && a.browser_download_url); +} + +function pickSetupAsset(assets: ReleaseAsset[]): ReleaseAsset | null { + const executables = assets.filter((a) => /\.(exe|msi|msix|msixbundle)$/i.test(a.name)); + if (executables.length === 0) return null; + return ( + executables.find((a) => /setup/i.test(a.name)) + || executables.find((a) => !/portable/i.test(a.name)) + || executables[0] + ); +} + +function pickLatestYml(assets: ReleaseAsset[]): ReleaseAsset | null { + return ( + assets.find((a) => /^latest\.ya?ml$/i.test(a.name)) + || assets.find((a) => /latest/i.test(a.name) && /\.ya?ml$/i.test(a.name)) + || null + ); +} + +function isDraftOrPrerelease(payload: Record<string, unknown>): boolean { + return Boolean(payload.draft) || Boolean(payload.prerelease); +} + +function parseReleasePayload(payload: Record<string, unknown>, fallbackUrl: string): UpdateCheckResult { + const latestTag = String(payload.tag_name || `v${APP_VERSION}`).trim(); + const latestVersion = latestTag.replace(/^v/i, "") || APP_VERSION; + const releaseUrl = String(payload.html_url || fallbackUrl); + const setup = pickSetupAsset(readAssets(payload)); + const body = typeof payload.body === "string" ? payload.body.trim() : ""; + + return { + updateAvailable: isRemoteNewer(APP_VERSION, latestVersion), + currentVersion: APP_VERSION, + latestVersion, + latestTag, + releaseUrl, + setupAssetUrl: setup?.browser_download_url || "", + setupAssetName: setup?.name || "", + setupAssetDigest: setup?.digest || "", + releaseNotes: body || undefined, + }; +} + +function uniqueStrings(values: string[]): string[] { + const seen = new Set<string>(); + const out: string[] = []; + for (const value of values) { + const trimmed = String(value || "").trim(); + if (!trimmed || seen.has(trimmed)) continue; + seen.add(trimmed); + out.push(trimmed); + } + return out; +} + +function extractFileNameFromUrl(url: string): string { + try { + const parsed = new URL(url); + const fileName = path.basename(parsed.pathname || ""); + return fileName ? decodeURIComponent(fileName) : ""; + } catch { + return ""; + } +} + +function deriveTagFromUrl(releaseUrl: string): string { + try { + const match = new URL(releaseUrl).pathname.match(/\/releases\/tag\/([^/?#]+)/i); + return match?.[1] ? decodeURIComponent(match[1]) : ""; + } catch { + return ""; + } +} + +function deriveNameVariants(name: string, url: string): string[] { + const directName = String(name || "").trim(); + const fromUrl = extractFileNameFromUrl(url); + const source = directName || fromUrl; + if (!source) return []; + const ext = path.extname(source); + const stem = ext ? source.slice(0, -ext.length) : source; + const dashed = `${stem.replace(/\s+/g, "-")}${ext}`; + return uniqueStrings([source, fromUrl, dashed]); +} + +function buildCandidates(repo: string, check: UpdateCheckResult): string[] { + const assetUrl = String(check.setupAssetUrl || "").trim(); + const tag = String(check.latestTag || "").trim() || deriveTagFromUrl(String(check.releaseUrl || "")); + const names = deriveNameVariants(String(check.setupAssetName || ""), assetUrl); + + const urls: string[] = [assetUrl]; + if (tag && names.length > 0) { + for (const name of names) { + urls.push(`${WEB_BASE}/${repo}/releases/download/${encodeURIComponent(tag)}/${encodeURIComponent(name)}`); + } + } + if (!tag && names.length > 0) { + for (const name of names) { + urls.push(`${WEB_BASE}/${repo}/releases/latest/download/${encodeURIComponent(name)}`); + } + } + return uniqueStrings(urls); +} + +function deriveFileName(check: UpdateCheckResult, url: string): string { + const sanitize = (raw: string): string => { + const base = path.basename(String(raw || "").trim()); + if (!base) return "update.exe"; + const safe = base.replace(/[\\/:*?"<>|]/g, "_").replace(/^\.+/, "").trim(); + return safe || "update.exe"; + }; + + const fromName = String(check.setupAssetName || "").trim(); + if (fromName) return sanitize(fromName); + try { + return sanitize(new URL(url).pathname || "update.exe"); + } catch { + return "update.exe"; + } +} + +function httpStatusFromError(error: unknown): number { + const match = String(error || "").match(/HTTP\s+(\d{3})/i); + return match ? Number(match[1]) : 0; +} + +function isRetryable(error: unknown): boolean { + const status = httpStatusFromError(error); + if (status === 429 || status >= 500) return true; + const text = String(error || "").toLowerCase(); + return text.includes("timeout") + || text.includes("fetch failed") + || text.includes("network") + || text.includes("econnreset") + || text.includes("enotfound") + || text.includes("aborted"); +} + +function shouldTryNextCandidate(error: unknown): boolean { + const status = httpStatusFromError(error); + if (status >= 400 && status <= 599) return true; + return isRetryable(error); +} + +function isIntegrityError(error: unknown): boolean { + const text = String(error || "").toLowerCase(); + return text.includes("integrit") || text.includes("mismatch"); +} + +async function sleep(ms: number, signal?: AbortSignal): Promise<void> { + if (!signal) return new Promise((resolve) => setTimeout(resolve, ms)); + if (signal.aborted) throw new Error("aborted:update_shutdown"); + + return new Promise((resolve, reject) => { + let timer: NodeJS.Timeout | null = setTimeout(() => { + timer = null; + signal.removeEventListener("abort", onAbort); + resolve(); + }, Math.max(0, ms)); + + const onAbort = (): void => { + if (timer) { + clearTimeout(timer); + timer = null; + } + signal.removeEventListener("abort", onAbort); + reject(new Error("aborted:update_shutdown")); + }; + + signal.addEventListener("abort", onAbort, { once: true }); + }); +} + +async function downloadFile( + url: string, + targetPath: string, + onProgress?: UpdateProgressCallback, +): Promise<void> { + const shutdown = activeAbortController?.signal; + if (shutdown?.aborted) throw new Error("aborted:update_shutdown"); + + logger.info(`Update-Download versucht: ${url}`); + + const tc = timeoutController(CONNECT_TIMEOUT_MS); + let response: Response; + try { + response = await fetch(url, { + headers: { "User-Agent": USER_AGENT, "Accept-Encoding": "identity" }, + redirect: "follow", + signal: combineSignals(tc.signal, shutdown), + }); + } finally { + tc.clear(); + } + + if (!response.ok || !response.body) { + throw new Error(`Update Download fehlgeschlagen (HTTP ${response.status})`); + } + + const clRaw = Number(response.headers.get("content-length") || NaN); + const totalBytes = Number.isFinite(clRaw) && clRaw > 0 ? Math.max(0, Math.floor(clRaw)) : null; + + let downloadedBytes = 0; + let lastProgressAt = 0; + + const reportProgress = (force: boolean): void => { + const now = Date.now(); + if (!force && now - lastProgressAt < 160) return; + lastProgressAt = now; + const percent = totalBytes && totalBytes > 0 + ? Math.max(0, Math.min(100, Math.floor((downloadedBytes / totalBytes) * 100))) + : null; + const message = totalBytes && percent !== null + ? `Update wird heruntergeladen: ${percent}% (${humanSize(downloadedBytes)} / ${humanSize(totalBytes)})` + : `Update wird heruntergeladen (${humanSize(downloadedBytes)})`; + emitProgress(onProgress, { stage: "downloading", percent, downloadedBytes, totalBytes, message }); + }; + + reportProgress(true); + + await fs.promises.mkdir(path.dirname(targetPath), { recursive: true }); + const tempPath = `${targetPath}.tmp`; + const writeStream = fs.createWriteStream(tempPath); + const reader = response.body.getReader(); + + const idleMs = getBodyIdleTimeout(); + let idleTimer: NodeJS.Timeout | null = null; + let idleTimedOut = false; + + const clearIdle = (): void => { + if (idleTimer) { + clearTimeout(idleTimer); + idleTimer = null; + } + }; + + const resetIdle = (): void => { + clearIdle(); + if (idleMs > 0) { + idleTimer = setTimeout(() => { + idleTimedOut = true; + reader.cancel().catch(() => undefined); + }, idleMs); + } + }; + + try { + resetIdle(); + for (;;) { + if (shutdown?.aborted) { + await reader.cancel().catch(() => undefined); + throw new Error("aborted:update_shutdown"); + } + const { done, value } = await reader.read(); + if (done) break; + + const buf = Buffer.from(value.buffer, value.byteOffset, value.byteLength); + if (!writeStream.write(buf)) { + await new Promise<void>((resolve) => writeStream.once("drain", resolve)); + } + downloadedBytes += buf.byteLength; + resetIdle(); + reportProgress(false); + } + } catch (error) { + writeStream.destroy(); + await fs.promises.rm(tempPath, { force: true }).catch(() => {}); + if (idleTimedOut) { + throw new Error(`Update Download Body Timeout nach ${Math.ceil(idleMs / 1000)}s`); + } + throw error; + } finally { + clearIdle(); + } + + await new Promise<void>((resolve, reject) => { + writeStream.end(() => resolve()); + writeStream.on("error", reject); + }); + + if (idleTimedOut) { + await fs.promises.rm(tempPath, { force: true }).catch(() => {}); + throw new Error(`Update Download Body Timeout nach ${Math.ceil(idleMs / 1000)}s`); + } + + if (totalBytes && downloadedBytes !== totalBytes) { + await fs.promises.rm(tempPath, { force: true }).catch(() => {}); + throw new Error(`Update Download unvollständig (${downloadedBytes} / ${totalBytes} Bytes)`); + } + + await fs.promises.rename(tempPath, targetPath); + reportProgress(true); + logger.info(`Update-Download abgeschlossen: ${targetPath} (${downloadedBytes} Bytes)`); +} + +async function downloadWithRetries( + url: string, + targetPath: string, + onProgress?: UpdateProgressCallback, +): Promise<void> { + const shutdown = activeAbortController?.signal; + let lastError: unknown; + + for (let attempt = 1; attempt <= RETRIES_PER_CANDIDATE; attempt += 1) { + if (shutdown?.aborted) throw new Error("aborted:update_shutdown"); + try { + await downloadFile(url, targetPath, onProgress); + return; + } catch (error) { + lastError = error; + await fs.promises.rm(targetPath, { force: true }).catch(() => {}); + if (attempt < RETRIES_PER_CANDIDATE && isRetryable(error)) { + logger.warn(`Update-Download Retry ${attempt}/${RETRIES_PER_CANDIDATE} für ${url}: ${compactErrorText(error)}`); + await sleep(RETRY_DELAY_MS * attempt, shutdown); + continue; + } + break; + } + } + throw lastError; +} + +async function downloadFromCandidates( + candidates: string[], + targetPath: string, + onProgress?: UpdateProgressCallback, +): Promise<void> { + const shutdown = activeAbortController?.signal; + let lastError: unknown = new Error("Update Download fehlgeschlagen"); + + logger.info(`Update-Download: ${candidates.length} Kandidat(en), je ${RETRIES_PER_CANDIDATE} Versuche`); + for (let i = 0; i < candidates.length; i += 1) { + if (shutdown?.aborted) throw new Error("aborted:update_shutdown"); + emitProgress(onProgress, { + stage: "downloading", + percent: null, + downloadedBytes: 0, + totalBytes: null, + message: `Update-Download: Quelle ${i + 1}/${candidates.length}`, + }); + try { + await downloadWithRetries(candidates[i], targetPath, onProgress); + return; + } catch (error) { + lastError = error; + logger.warn(`Update-Download Kandidat ${i + 1}/${candidates.length} endgültig fehlgeschlagen: ${compactErrorText(error)}`); + if (i < candidates.length - 1 && shouldTryNextCandidate(error)) continue; + break; + } + } + throw lastError; +} + +async function resolveAssetFromApi(repo: string, tag: string): Promise<{ + setupAssetUrl: string; + setupAssetName: string; + setupAssetDigest: string; +} | null> { + const endpoints = uniqueStrings([ + tag ? `releases/tags/${encodeURIComponent(tag)}` : "", + "releases/latest", + ]); + + for (const ep of endpoints) { + try { + const { ok, payload } = await fetchRelease(repo, ep); + if (!ok || !payload || isDraftOrPrerelease(payload)) continue; + const setup = pickSetupAsset(readAssets(payload)); + if (!setup) continue; + return { + setupAssetUrl: setup.browser_download_url, + setupAssetName: setup.name, + setupAssetDigest: setup.digest, + }; + } catch { + } + } + return null; +} + +async function resolveDigestFromYml(repo: string, tag: string, setupName: string): Promise<string> { + const endpoints = uniqueStrings([ + tag ? `releases/tags/${encodeURIComponent(tag)}` : "", + "releases/latest", + ]); + + for (const ep of endpoints) { + try { + const { ok, payload } = await fetchRelease(repo, ep); + if (!ok || !payload || isDraftOrPrerelease(payload)) continue; + + const yml = pickLatestYml(readAssets(payload)); + if (!yml) continue; + + const tc = timeoutController(RELEASE_FETCH_TIMEOUT_MS); + let response: Response; + try { + response = await fetch(yml.browser_download_url, { + headers: { "User-Agent": USER_AGENT }, + signal: tc.signal, + }); + } finally { + tc.clear(); + } + if (!response.ok) continue; + + const yamlText = await readTextBody(response, RELEASE_FETCH_TIMEOUT_MS); + const sha = parseSha512FromLatestYml(yamlText, setupName); + if (sha) return `sha512:${sha}`; + } catch { + } + } + return ""; +} + +export function buildInstallerLaunchArgs(): string[] { + return ["/S", "--updated", "--force-run"]; +} + +export async function checkGitHubUpdate(repo: string): Promise<UpdateCheckResult> { + const safeRepo = normalizeUpdateRepo(repo); + const fallbackUrl = `${WEB_BASE}/${safeRepo}/releases/latest`; + const fallback: UpdateCheckResult = { + updateAvailable: false, + currentVersion: APP_VERSION, + latestVersion: APP_VERSION, + latestTag: `v${APP_VERSION}`, + releaseUrl: fallbackUrl, + }; + + try { + const { ok, status, payload } = await fetchRelease(safeRepo, "releases/latest"); + if (!ok || !payload) { + const reason = String((payload?.message as string) || `HTTP ${status}`); + return { ...fallback, error: reason }; + } + return parseReleasePayload(payload, fallbackUrl); + } catch (error) { + return { ...fallback, error: compactErrorText(error) }; + } +} + +export async function installLatestUpdate( + repo: string, + prechecked?: UpdateCheckResult, + onProgress?: UpdateProgressCallback, +): Promise<UpdateInstallResult> { + if (activeAbortController && !activeAbortController.signal.aborted) { + emitProgress(onProgress, { + stage: "error", percent: null, downloadedBytes: 0, totalBytes: null, + message: "Update-Download läuft bereits", + }); + return { started: false, message: "Update-Download läuft bereits" }; + } + + const abortCtrl = new AbortController(); + activeAbortController = abortCtrl; + const safeRepo = normalizeUpdateRepo(repo); + + const check = prechecked && !prechecked.error + ? prechecked + : await checkGitHubUpdate(safeRepo); + + if (check.error) { + activeAbortController = null; + emitProgress(onProgress, { + stage: "error", percent: null, downloadedBytes: 0, totalBytes: null, + message: check.error, + }); + return { started: false, message: check.error }; + } + + if (!check.updateAvailable) { + activeAbortController = null; + emitProgress(onProgress, { + stage: "error", percent: null, downloadedBytes: 0, totalBytes: null, + message: "Kein neues Update verfügbar", + }); + return { started: false, message: "Kein neues Update verfügbar" }; + } + + let effective: UpdateCheckResult = { + ...check, + setupAssetUrl: String(check.setupAssetUrl || ""), + setupAssetName: String(check.setupAssetName || ""), + setupAssetDigest: String(check.setupAssetDigest || ""), + }; + + if (!effective.setupAssetUrl || !effective.setupAssetDigest) { + const refreshed = await resolveAssetFromApi(safeRepo, effective.latestTag); + if (refreshed) { + effective = { + ...effective, + setupAssetUrl: refreshed.setupAssetUrl, + setupAssetName: refreshed.setupAssetName, + setupAssetDigest: refreshed.setupAssetDigest, + }; + } + } + + if (!effective.setupAssetDigest && effective.setupAssetUrl) { + const digest = await resolveDigestFromYml(safeRepo, effective.latestTag, effective.setupAssetName || ""); + if (digest) { + effective = { ...effective, setupAssetDigest: digest }; + logger.info("Update-Integritätsdigest aus latest.yml übernommen"); + } + } + + let candidates = buildCandidates(safeRepo, effective); + if (candidates.length === 0) { + activeAbortController = null; + emitProgress(onProgress, { + stage: "error", percent: null, downloadedBytes: 0, totalBytes: null, + message: "Setup-Asset nicht gefunden", + }); + return { started: false, message: "Setup-Asset nicht gefunden" }; + } + + const fileName = deriveFileName(effective, candidates[0]); + const targetPath = path.join(os.tmpdir(), "rd-update", `${Date.now()}-${process.pid}-${crypto.randomUUID()}-${fileName}`); + + try { + emitProgress(onProgress, { + stage: "starting", percent: 0, downloadedBytes: 0, totalBytes: null, + message: "Update wird vorbereitet", + }); + + if (abortCtrl.signal.aborted) throw new Error("aborted:update_shutdown"); + + let verified = false; + let lastVerifyError: unknown = null; + let integrityError: unknown = null; + + for (let pass = 0; pass < MAX_DOWNLOAD_PASSES && !verified; pass += 1) { + logger.info(`Update-Download Kandidaten (Pass ${pass + 1}/${MAX_DOWNLOAD_PASSES}): ${candidates.join(" | ")}`); + lastVerifyError = null; + + for (let i = 0; i < candidates.length; i += 1) { + if (abortCtrl.signal.aborted) throw new Error("aborted:update_shutdown"); + + try { + await downloadWithRetries(candidates[i], targetPath, onProgress); + + if (abortCtrl.signal.aborted) throw new Error("aborted:update_shutdown"); + + emitProgress(onProgress, { + stage: "verifying", percent: 100, downloadedBytes: 0, totalBytes: null, + message: `Prüfe Installer-Integrität (${i + 1}/${candidates.length})`, + }); + + await verifyDownloadedInstaller(targetPath, String(effective.setupAssetDigest || "")); + verified = true; + break; + } catch (error) { + lastVerifyError = error; + if (!integrityError && isIntegrityError(error)) { + integrityError = error; + } + await fs.promises.rm(targetPath, { force: true }).catch(() => {}); + if (i < candidates.length - 1) { + logger.warn(`Update-Kandidat ${i + 1}/${candidates.length} verworfen: ${compactErrorText(error)}`); + } + } + } + + if (verified) break; + + const status = httpStatusFromError(lastVerifyError); + const shouldRefresh = pass < MAX_DOWNLOAD_PASSES - 1 && (status === 404 || integrityError !== null); + if (!shouldRefresh) break; + + logger.warn(`Pass ${pass + 1} fehlgeschlagen (${integrityError ? "Integritätsfehler" : "HTTP 404"}), aktualisiere Kandidaten...`); + + const refreshed = await resolveAssetFromApi(safeRepo, effective.latestTag); + if (refreshed) { + effective = { + ...effective, + setupAssetUrl: refreshed.setupAssetUrl || effective.setupAssetUrl, + setupAssetName: refreshed.setupAssetName || effective.setupAssetName, + setupAssetDigest: refreshed.setupAssetDigest || effective.setupAssetDigest, + }; + } + + const ymlDigest = await resolveDigestFromYml(safeRepo, effective.latestTag, effective.setupAssetName || ""); + if (ymlDigest) { + effective = { ...effective, setupAssetDigest: ymlDigest }; + logger.info("Update-Integritätsdigest aus latest.yml aktualisiert"); + } + + const refreshedCandidates = buildCandidates(safeRepo, effective); + if (refreshedCandidates.length > 0) { + const changed = refreshedCandidates.length !== candidates.length + || refreshedCandidates.some((v, idx) => v !== candidates[idx]); + if (changed) { + candidates = refreshedCandidates; + logger.info("Kandidatenliste aktualisiert"); + } + } + + if (integrityError) { + integrityError = null; + logger.warn("SHA-Mismatch erkannt, erneuter Download-Versuch"); + } + } + + if (!verified) { + throw integrityError || lastVerifyError || new Error("Update-Download fehlgeschlagen"); + } + + emitProgress(onProgress, { + stage: "launching", percent: 100, downloadedBytes: 0, totalBytes: null, + message: "Starte stille Update-Installation", + }); + + const child = childProcess.spawn(targetPath, buildInstallerLaunchArgs(), { + detached: true, + stdio: "ignore", + windowsHide: true, + }); + child.once("error", (spawnError) => { + logger.error(`Update-Installer Start fehlgeschlagen: ${compactErrorText(spawnError)}`); + }); + child.unref(); + + emitProgress(onProgress, { + stage: "done", percent: 100, downloadedBytes: 0, totalBytes: null, + message: "Update wird im Hintergrund installiert und danach neu gestartet", + }); + + return { started: true, message: "Stille Update-Installation gestartet" }; + } catch (error) { + try { + await fs.promises.rm(targetPath, { force: true }); + } catch { + } + const releaseUrl = String(effective.releaseUrl || "").trim(); + const hint = releaseUrl ? ` – Manuell: ${releaseUrl}` : ""; + const message = `${compactErrorText(error)}${hint}`; + emitProgress(onProgress, { + stage: "error", percent: null, downloadedBytes: 0, totalBytes: null, + message, + }); + return { started: false, message }; + } finally { + if (activeAbortController === abortCtrl) { + activeAbortController = null; + } + } +} + +export function abortActiveUpdateDownload(): void { + if (!activeAbortController || activeAbortController.signal.aborted) return; + activeAbortController.abort("shutdown"); +} diff --git a/src/main/utils.ts b/src/main/utils.ts new file mode 100644 index 0000000..c6fd2bd --- /dev/null +++ b/src/main/utils.ts @@ -0,0 +1,309 @@ +import path from "node:path"; +import { ParsedPackageInput } from "../shared/types"; + +function safeDecodeURIComponent(value: string): string { + try { + return decodeURIComponent(value); + } catch { + return value; + } +} + +const WINDOWS_RESERVED_BASENAMES = new Set([ + "con", "prn", "aux", "nul", + "com1", "com2", "com3", "com4", "com5", "com6", "com7", "com8", "com9", + "lpt1", "lpt2", "lpt3", "lpt4", "lpt5", "lpt6", "lpt7", "lpt8", "lpt9" +]); + +export function compactErrorText(message: unknown, maxLen = 220): string { + const raw = String(message ?? "").replace(/<[^>]+>/g, " ").replace(/\s+/g, " ").trim(); + if (!raw) { + return "Unbekannter Fehler"; + } + const safeMaxLen = Number.isFinite(maxLen) ? Math.max(4, Math.floor(maxLen)) : 220; + if (raw.length <= safeMaxLen) { + return raw; + } + return `${raw.slice(0, safeMaxLen - 3)}...`; +} + +export function sanitizeFilename(name: string): string { + const cleaned = String(name || "") + .replace(/\0/g, "") + .replace(/[\\/:*?"<>|]/g, " ") + .replace(/\s+/g, " ") + .trim(); + + let normalized = cleaned + .replace(/^[.\s]+/g, "") + .replace(/[.\s]+$/g, "") + .trim(); + + if (!normalized || normalized === "." || normalized === ".." || /^\.+$/.test(normalized)) { + return "Paket"; + } + + const parsed = path.parse(normalized); + const reservedBase = (parsed.name.split(".")[0] || parsed.name).toLowerCase(); + if (WINDOWS_RESERVED_BASENAMES.has(reservedBase)) { + normalized = `${parsed.name.replace(/^([^.]*)/, "$1_")}${parsed.ext}`; + } + + return normalized || "Paket"; +} + +export function isHttpLink(value: string): boolean { + const text = String(value || "").trim(); + if (!text) { + return false; + } + try { + const url = new URL(text); + return (url.protocol === "http:" || url.protocol === "https:") && !!url.hostname; + } catch { + return false; + } +} + +export function extractHttpLinksFromText(text: string): string[] { + const matches = String(text || "").match(/https?:\/\/[^\s<>"']+/gi) ?? []; + const seen = new Set<string>(); + const links: string[] = []; + + for (const match of matches) { + let candidate = String(match || "").trim(); + let openParen = 0; + let closeParen = 0; + let openBracket = 0; + let closeBracket = 0; + for (const char of candidate) { + if (char === "(") { + openParen += 1; + } else if (char === ")") { + closeParen += 1; + } else if (char === "[") { + openBracket += 1; + } else if (char === "]") { + closeBracket += 1; + } + } + while (candidate.length > 0) { + const lastChar = candidate[candidate.length - 1]; + if (![")", "]", ",", ".", "!", "?", ";", ":"].includes(lastChar)) { + break; + } + if (lastChar === ")") { + if (closeParen <= openParen) { + break; + } + } + if (lastChar === "]") { + if (closeBracket <= openBracket) { + break; + } + } + if (lastChar === ")") { + closeParen = Math.max(0, closeParen - 1); + } else if (lastChar === "]") { + closeBracket = Math.max(0, closeBracket - 1); + } + candidate = candidate.slice(0, -1); + } + if (!candidate || !isHttpLink(candidate) || seen.has(candidate)) { + continue; + } + seen.add(candidate); + links.push(candidate); + } + + return links; +} + +export function humanSize(bytes: number): string { + const value = Number(bytes); + if (!Number.isFinite(value) || value < 0) { + return "0 B"; + } + if (value < 1024) { + return `${Math.round(value)} B`; + } + const units = ["KB", "MB", "GB", "TB"]; + let size = value / 1024; + let unit = 0; + while (size >= 1024 && unit < units.length - 1) { + size /= 1024; + unit += 1; + } + return `${size.toFixed(size < 10 ? 1 : 0)} ${units[unit]}`; +} + +export function filenameFromUrl(url: string): string { + try { + const parsed = new URL(url); + if (parsed.protocol !== "http:" && parsed.protocol !== "https:") { + return "download.bin"; + } + const queryName = parsed.searchParams.get("filename") + || parsed.searchParams.get("file") + || parsed.searchParams.get("name") + || parsed.searchParams.get("download") + || parsed.searchParams.get("title") + || ""; + const rawName = queryName || path.basename(parsed.pathname || ""); + const decoded = safeDecodeURIComponent(rawName || "").trim(); + const normalized = decoded + .replace(/\.(rar|zip|7z|tar|gz|bz2|xz|iso|part\d+\.rar|r\d{2,3})\.html$/i, ".$1") + .replace(/\.(mp4|mkv|avi|mp3|flac|srt)\.html$/i, ".$1"); + return sanitizeFilename(normalized || "download.bin"); + } catch { + return "download.bin"; + } +} + +export function looksLikeOpaqueFilename(name: string): boolean { + const cleaned = sanitizeFilename(name || "").toLowerCase(); + if (!cleaned || cleaned === "download.bin") { + return true; + } + const parsed = path.parse(cleaned); + return /^[a-f0-9]{24,}$/i.test(parsed.name || cleaned); +} + +export function inferPackageNameFromLinks(links: string[]): string { + if (links.length === 0) { + return "Paket"; + } + const names = links.map((link) => filenameFromUrl(link).toLowerCase()); + const first = names[0]; + const match = first.match(/^([a-z0-9._\- ]{3,80}?)(?:\.|-|_)(?:part\d+|r\d{2}|s\d{2}e\d{2})/i); + if (match) { + return sanitizeFilename(match[1]); + } + return sanitizeFilename(path.parse(first).name || "Paket"); +} + +export function uniquePreserveOrder(items: string[]): string[] { + const seen = new Set<string>(); + const out: string[] = []; + for (const item of items) { + const trimmed = item.trim(); + if (!trimmed || seen.has(trimmed)) { + continue; + } + seen.add(trimmed); + out.push(trimmed); + } + return out; +} + +export function parsePackagesFromLinksText(rawText: string, defaultPackageName: string): ParsedPackageInput[] { + const lines = String(rawText || "").split(/\r?\n/); + const packages: ParsedPackageInput[] = []; + let currentName = String(defaultPackageName || "").trim(); + let currentLinks: string[] = []; + let currentFileNames: string[] = []; + let pendingFileName = ""; + + const flush = (): void => { + const links = uniquePreserveOrder(currentLinks.filter((line) => isHttpLink(line))); + if (links.length > 0) { + const normalizedCurrentName = String(currentName || "").trim(); + const fileNames = links.map((link) => { + const firstIndex = currentLinks.findIndex((currentLink) => currentLink === link); + return firstIndex >= 0 ? currentFileNames[firstIndex] || "" : ""; + }); + const nextPackage: ParsedPackageInput = { + name: normalizedCurrentName + ? sanitizeFilename(normalizedCurrentName) + : inferPackageNameFromLinks(links), + links + }; + if (fileNames.some((fileName) => fileName.trim().length > 0)) { + nextPackage.fileNames = fileNames; + } + packages.push(nextPackage); + } + currentLinks = []; + currentFileNames = []; + pendingFileName = ""; + }; + + for (const line of lines) { + const text = line.trim(); + if (!text) { + continue; + } + const marker = text.match(/^#\s*package\s*:\s*(.+)$/i); + if (marker) { + flush(); + currentName = String(marker[1] || "").trim(); + pendingFileName = ""; + continue; + } + const fileMarker = text.match(/^#\s*file\s*:\s*(.+)$/i); + if (fileMarker) { + pendingFileName = sanitizeFilename(String(fileMarker[1] || "").trim()); + continue; + } + if (!isHttpLink(text)) { + continue; + } + currentLinks.push(text); + currentFileNames.push(pendingFileName); + pendingFileName = ""; + } + + flush(); + if (packages.length === 0) { + return []; + } + return packages; +} + +export function ensureDirPath(baseDir: string, packageName: string): string { + if (!path.isAbsolute(baseDir)) { + throw new Error("baseDir muss ein absoluter Pfad sein"); + } + return path.join(baseDir, sanitizeFilename(packageName)); +} + +export function nowMs(): number { + return Date.now(); +} + +export function sleep(ms: number, signal?: AbortSignal): Promise<void> { + return new Promise((resolve, reject) => { + if (signal?.aborted) { + reject(new Error(String(signal.reason || "aborted"))); + return; + } + const timer = setTimeout(() => { + cleanup(); + resolve(); + }, ms); + const onAbort = (): void => { + clearTimeout(timer); + cleanup(); + reject(new Error(String(signal?.reason || "aborted"))); + }; + const cleanup = (): void => { + signal?.removeEventListener("abort", onAbort); + }; + signal?.addEventListener("abort", onAbort, { once: true }); + }); +} + +export function formatEta(seconds: number): string { + if (!Number.isFinite(seconds) || seconds < 0) { + return "--"; + } + const s = Math.floor(seconds); + const sec = s % 60; + const minTotal = Math.floor(s / 60); + const min = minTotal % 60; + const hr = Math.floor(minTotal / 60); + if (hr > 0) { + return `${String(hr).padStart(2, "0")}:${String(min).padStart(2, "0")}:${String(sec).padStart(2, "0")}`; + } + return `${String(min).padStart(2, "0")}:${String(sec).padStart(2, "0")}`; +} diff --git a/src/main/video-processor.ts b/src/main/video-processor.ts new file mode 100644 index 0000000..c0cbacd --- /dev/null +++ b/src/main/video-processor.ts @@ -0,0 +1,513 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import crypto from "node:crypto"; +import { spawn } from "node:child_process"; + +// Removes only-German audio handling for "Dual Language" (.DL.) scene releases. +// Mirrors the user's ffmpeg script but adds: language-tag detection (with safe +// fallbacks), disk-space pre-check, atomic temp->replace, mtime preservation, +// abort-into-child, and "never destroy the only usable audio" safety. +// +// The ffmpeg/ffprobe-specific logic lives here so it is mockable in isolation; +// the per-package iteration + filename/.DL. rename + logging stays in +// download-manager.ts (its existing domain). + +export type GermanAudioMode = "tag" | "first"; + +export interface ProbedAudioStream { + language: string; + title: string; +} + +export type AudioTrackDecision = + | { action: "remux"; audioRelIndex: number; reason: string } + | { action: "single"; audioRelIndex: 0; reason: string } + | { action: "skip"; reason: string }; + +export type VideoProcessAction = + | "remuxed" + | "kept-single" + | "skipped-no-german" + | "skipped-no-audio" + | "skipped-no-space" + | "skipped-no-tool" + | "error" + | "aborted"; + +export interface VideoProcessResult { + action: VideoProcessAction; + reason: string; + keptTrackIndex?: number; + totalAudioTracks?: number; + audioLanguages?: string[]; + error?: string; +} + +export interface ProcessVideoOptions { + mode: GermanAudioMode; + cpuPriority?: string; + signal?: AbortSignal; +} + +// Injection seam so the irreversible file-mutating body (temp -> replace -> +// utimes -> rm-on-failure) can be exercised in tests with a fake ffmpeg/ffprobe +// runner, without spawning real processes. Production passes nothing. +export interface ProcessVideoDeps { + resolveTooling?: () => Promise<{ ffmpeg: string; ffprobe: string } | null>; + runProcess?: typeof runVideoProcess; + // Seam for the atomic-replace rename so its failure/recovery path is testable + // without provoking a real OS file lock. Production uses renameWithRetry. + rename?: (from: string, to: string) => Promise<void>; +} + +const VIDEO_REMUX_EXTENSIONS = new Set([".mkv", ".mp4"]); +const PROBE_TIMEOUT_MS = 60_000; +const STDOUT_CAP = 2 * 1024 * 1024; +const STDERR_CAP = 64 * 1024; + +// --------------------------------------------------------------------------- +// Pure helpers (no fs / no process) — unit-tested in isolation. +// --------------------------------------------------------------------------- + +// "X.German.DL.720p.mkv" -> "X.German.720p.mkv"; "X.DL.mkv" -> "X.mkv". +export function stripDualLangMarker(fileName: string): string { + const ext = path.extname(fileName); + const base = ext ? fileName.slice(0, -ext.length) : fileName; + const stripped = base.replace(/\.DL\./gi, ".").replace(/\.DL$/i, ""); + return stripped + ext; +} + +export function hasDualLangMarker(fileName: string): boolean { + return stripDualLangMarker(fileName) !== fileName; +} + +export function isRemuxableVideoFile(fileName: string): boolean { + return VIDEO_REMUX_EXTENSIONS.has(path.extname(fileName).toLowerCase()); +} + +// True when the release name explicitly marks it as a German release. Used in +// tag mode to fall back to the first audio track (German-first scene convention) +// when the audio language tags are wrong (a German dub mislabeled "eng"), instead +// of skipping. Deliberately requires an explicit german/deutsch token — the +// ".DL." marker alone (present on every processed file) is not enough, and a bare +// "dubbed" can mean an Italian/French dub, so it must NOT flag a German release. +export function looksLikeGermanRelease(fileName: string): boolean { + return /(^|[._\s-])(german|deutsch)([._\s-]|$)/i.test(fileName); +} + +function isGermanStream(stream: ProbedAudioStream): boolean { + const lang = (stream.language || "").toLowerCase().trim(); + if (["ger", "deu", "de", "german", "deutsch"].includes(lang)) { + return true; + } + // Free-text title fallback (used when the language tag is missing). Full words + // only — the 2-3 letter codes ger/deu are too ambiguous in a title and would + // pick the wrong track to keep (which then deletes the real German one). + if (lang) { + return false; + } + const title = (stream.title || "").toLowerCase(); + return /\b(german|deutsch)\b/.test(title); +} + +// Decide which audio track to keep. Safety invariant: only ever choose to remux +// (which destroys the original) when we are confident; otherwise skip untouched. +export function pickAudioTrack(streams: ProbedAudioStream[], mode: GermanAudioMode, germanRelease = false): AudioTrackDecision { + const total = streams.length; + if (total === 0) { + return { action: "skip", reason: "no-audio" }; + } + if (mode === "first") { + return total === 1 + ? { action: "single", audioRelIndex: 0, reason: "single-audio" } + : { action: "remux", audioRelIndex: 0, reason: "first-audio" }; + } + // tag mode + const germanPos = streams.findIndex(isGermanStream); + if (germanPos >= 0) { + return total === 1 + ? { action: "single", audioRelIndex: 0, reason: "single-german" } + : { action: "remux", audioRelIndex: germanPos, reason: "german-tag" }; + } + const anyTagged = streams.some((s) => (s.language || "").trim().length > 0); + if (!anyTagged) { + // No language metadata at all -> fall back to the script's behavior. + return total === 1 + ? { action: "single", audioRelIndex: 0, reason: "single-untagged" } + : { action: "remux", audioRelIndex: 0, reason: "fallback-first-untagged" }; + } + if (germanRelease) { + // Tagged, no German track found, but the release name explicitly says German + // -> the dub is mislabeled (German audio tagged "eng"). Trust the German-first + // scene convention rather than skipping. + return total === 1 + ? { action: "single", audioRelIndex: 0, reason: "single-german-mislabeled" } + : { action: "remux", audioRelIndex: 0, reason: "fallback-first-german-release" }; + } + // Tagged, no German track, and nothing says German -> never guess-delete. + return { action: "skip", reason: "no-german-track" }; +} + +export function parseFfprobeAudioStreams(jsonText: string): ProbedAudioStream[] { + let parsed: unknown; + try { + parsed = JSON.parse(jsonText); + } catch { + return []; + } + const streams = (parsed as { streams?: unknown }).streams; + if (!Array.isArray(streams)) { + return []; + } + return streams.map((raw) => { + const tags = (raw && typeof raw === "object" ? (raw as { tags?: unknown }).tags : undefined) as + | { language?: unknown; title?: unknown } + | undefined; + return { + language: typeof tags?.language === "string" ? tags.language : "", + title: typeof tags?.title === "string" ? tags.title : "" + }; + }); +} + +export function buildFfprobeArgs(input: string): string[] { + return [ + "-v", "error", + "-select_streams", "a", + "-show_entries", "stream=index:stream_tags=language,title", + "-of", "json", + input + ]; +} + +export function buildFfmpegRemuxArgs(opts: { input: string; output: string; audioRelIndex: number; keepSubs?: boolean }): string[] { + const args = ["-i", opts.input, "-map", "0:v:0", "-map", `0:a:${opts.audioRelIndex}`]; + if (opts.keepSubs) { + // Optional (not enabled by current settings): keep German subtitle tracks only. + args.push("-map", "0:s:m:language:ger?", "-map", "0:s:m:language:deu?"); + } + // Stream-copy and keep metadata (so the kept track's language tag survives; + // unlike the original script's -map_metadata -1 which dropped it). + args.push("-c", "copy", "-disposition:a:0", "default", "-y", opts.output); + return args; +} + +// Stream-copy remux is disk-bound; generous budget scaled by size, clamped. +export function computeRemuxTimeoutMs(bytes: number): number { + const perBytes = Math.ceil((Number(bytes) || 0) / (10 * 1024 * 1024)) * 1000; + return Math.max(120_000, Math.min(60 * 60 * 1000, 120_000 + perBytes)); +} + +// --------------------------------------------------------------------------- +// Tooling discovery (system PATH + RD_FFMPEG_BIN/RD_FFPROBE_BIN env override). +// Lazy probe + cache, mirroring the extractor's 7z/Java resolution convention. +// --------------------------------------------------------------------------- + +interface VideoTooling { + ffmpeg: string; + ffprobe: string; +} + +let cachedTooling: VideoTooling | null | undefined; +let cachedToolingNullSince = 0; +const TOOLING_NULL_TTL_MS = 5 * 60 * 1000; + +function ffmpegCandidate(): string { + return String(process.env.RD_FFMPEG_BIN || "").trim() || "ffmpeg"; +} + +function ffprobeCandidate(): string { + return String(process.env.RD_FFPROBE_BIN || "").trim() || "ffprobe"; +} + +async function probeVersion(command: string): Promise<boolean> { + const result = await runVideoProcess(command, ["-version"], { timeoutMs: 10_000 }); + return result.ok && !result.missing; +} + +export async function resolveVideoTooling(): Promise<VideoTooling | null> { + if (cachedTooling) { + return cachedTooling; + } + if (cachedTooling === null && Date.now() - cachedToolingNullSince < TOOLING_NULL_TTL_MS) { + return null; + } + const ffmpeg = ffmpegCandidate(); + const ffprobe = ffprobeCandidate(); + const [ffmpegOk, ffprobeOk] = await Promise.all([probeVersion(ffmpeg), probeVersion(ffprobe)]); + if (ffmpegOk && ffprobeOk) { + cachedTooling = { ffmpeg, ffprobe }; + return cachedTooling; + } + cachedTooling = null; + cachedToolingNullSince = Date.now(); + return null; +} + +export function resetVideoToolingCache(): void { + cachedTooling = undefined; + cachedToolingNullSince = 0; +} + +// --------------------------------------------------------------------------- +// Process spawning (ffmpeg/ffprobe). ffmpeg/ffprobe exit conventions: 0 = ok, +// anything else = real failure (NOT 7-Zip's "exit 1 = warning" semantics). +// --------------------------------------------------------------------------- + +export interface VideoSpawnResult { + ok: boolean; + aborted: boolean; + timedOut: boolean; + missing: boolean; + exitCode: number | null; + stdout: string; + stderr: string; +} + +function appendCapped(buffer: string, text: string, cap: number): string { + const next = buffer + text; + return next.length > cap ? next.slice(next.length - cap) : next; +} + +function applyChildPriority(pid: number | undefined, cpuPriority?: string): void { + if (process.platform !== "win32") { + return; + } + const numeric = Number(pid || 0); + if (!Number.isFinite(numeric) || numeric <= 0) { + return; + } + try { + const level = cpuPriority === "high" ? os.constants.priority.PRIORITY_NORMAL : os.constants.priority.PRIORITY_BELOW_NORMAL; + os.setPriority(numeric, level); + } catch { + } +} + +function killChildTree(child: { pid?: number; kill: () => void }): void { + const pid = Number(child.pid || 0); + if (process.platform === "win32" && Number.isFinite(pid) && pid > 0) { + try { + const killer = spawn("taskkill", ["/PID", String(pid), "/T", "/F"], { windowsHide: true, stdio: "ignore" }); + killer.on("error", () => { try { child.kill(); } catch {} }); + return; + } catch { + } + } + try { + child.kill(); + } catch { + } +} + +export function runVideoProcess( + command: string, + args: string[], + opts: { signal?: AbortSignal; timeoutMs?: number; cpuPriority?: string } = {} +): Promise<VideoSpawnResult> { + const { signal, timeoutMs, cpuPriority } = opts; + if (signal?.aborted) { + return Promise.resolve({ ok: false, aborted: true, timedOut: false, missing: false, exitCode: null, stdout: "", stderr: "" }); + } + return new Promise((resolve) => { + let settled = false; + let stdout = ""; + let stderr = ""; + let timedOut = false; + let aborted = false; + let timeoutId: NodeJS.Timeout | null = null; + + const child = spawn(command, args, { windowsHide: true }); + applyChildPriority(child.pid, cpuPriority); + + const onAbort = (): void => { + aborted = true; + killChildTree(child); + }; + + const finish = (result: VideoSpawnResult): void => { + if (settled) { + return; + } + settled = true; + if (timeoutId) { + clearTimeout(timeoutId); + timeoutId = null; + } + if (signal) { + signal.removeEventListener("abort", onAbort); + } + resolve(result); + }; + + if (timeoutMs && timeoutMs > 0) { + timeoutId = setTimeout(() => { + timedOut = true; + killChildTree(child); + finish({ ok: false, aborted: false, timedOut: true, missing: false, exitCode: null, stdout, stderr }); + }, timeoutMs); + } + if (signal) { + signal.addEventListener("abort", onAbort, { once: true }); + } + + child.stdout?.on("data", (chunk) => { stdout = appendCapped(stdout, String(chunk || ""), STDOUT_CAP); }); + child.stderr?.on("data", (chunk) => { stderr = appendCapped(stderr, String(chunk || ""), STDERR_CAP); }); + + child.on("error", (error) => { + const text = String(error || ""); + finish({ ok: false, aborted: false, timedOut: false, missing: text.toLowerCase().includes("enoent"), exitCode: null, stdout, stderr: stderr || text }); + }); + + child.on("close", (code) => { + if (aborted) { + finish({ ok: false, aborted: true, timedOut: false, missing: false, exitCode: code, stdout, stderr }); + return; + } + if (timedOut) { + finish({ ok: false, aborted: false, timedOut: true, missing: false, exitCode: code, stdout, stderr }); + return; + } + finish({ ok: code === 0, aborted: false, timedOut: false, missing: false, exitCode: code, stdout, stderr }); + }); + }); +} + +// --------------------------------------------------------------------------- +// Per-file orchestration: probe -> decide -> (disk check) -> remux -> atomic +// replace -> preserve mtime. Operates IN PLACE (same filename); the .DL. rename +// + companion handling + logging is done by the caller (download-manager). +// --------------------------------------------------------------------------- + +async function getFreeSpaceBytes(dir: string): Promise<number | null> { + try { + const stat = await fs.promises.statfs(dir); + return Number(stat.bavail) * Number(stat.bsize); + } catch { + return null; + } +} + +const RENAME_RETRY_DELAYS_MS = [200, 500, 1000]; +const RENAME_RETRYABLE_CODES = new Set(["EBUSY", "EACCES", "EPERM", "EEXIST"]); + +function delayMs(ms: number): Promise<void> { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +// Windows file locks from antivirus, the search indexer, or a media scanner are +// transient: a rename that hits EBUSY/EACCES/EPERM/EEXIST often succeeds a moment +// later. Retry with backoff before giving up so a momentary lock doesn't abort +// the atomic replace and leave the file unprocessed. +export async function renameWithRetry(from: string, to: string): Promise<void> { + for (let attempt = 0; ; attempt += 1) { + try { + await fs.promises.rename(from, to); + return; + } catch (error) { + const code = (error as NodeJS.ErrnoException)?.code; + if (!code || !RENAME_RETRYABLE_CODES.has(code) || attempt >= RENAME_RETRY_DELAYS_MS.length) { + throw error; + } + await delayMs(RENAME_RETRY_DELAYS_MS[attempt]); + } + } +} + +// Short, unique, same-directory sidecar name (never longer than the original file +// name) so concurrent packages / retries never collide on a fixed temp name and a +// long scene filename + suffix cannot push the path past Windows MAX_PATH. +function uniqueTempPath(filePath: string): string { + const ext = path.extname(filePath); + const token = `${process.pid.toString(36)}${crypto.randomBytes(3).toString("hex")}`; + return path.join(path.dirname(filePath), `~rd${token}${ext}`); +} + +export async function processVideoFile(filePath: string, opts: ProcessVideoOptions, deps: ProcessVideoDeps = {}): Promise<VideoProcessResult> { + const resolveTool = deps.resolveTooling || resolveVideoTooling; + const run = deps.runProcess || runVideoProcess; + if (opts.signal?.aborted) { + return { action: "aborted", reason: "aborted" }; + } + const tooling = await resolveTool(); + if (!tooling) { + return { action: "skipped-no-tool", reason: "ffmpeg/ffprobe nicht gefunden (PATH oder RD_FFMPEG_BIN)" }; + } + + const probe = await run(tooling.ffprobe, buildFfprobeArgs(filePath), { signal: opts.signal, timeoutMs: PROBE_TIMEOUT_MS }); + if (probe.aborted) { + return { action: "aborted", reason: "aborted" }; + } + if (!probe.ok) { + return { action: "error", reason: "ffprobe fehlgeschlagen", error: probe.stderr || `exit ${String(probe.exitCode)}` }; + } + + const streams = parseFfprobeAudioStreams(probe.stdout); + const audioLanguages = streams.map((s) => (s.language || "").trim() || "und"); + const decision = pickAudioTrack(streams, opts.mode, looksLikeGermanRelease(path.basename(filePath))); + if (decision.action === "skip") { + return { + action: decision.reason === "no-german-track" ? "skipped-no-german" : "skipped-no-audio", + reason: decision.reason, + totalAudioTracks: streams.length, + audioLanguages + }; + } + if (decision.action === "single") { + return { action: "kept-single", reason: decision.reason, totalAudioTracks: streams.length, audioLanguages, keptTrackIndex: 0 }; + } + + // remux path + let originalStat: fs.Stats; + try { + originalStat = await fs.promises.stat(filePath); + } catch (error) { + return { action: "error", reason: "stat fehlgeschlagen", error: String(error), audioLanguages }; + } + const free = await getFreeSpaceBytes(path.dirname(filePath)); + if (free !== null && free < Math.ceil(originalStat.size * 1.05)) { + return { action: "skipped-no-space", reason: "zu wenig freier Speicher fuer Remux", totalAudioTracks: streams.length, audioLanguages }; + } + + const tempPath = uniqueTempPath(filePath); + await fs.promises.rm(tempPath, { force: true }).catch(() => {}); + + const remux = await run( + tooling.ffmpeg, + buildFfmpegRemuxArgs({ input: filePath, output: tempPath, audioRelIndex: decision.audioRelIndex, keepSubs: false }), + { signal: opts.signal, timeoutMs: computeRemuxTimeoutMs(originalStat.size), cpuPriority: opts.cpuPriority } + ); + if (remux.aborted) { + await fs.promises.rm(tempPath, { force: true }).catch(() => {}); + return { action: "aborted", reason: "aborted" }; + } + if (!remux.ok) { + await fs.promises.rm(tempPath, { force: true }).catch(() => {}); + return { action: "error", reason: "ffmpeg remux fehlgeschlagen", error: remux.stderr || `exit ${String(remux.exitCode)}`, totalAudioTracks: streams.length, audioLanguages, keptTrackIndex: decision.audioRelIndex }; + } + + const tempStat = await fs.promises.stat(tempPath).catch(() => null); + if (!tempStat || tempStat.size <= 0) { + await fs.promises.rm(tempPath, { force: true }).catch(() => {}); + return { action: "error", reason: "Remux ergab leere Datei", totalAudioTracks: streams.length, audioLanguages }; + } + + const renameOp = deps.rename || renameWithRetry; + try { + // Atomic replace-over: libuv maps fs.rename to MoveFileEx(REPLACE_EXISTING) on + // Windows and rename(2) on POSIX, both atomic on the same volume, so filePath + // holds either the full original or the full remux at every instant. Retried + // for transient locks. We must NEVER rm the original first (the old fallback + // did): an rm-then-failed-rename left zero copies of the file on disk. + await renameOp(tempPath, filePath); + // Preserve original mtime so freshness gates (hybrid collect) don't skip it. + await fs.promises.utimes(filePath, originalStat.atime, originalStat.mtime).catch(() => {}); + } catch (error) { + // Replace failed -> the original is untouched at filePath. Drop the temp only. + await fs.promises.rm(tempPath, { force: true }).catch(() => {}); + return { action: "error", reason: "Ersetzen der Datei fehlgeschlagen", error: String(error), totalAudioTracks: streams.length, audioLanguages }; + } + + return { action: "remuxed", reason: decision.reason, keptTrackIndex: decision.audioRelIndex, totalAudioTracks: streams.length, audioLanguages }; +} diff --git a/src/main/windows-host-diagnostics.ts b/src/main/windows-host-diagnostics.ts new file mode 100644 index 0000000..b96f119 --- /dev/null +++ b/src/main/windows-host-diagnostics.ts @@ -0,0 +1,325 @@ +import fs from "node:fs"; +import { spawnSync } from "node:child_process"; + +export interface WindowsHostEvent { + timeCreated: string; + id: number; + providerName: string; + levelDisplayName: string; + message: string; + bugcheckCode?: string; + bugcheckCodeHex?: string; + reportId?: string; +} + +export interface WindowsHostDumpFile { + name: string; + fullName: string; + length: number; + lastWriteTime: string; +} + +export interface WindowsCrashControlInfo { + crashDumpEnabled: number | null; + minidumpDir: string; + dumpFile: string; + overwrite: number | null; + logEvent: number | null; + autoReboot: number | null; +} + +export interface WindowsHostDiagnostics { + collectedAt: string; + supported: boolean; + platform: string; + crashControl: WindowsCrashControlInfo | null; + recentKernelPower: WindowsHostEvent[]; + recentWerKernel: WindowsHostEvent[]; + recentKernelDump: WindowsHostEvent[]; + recentAppCrashes: WindowsHostEvent[]; + recentMinidumps: WindowsHostDumpFile[]; + assessmentHints: string[]; + errors: string[]; +} + +const CACHE_TTL_MS = 15_000; + +let cachedAt = 0; +let cachedValue: WindowsHostDiagnostics | null = null; + +function createEmptyDiagnostics(): WindowsHostDiagnostics { + return { + collectedAt: new Date().toISOString(), + supported: process.platform === "win32", + platform: process.platform, + crashControl: null, + recentKernelPower: [], + recentWerKernel: [], + recentKernelDump: [], + recentAppCrashes: [], + recentMinidumps: [], + assessmentHints: [], + errors: [] + }; +} + +function runPowerShellJson(script: string): unknown { + const result = spawnSync( + process.env.ComSpec && process.env.ComSpec.toLowerCase().includes("pwsh") ? process.env.ComSpec : "powershell.exe", + ["-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-Command", script], + { + encoding: "utf8", + timeout: 20_000, + windowsHide: true, + stdio: ["ignore", "pipe", "pipe"] + } + ); + + if (result.status !== 0) { + const errorText = String(result.stderr || result.stdout || "").trim() || `PowerShell exited with code ${result.status}`; + throw new Error(errorText); + } + + const text = String(result.stdout || "").trim(); + if (!text) { + return null; + } + return JSON.parse(text) as unknown; +} + +function asRecord(value: unknown): Record<string, unknown> | null { + if (!value || typeof value !== "object" || Array.isArray(value)) { + return null; + } + return value as Record<string, unknown>; +} + +function asString(value: unknown): string { + return typeof value === "string" ? value : value === undefined || value === null ? "" : String(value); +} + +function asNumber(value: unknown): number | null { + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : null; +} + +function normalizeEvent(value: unknown): WindowsHostEvent | null { + const record = asRecord(value); + if (!record) { + return null; + } + return { + timeCreated: asString(record.TimeCreated), + id: asNumber(record.Id) || 0, + providerName: asString(record.ProviderName), + levelDisplayName: asString(record.LevelDisplayName), + message: asString(record.Message), + bugcheckCode: asString(record.BugcheckCode), + bugcheckCodeHex: asString(record.BugcheckCodeHex), + reportId: asString(record.ReportId) + }; +} + +function normalizeDumpFile(value: unknown): WindowsHostDumpFile | null { + const record = asRecord(value); + if (!record) { + return null; + } + return { + name: asString(record.Name), + fullName: asString(record.FullName), + length: asNumber(record.Length) || 0, + lastWriteTime: asString(record.LastWriteTime) + }; +} + +function normalizeCrashControl(value: unknown): WindowsCrashControlInfo | null { + const record = asRecord(value); + if (!record) { + return null; + } + return { + crashDumpEnabled: asNumber(record.CrashDumpEnabled), + minidumpDir: asString(record.MinidumpDir), + dumpFile: asString(record.DumpFile), + overwrite: asNumber(record.Overwrite), + logEvent: asNumber(record.LogEvent), + autoReboot: asNumber(record.AutoReboot) + }; +} + +function pushHints(diagnostics: WindowsHostDiagnostics): void { + if (diagnostics.recentKernelPower.some((entry) => String(entry.bugcheckCode || "").trim() === "0")) { + diagnostics.assessmentHints.push("Kernel-Power 41 mit BugcheckCode 0 deutet eher auf Freeze, Watchdog oder harten Reset als auf einen sauber erfassten klassischen BSOD hin."); + } + if (diagnostics.recentWerKernel.some((entry) => /watchdog/i.test(entry.message))) { + diagnostics.assessmentHints.push("WER-Kernel meldet WATCHDOG-Live-Dumps. Das spricht eher fuer Kernel-, Treiber- oder Hardware-Stalls als fuer einen normalen User-Mode-App-Crash."); + } + if (diagnostics.recentAppCrashes.length === 0) { + diagnostics.assessmentHints.push("Keine passenden Application-Error- oder Windows-Error-Reporting-Eintraege fuer den Downloader/Electron in den letzten Tagen gefunden."); + } + if (diagnostics.recentMinidumps.length === 0) { + diagnostics.assessmentHints.push("Keine aktuellen Minidumps gefunden. Falls der Server erneut abstuerzt, sollte geprueft werden, ob Windows den Dump wirklich schreiben darf."); + } +} + +function loadFromPowerShell(): WindowsHostDiagnostics { + const script = String.raw` +$ErrorActionPreference = "SilentlyContinue" + +function Convert-EventRecord($eventRecord) { + $map = @{} + try { + [xml]$xml = $eventRecord.ToXml() + foreach ($node in $xml.Event.EventData.Data) { + if ($node.Name) { + $map[$node.Name] = [string]$node.'#text' + } + } + } catch { + } + + $reportId = "" + if ([string]$eventRecord.Message -match "ReportId\s+([^,\r\n]+)") { + $reportId = $Matches[1] + } + + [PSCustomObject]@{ + TimeCreated = if ($eventRecord.TimeCreated) { $eventRecord.TimeCreated.ToUniversalTime().ToString("o") } else { "" } + Id = [int]$eventRecord.Id + ProviderName = [string]$eventRecord.ProviderName + LevelDisplayName = [string]$eventRecord.LevelDisplayName + Message = [string]$eventRecord.Message + BugcheckCode = if ($map.ContainsKey("BugcheckCode")) { [string]$map["BugcheckCode"] } else { "" } + BugcheckCodeHex = if ($map.ContainsKey("BugcheckCode") -and [int64]$map["BugcheckCode"] -gt 0) { ("0x{0:X}" -f [int64]$map["BugcheckCode"]) } else { "" } + ReportId = $reportId + } +} + +$startTime = (Get-Date).AddDays(-7) +$crashControl = Get-ItemProperty "HKLM:\SYSTEM\CurrentControlSet\Control\CrashControl" + +$kernelPower = @( + Get-WinEvent -FilterHashtable @{ LogName = "System"; Id = 41; StartTime = $startTime } -MaxEvents 5 | + ForEach-Object { Convert-EventRecord $_ } +) + +$werKernel = @( + Get-WinEvent -FilterHashtable @{ LogName = "Microsoft-Windows-WerKernel/Operational"; StartTime = $startTime } -MaxEvents 30 | + Where-Object { $_.Message -match "WATCHDOG|dump|bugcheck|blue|memory" } | + Select-Object -First 10 | + ForEach-Object { Convert-EventRecord $_ } +) + +$kernelDump = @( + Get-WinEvent -FilterHashtable @{ LogName = "Microsoft-Windows-Kernel-Dump/Operational"; StartTime = $startTime } -MaxEvents 20 | + Select-Object -First 10 | + ForEach-Object { Convert-EventRecord $_ } +) + +$appCrashes = @( + Get-WinEvent -FilterHashtable @{ LogName = "Application"; StartTime = $startTime } -MaxEvents 100 | + Where-Object { + ($_.ProviderName -eq "Application Error" -or $_.ProviderName -eq "Windows Error Reporting") -and + ($_.Message -match "Real-Debrid-Downloader|electron|node\.exe|main\.js") + } | + Select-Object -First 10 | + ForEach-Object { Convert-EventRecord $_ } +) + +$dumpFiles = @() +foreach ($dir in @("C:\Windows\Minidump", "C:\Windows\Minidumps")) { + if (Test-Path $dir) { + $dumpFiles += Get-ChildItem -Path $dir -File | + Sort-Object LastWriteTime -Descending | + Select-Object -First 10 | + ForEach-Object { + [PSCustomObject]@{ + Name = $_.Name + FullName = $_.FullName + Length = [int64]$_.Length + LastWriteTime = $_.LastWriteTimeUtc.ToString("o") + } + } + } +} + +[PSCustomObject]@{ + CrashControl = [PSCustomObject]@{ + CrashDumpEnabled = if ($null -ne $crashControl.CrashDumpEnabled) { [int]$crashControl.CrashDumpEnabled } else { $null } + MinidumpDir = [string]$crashControl.MinidumpDir + DumpFile = [string]$crashControl.DumpFile + Overwrite = if ($null -ne $crashControl.Overwrite) { [int]$crashControl.Overwrite } else { $null } + LogEvent = if ($null -ne $crashControl.LogEvent) { [int]$crashControl.LogEvent } else { $null } + AutoReboot = if ($null -ne $crashControl.AutoReboot) { [int]$crashControl.AutoReboot } else { $null } + } + RecentKernelPower = @($kernelPower) + RecentWerKernel = @($werKernel) + RecentKernelDump = @($kernelDump) + RecentAppCrashes = @($appCrashes) + RecentMinidumps = @($dumpFiles) +} | ConvertTo-Json -Depth 6 -Compress +`; + + const raw = runPowerShellJson(script); + const parsed = asRecord(raw); + const diagnostics = createEmptyDiagnostics(); + diagnostics.crashControl = normalizeCrashControl(parsed?.CrashControl ?? null); + diagnostics.recentKernelPower = Array.isArray(parsed?.RecentKernelPower) ? parsed!.RecentKernelPower.map(normalizeEvent).filter(Boolean) as WindowsHostEvent[] : []; + diagnostics.recentWerKernel = Array.isArray(parsed?.RecentWerKernel) ? parsed!.RecentWerKernel.map(normalizeEvent).filter(Boolean) as WindowsHostEvent[] : []; + diagnostics.recentKernelDump = Array.isArray(parsed?.RecentKernelDump) ? parsed!.RecentKernelDump.map(normalizeEvent).filter(Boolean) as WindowsHostEvent[] : []; + diagnostics.recentAppCrashes = Array.isArray(parsed?.RecentAppCrashes) ? parsed!.RecentAppCrashes.map(normalizeEvent).filter(Boolean) as WindowsHostEvent[] : []; + diagnostics.recentMinidumps = Array.isArray(parsed?.RecentMinidumps) ? parsed!.RecentMinidumps.map(normalizeDumpFile).filter(Boolean) as WindowsHostDumpFile[] : []; + diagnostics.collectedAt = new Date().toISOString(); + pushHints(diagnostics); + return diagnostics; +} + +export function getWindowsHostDiagnostics(forceRefresh = false): WindowsHostDiagnostics { + if (!forceRefresh && cachedValue && Date.now() - cachedAt < CACHE_TTL_MS) { + return cachedValue; + } + + const diagnostics = createEmptyDiagnostics(); + if (process.platform !== "win32") { + diagnostics.assessmentHints.push("Windows-Host-Diagnose ist nur unter Windows verfuegbar."); + cachedAt = Date.now(); + cachedValue = diagnostics; + return diagnostics; + } + + try { + const loaded = loadFromPowerShell(); + cachedAt = Date.now(); + cachedValue = loaded; + return loaded; + } catch (error) { + diagnostics.errors.push(String(error instanceof Error ? error.message : error)); + diagnostics.assessmentHints.push("Host-Diagnose konnte nicht vollstaendig geladen werden."); + cachedAt = Date.now(); + cachedValue = diagnostics; + return diagnostics; + } +} + +export function getCachedWindowsHostDiagnostics(): WindowsHostDiagnostics | null { + return cachedValue; +} + +export function resetWindowsHostDiagnosticsCache(): void { + cachedAt = 0; + cachedValue = null; +} + +export function hasRecentWindowsMinidumps(): boolean { + for (const dir of ["C:\\Windows\\Minidump", "C:\\Windows\\Minidumps"]) { + try { + const entries = fs.readdirSync(dir, { withFileTypes: true }); + if (entries.some((entry) => entry.isFile())) { + return true; + } + } catch { + } + } + return false; +} diff --git a/src/preload/preload.ts b/src/preload/preload.ts new file mode 100644 index 0000000..0634cf1 --- /dev/null +++ b/src/preload/preload.ts @@ -0,0 +1,124 @@ +import { contextBridge, ipcRenderer } from "electron"; +import { + AddLinksPayload, + AllDebridHostInfo, + AppSettings, + DebridAccountStatus, + DebridLinkHostLimitInfo, + DebridProvider, + DuplicatePolicy, + EnableRemoteDiagnosticsInput, + HistoryEntry, + PackagePriority, + RemoteDiagnosticsInfo, + RendererErrorReport, + SessionStats, + StartConflictEntry, + StartConflictResolutionResult, + UiSnapshot, + UpdateCheckResult, + UpdateInstallProgress +} from "../shared/types"; +import { IPC_CHANNELS } from "../shared/ipc"; +import { ElectronApi } from "../shared/preload-api"; + +const api: ElectronApi = { + getSnapshot: (): Promise<UiSnapshot> => ipcRenderer.invoke(IPC_CHANNELS.GET_SNAPSHOT), + getVersion: (): Promise<string> => ipcRenderer.invoke(IPC_CHANNELS.GET_VERSION), + checkUpdates: (): Promise<UpdateCheckResult> => ipcRenderer.invoke(IPC_CHANNELS.CHECK_UPDATES), + installUpdate: () => ipcRenderer.invoke(IPC_CHANNELS.INSTALL_UPDATE), + openExternal: (url: string): Promise<boolean> => ipcRenderer.invoke(IPC_CHANNELS.OPEN_EXTERNAL, url), + updateSettings: (settings: Partial<AppSettings>): Promise<AppSettings> => ipcRenderer.invoke(IPC_CHANNELS.UPDATE_SETTINGS, settings), + resetProviderDailyUsage: (provider: DebridProvider): Promise<AppSettings> => ipcRenderer.invoke(IPC_CHANNELS.RESET_PROVIDER_DAILY_USAGE, provider), + resetDebridLinkApiKeyDailyUsage: (keyId: string): Promise<AppSettings> => ipcRenderer.invoke(IPC_CHANNELS.RESET_DEBRID_LINK_API_KEY_DAILY_USAGE, keyId), + addLinks: (payload: AddLinksPayload): Promise<{ addedPackages: number; addedLinks: number; invalidCount: number }> => + ipcRenderer.invoke(IPC_CHANNELS.ADD_LINKS, payload), + addContainers: (filePaths: string[]): Promise<{ addedPackages: number; addedLinks: number }> => + ipcRenderer.invoke(IPC_CHANNELS.ADD_CONTAINERS, filePaths), + getStartConflicts: (): Promise<StartConflictEntry[]> => ipcRenderer.invoke(IPC_CHANNELS.GET_START_CONFLICTS), + resolveStartConflict: (packageId: string, policy: DuplicatePolicy): Promise<StartConflictResolutionResult> => + ipcRenderer.invoke(IPC_CHANNELS.RESOLVE_START_CONFLICT, packageId, policy), + clearAll: (): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.CLEAR_ALL), + start: (): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.START), + startPackages: (packageIds: string[]): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.START_PACKAGES, packageIds), + stop: (): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.STOP), + togglePause: (): Promise<boolean> => ipcRenderer.invoke(IPC_CHANNELS.TOGGLE_PAUSE), + cancelPackage: (packageId: string): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.CANCEL_PACKAGE, packageId), + renamePackage: (packageId: string, newName: string): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.RENAME_PACKAGE, packageId, newName), + reorderPackages: (packageIds: string[]): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.REORDER_PACKAGES, packageIds), + removeItem: (itemId: string): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.REMOVE_ITEM, itemId), + togglePackage: (packageId: string): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.TOGGLE_PACKAGE, packageId), + exportPackageSelection: (packageIds: string[]) => ipcRenderer.invoke(IPC_CHANNELS.EXPORT_PACKAGE_SELECTION, packageIds), + exportItemSelection: (itemIds: string[]) => ipcRenderer.invoke(IPC_CHANNELS.EXPORT_ITEM_SELECTION, itemIds), + exportQueue: (): Promise<{ saved: boolean }> => ipcRenderer.invoke(IPC_CHANNELS.EXPORT_QUEUE), + importQueue: (json: string): Promise<{ addedPackages: number; addedLinks: number }> => ipcRenderer.invoke(IPC_CHANNELS.IMPORT_QUEUE, json), + toggleClipboard: (): Promise<boolean> => ipcRenderer.invoke(IPC_CHANNELS.TOGGLE_CLIPBOARD), + pickFolder: (): Promise<string | null> => ipcRenderer.invoke(IPC_CHANNELS.PICK_FOLDER), + pickContainers: (): Promise<string[]> => ipcRenderer.invoke(IPC_CHANNELS.PICK_CONTAINERS), + getSessionStats: (): Promise<SessionStats> => ipcRenderer.invoke(IPC_CHANNELS.GET_SESSION_STATS), + resetSessionStats: (): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.RESET_SESSION_STATS), + resetDownloadStats: (): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.RESET_DOWNLOAD_STATS), + restart: (): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.RESTART), + quit: (): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.QUIT), + exportBackup: (): Promise<{ saved: boolean }> => ipcRenderer.invoke(IPC_CHANNELS.EXPORT_BACKUP), + importBackup: (): Promise<{ restored: boolean; relaunch: boolean; message: string }> => ipcRenderer.invoke(IPC_CHANNELS.IMPORT_BACKUP), + exportSupportBundle: (): Promise<{ saved: boolean; filePath?: string }> => ipcRenderer.invoke(IPC_CHANNELS.EXPORT_SUPPORT_BUNDLE), + openLog: (): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.OPEN_LOG), + openAuditLog: (): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.OPEN_AUDIT_LOG), + openRenameLog: (): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.OPEN_RENAME_LOG), + openSessionLog: (): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.OPEN_SESSION_LOG), + openTraceLog: (): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.OPEN_TRACE_LOG), + openPackageLog: (packageId: string): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.OPEN_PACKAGE_LOG, packageId), + openItemLog: (itemId: string): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.OPEN_ITEM_LOG, itemId), + getDebugSetupCheck: () => ipcRenderer.invoke(IPC_CHANNELS.GET_DEBUG_SETUP_CHECK), + getRecentErrors: () => ipcRenderer.invoke(IPC_CHANNELS.GET_RECENT_ERRORS), + testNotification: (url: string, mention: string) => ipcRenderer.invoke(IPC_CHANNELS.TEST_NOTIFY, url, mention), + getTraceConfig: () => ipcRenderer.invoke(IPC_CHANNELS.GET_TRACE_CONFIG), + setTraceEnabled: (enabled: boolean, note?: string, durationMinutes?: number) => ipcRenderer.invoke(IPC_CHANNELS.SET_TRACE_ENABLED, enabled, note, durationMinutes), + rotateDebugToken: (): Promise<{ path: string }> => ipcRenderer.invoke(IPC_CHANNELS.ROTATE_DEBUG_TOKEN), + getRemoteDiagnostics: (): Promise<RemoteDiagnosticsInfo> => ipcRenderer.invoke(IPC_CHANNELS.GET_REMOTE_DIAGNOSTICS), + enableRemoteDiagnostics: (input: EnableRemoteDiagnosticsInput): Promise<RemoteDiagnosticsInfo> => ipcRenderer.invoke(IPC_CHANNELS.ENABLE_REMOTE_DIAGNOSTICS, input), + disableRemoteDiagnostics: (): Promise<RemoteDiagnosticsInfo> => ipcRenderer.invoke(IPC_CHANNELS.DISABLE_REMOTE_DIAGNOSTICS), + rotateRemoteDiagnosticsToken: (): Promise<RemoteDiagnosticsInfo> => ipcRenderer.invoke(IPC_CHANNELS.ROTATE_REMOTE_DIAGNOSTICS_TOKEN), + openRealDebridLogin: (): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.OPEN_REALDEBRID_LOGIN), + openAllDebridLogin: (): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.OPEN_ALLDEBRID_LOGIN), + importBestDebridCookies: (): Promise<number> => ipcRenderer.invoke(IPC_CHANNELS.IMPORT_BESTDEBRID_COOKIES), + getAllDebridHostInfo: (): Promise<AllDebridHostInfo> => ipcRenderer.invoke(IPC_CHANNELS.GET_ALLDEBRID_HOST_INFO), + getDebridLinkHostLimits: (): Promise<DebridLinkHostLimitInfo[]> => ipcRenderer.invoke(IPC_CHANNELS.GET_DEBRIDLINK_HOST_LIMITS), + checkDebridAccounts: (): Promise<DebridAccountStatus[]> => ipcRenderer.invoke(IPC_CHANNELS.CHECK_DEBRID_ACCOUNTS), + checkMegaDebridAccount: (login: string, password: string): Promise<DebridAccountStatus | null> => ipcRenderer.invoke(IPC_CHANNELS.CHECK_MEGA_DEBRID_ACCOUNT, login, password), + retryExtraction: (packageId: string): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.RETRY_EXTRACTION, packageId), + extractNow: (packageId: string): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.EXTRACT_NOW, packageId), + resetPackage: (packageId: string): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.RESET_PACKAGE, packageId), + getHistory: (): Promise<HistoryEntry[]> => ipcRenderer.invoke(IPC_CHANNELS.GET_HISTORY), + clearHistory: (): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.CLEAR_HISTORY), + removeHistoryEntry: (entryId: string): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.REMOVE_HISTORY_ENTRY, entryId), + setPackagePriority: (packageId: string, priority: PackagePriority): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.SET_PACKAGE_PRIORITY, packageId, priority), + skipItems: (itemIds: string[]): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.SKIP_ITEMS, itemIds), + resetItems: (itemIds: string[]): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.RESET_ITEMS, itemIds), + startItems: (itemIds: string[]): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.START_ITEMS, itemIds), + reportRendererError: (report: RendererErrorReport): void => ipcRenderer.send(IPC_CHANNELS.LOG_RENDERER_ERROR, report), + onStateUpdate: (callback: (snapshot: UiSnapshot) => void): (() => void) => { + const listener = (_event: unknown, snapshot: UiSnapshot): void => callback(snapshot); + ipcRenderer.on(IPC_CHANNELS.STATE_UPDATE, listener); + return () => { + ipcRenderer.removeListener(IPC_CHANNELS.STATE_UPDATE, listener); + }; + }, + onClipboardDetected: (callback: (links: string[]) => void): (() => void) => { + const listener = (_event: unknown, links: string[]): void => callback(links); + ipcRenderer.on(IPC_CHANNELS.CLIPBOARD_DETECTED, listener); + return () => { + ipcRenderer.removeListener(IPC_CHANNELS.CLIPBOARD_DETECTED, listener); + }; + }, + onUpdateInstallProgress: (callback: (progress: UpdateInstallProgress) => void): (() => void) => { + const listener = (_event: unknown, progress: UpdateInstallProgress): void => callback(progress); + ipcRenderer.on(IPC_CHANNELS.UPDATE_INSTALL_PROGRESS, listener); + return () => { + ipcRenderer.removeListener(IPC_CHANNELS.UPDATE_INSTALL_PROGRESS, listener); + }; + } +}; + +contextBridge.exposeInMainWorld("rd", api); diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx new file mode 100644 index 0000000..a593916 --- /dev/null +++ b/src/renderer/App.tsx @@ -0,0 +1,7325 @@ +import { CSSProperties, DragEvent, KeyboardEvent as ReactKeyboardEvent, ReactElement, memo, useCallback, useDeferredValue, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; +import { parseDebridLinkApiKeys } from "../shared/debrid-link-keys"; +import { getMegaDebridAccountId, parseMegaDebridAccounts, serializeMegaDebridAccounts, maskMegaDebridLogin } from "../shared/mega-debrid-accounts"; +import type { + AllDebridHostInfo, + AppSettings, + AppTheme, + AudioStripSummary, + BandwidthScheduleEntry, + DebugSetupCheckResult, + DebridFallbackProvider, + DebridLinkHostLimitInfo, + DebridProvider, + DownloadItem, + DownloadStats, + DuplicatePolicy, + HistoryEntry, + PackageEntry, + RemoteDiagnosticsInfo, + StartConflictEntry, + UiSnapshot, + UpdateCheckResult, + UpdateInstallProgress +} from "../shared/types"; +import { + getDebridLinkApiKeyTotalUsageBytes, + getDebridLinkApiKeyDailyLimitBytes, + getDebridLinkApiKeyDailyRemainingBytes, + getDebridLinkApiKeyDailyUsageBytes, + getMegaDebridAccountDailyLimitBytes, + getMegaDebridAccountDailyUsageBytes, + getMegaDebridAccountTotalUsageBytes, + getProviderDailyLimitBytes, + getProviderDailyRemainingBytes, + getProviderTotalUsageBytes, + getProviderDailyUsageBytes, + getProviderUsageDayKey +} from "../shared/provider-daily-limits"; +import { reorderPackageOrderByDrop, sortPackageOrderByName, sortPackagesForDisplay } from "./package-order"; +import { pruneSelection } from "./selection"; + +type Tab = "collector" | "downloads" | "history" | "statistics" | "settings"; +type SettingsSubTab = "allgemein" | "accounts" | "entpacken" | "geschwindigkeit" | "bereinigung" | "updates"; + +interface CollectorTab { + id: string; + name: string; + text: string; +} + +interface StartConflictPromptState { + entry: StartConflictEntry; + applyToAll: boolean; +} + +interface ConfirmPromptState { + title: string; + message: string; + confirmLabel: string; + cancelLabel?: string; + danger?: boolean; + details?: string; + detailsLabel?: string; +} + +interface ContextMenuState { + x: number; + y: number; + packageId: string; + itemId?: string; +} + +interface LinkPopupState { + title: string; + links: { name: string; url: string }[]; + isPackage: boolean; +} + +type AccountService = "realdebrid" | "megadebrid-api" | "megadebrid-web" | "bestdebrid" | "alldebrid" | "ddownload" | "onefichier" | "debridlink" | "linksnappy"; +type AccountKind = + | "realdebrid-api" + | "realdebrid-web" + | "megadebrid-api" + | "megadebrid-web" + | "bestdebrid-api" + | "bestdebrid-web" + | "alldebrid-api" + | "alldebrid-web" + | "ddownload-login" + | "onefichier-api" + | "debridlink-api" + | "linksnappy-login"; + +type AccountQuickAction = "realdebrid-login" | "bestdebrid-cookies" | "alldebrid-login" | "alldebrid-status"; +type AccountColumnKey = "service" | "mode" | "status" | "secret"; + +interface AccountOption { + kind: AccountKind; + service: AccountService; + serviceLabel: string; + title: string; + modeLabel: string; + pickerDescription: string; + needsToken?: boolean; + needsCredentials?: boolean; +} + +interface MegaDialogAccount { + login: string; + password: string; +} + +export interface AccountDialogState { + mode: "create" | "edit"; + kind: AccountKind | null; + token: string; + login: string; + password: string; + dailyLimitGb: string; + keyDailyLimitGbById: Record<string, string>; + megaAccounts: MegaDialogAccount[]; + megaNewLogin: string; + megaNewPassword: string; + megaDisabledIds: string[]; +} + +interface DebridLinkAccountKeyEntry { + id: string; + label: string; + token: string; + masked: string; + disabled: boolean; + dailyUsedBytes: number; + totalUsedBytes: number; + dailyLimitBytes: number; + dailyRemainingBytes: number | null; + dailyLimitReached: boolean; +} + +interface ConfiguredAccountEntry { + kind: AccountKind; + service: AccountService; + provider: DebridProvider; + serviceLabel: string; + modeLabel: string; + statusLabel: string; + summary: string; + summaryLines: string[]; + note: string; + disabled: boolean; + dailyUsedBytes: number; + totalUsedBytes: number; + dailyLimitBytes: number; + dailyRemainingBytes: number | null; + dailyLimitReached: boolean; + debridLinkKeys: DebridLinkAccountKeyEntry[]; +} + +function buildDebugSetupDetails(setup: DebugSetupCheckResult): string { + const formatDiskLine = (label: string, value: DebugSetupCheckResult["diskSpace"]["runtime"]): string => { + if (value.freeBytes === null || value.totalBytes === null) { + return `${label}: unbekannt (${value.path})`; + } + return `${label}: ${humanSize(value.freeBytes)} frei von ${humanSize(value.totalBytes)} (${value.freePercent ?? "?"}% frei) | ${value.path}`; + }; + + const formatFileLine = (label: string, bytes: number): string => `${label}: ${humanSize(bytes)}`; + const lines: string[] = [ + `Status: ${setup.status === "ok" ? "OK" : "Warnung"}`, + `Debug-Server aktiv: ${setup.enabled ? "ja" : "nein"}`, + `Runtime-Ordner: ${setup.runtimeBaseDir}`, + `Host: ${setup.host}`, + `Port: ${setup.port}`, + `Token-Datei: ${setup.tokenPath}`, + `Support-Manifest: ${setup.supportManifestPresent ? "vorhanden" : "fehlt"} (${setup.supportManifestPath})`, + `Trace aktiv: ${setup.traceEnabled ? "ja" : "nein"}`, + `Trace-Auto-Ende: ${setup.traceAutoDisableAt || "nicht gesetzt"}`, + "", + "Freier Speicherplatz:", + formatDiskLine("Runtime", setup.diskSpace.runtime), + formatDiskLine("Download-Ziel", setup.diskSpace.output), + formatDiskLine("Entpack-Ziel", setup.diskSpace.extract), + "", + "Support-Logs:", + formatFileLine("Gesamt", setup.logSummary.totalBytes), + formatFileLine("Hauptlog", setup.logSummary.main.bytes + setup.logSummary.mainBackup.bytes), + formatFileLine("Audit", setup.logSummary.audit.bytes + setup.logSummary.auditBackup.bytes), + formatFileLine("Rename", setup.logSummary.rename.bytes + setup.logSummary.renameBackup.bytes), + formatFileLine("Trace", setup.logSummary.trace.bytes + setup.logSummary.traceBackup.bytes), + `${formatFileLine("Session-Logs", setup.logSummary.session.bytes + setup.logSummary.sessionLogs.bytes)} | Dateien: ${setup.logSummary.sessionLogs.fileCount}`, + `${formatFileLine("Paket-Logs", setup.logSummary.packageLogs.bytes)} | Dateien: ${setup.logSummary.packageLogs.fileCount}`, + `${formatFileLine("Item-Logs", setup.logSummary.itemLogs.bytes)} | Dateien: ${setup.logSummary.itemLogs.fileCount}`, + "", + "Support-Bundle:", + `${formatFileLine("Schätzwert", setup.supportBundle.estimatedBytes)} | Einträge: ${setup.supportBundle.estimatedEntries}`, + formatFileLine("Doppelte Live-Log-Spiegelung", setup.supportBundle.duplicatedLiveLogBytes), + setup.supportBundle.note, + "", + "Lokale URLs:", + setup.localUrls.health, + setup.localUrls.meta, + setup.localUrls.diagnostics, + "", + "Remote-Vorlagen:", + setup.remoteUrlTemplates.health, + setup.remoteUrlTemplates.meta, + setup.remoteUrlTemplates.diagnostics + ]; + + if (setup.warnings.length > 0) { + lines.push("", "Warnungen:"); + for (const warning of setup.warnings) { + lines.push(`- ${warning}`); + } + } + + if (setup.notes.length > 0) { + lines.push("", "Hinweise:"); + for (const note of setup.notes) { + lines.push(`- ${note}`); + } + } + + return lines.join("\n"); +} + +const ACCOUNT_OPTIONS: AccountOption[] = [ + { + kind: "realdebrid-api", + service: "realdebrid", + serviceLabel: "Real-Debrid", + title: "Real-Debrid API", + modeLabel: "API", + pickerDescription: "Direkter Zugriff über API-Token.", + needsToken: true + }, + { + kind: "realdebrid-web", + service: "realdebrid", + serviceLabel: "Real-Debrid", + title: "Real-Debrid Web", + modeLabel: "Web", + pickerDescription: "Login über Browserfenster statt Token." + }, + { + kind: "megadebrid-api", + service: "megadebrid-api", + serviceLabel: "Mega-Debrid", + title: "Mega-Debrid API", + modeLabel: "API", + pickerDescription: "Login:Passwort-Paare für Mega-Debrid (API). Mehrere Accounts zeilenweise für Multi-Account.", + needsToken: true + }, + { + kind: "megadebrid-web", + service: "megadebrid-web", + serviceLabel: "Mega-Debrid", + title: "Mega-Debrid Web", + modeLabel: "Web", + pickerDescription: "Login:Passwort-Paare für Mega-Debrid (Web). Mehrere Accounts zeilenweise für Multi-Account.", + needsToken: true + }, + { + kind: "bestdebrid-api", + service: "bestdebrid", + serviceLabel: "BestDebrid", + title: "BestDebrid API", + modeLabel: "API", + pickerDescription: "Direkter Zugriff über API-Token.", + needsToken: true + }, + { + kind: "bestdebrid-web", + service: "bestdebrid", + serviceLabel: "BestDebrid", + title: "BestDebrid Web", + modeLabel: "Web", + pickerDescription: "Cookie-Import aus dem Browser statt API-Token." + }, + { + kind: "alldebrid-api", + service: "alldebrid", + serviceLabel: "AllDebrid", + title: "AllDebrid API", + modeLabel: "API", + pickerDescription: "Direkter Zugriff über API-Key.", + needsToken: true + }, + { + kind: "alldebrid-web", + service: "alldebrid", + serviceLabel: "AllDebrid", + title: "AllDebrid Web", + modeLabel: "Web", + pickerDescription: "Login über Browserfenster für reCAPTCHA.", + }, + { + kind: "ddownload-login", + service: "ddownload", + serviceLabel: "DDownload", + title: "DDownload Login", + modeLabel: "Login", + pickerDescription: "Direkter Login für ddownload.com und ddl.to.", + needsCredentials: true + }, + { + kind: "onefichier-api", + service: "onefichier", + serviceLabel: "1Fichier", + title: "1Fichier API", + modeLabel: "API", + pickerDescription: "API-Key für 1fichier.com.", + needsToken: true + }, + { + kind: "debridlink-api", + service: "debridlink", + serviceLabel: "Debrid-Link", + title: "Debrid-Link API", + modeLabel: "API", + pickerDescription: "API-Key(s) für debrid-link.com. Mehrere Keys zeilenweise für Multi-Account.", + needsToken: true + }, + { + kind: "linksnappy-login", + service: "linksnappy", + serviceLabel: "LinkSnappy", + title: "LinkSnappy Web", + modeLabel: "Web", + pickerDescription: "Login für linksnappy.com mit Benutzername und Passwort.", + needsCredentials: true + } +]; + +const ACCOUNT_SERVICES: AccountService[] = ["realdebrid", "megadebrid-api", "megadebrid-web", "bestdebrid", "alldebrid", "ddownload", "onefichier", "debridlink", "linksnappy"]; +const ACCOUNT_LIMIT_BYTES_PER_GIB = 1024 * 1024 * 1024; +const ACCOUNT_COLUMN_STORAGE_KEY = "rd-account-column-widths-v2"; +const ACCOUNT_COLUMN_DEFAULT_WIDTHS: Record<AccountColumnKey, number> = { + service: 240, + mode: 96, + status: 320, + secret: 210 +}; +const ACCOUNT_COLUMN_MIN_WIDTHS: Record<AccountColumnKey, number> = { + service: 180, + mode: 80, + status: 180, + secret: 140 +}; + +function loadAccountColumnWidths(): Record<AccountColumnKey, number> { + if (typeof window === "undefined") { + return { ...ACCOUNT_COLUMN_DEFAULT_WIDTHS }; + } + try { + const raw = window.localStorage.getItem(ACCOUNT_COLUMN_STORAGE_KEY); + if (!raw) { + return { ...ACCOUNT_COLUMN_DEFAULT_WIDTHS }; + } + const parsed = JSON.parse(raw) as Partial<Record<AccountColumnKey, unknown>>; + return { + service: Math.max(ACCOUNT_COLUMN_MIN_WIDTHS.service, Number(parsed.service) || ACCOUNT_COLUMN_DEFAULT_WIDTHS.service), + mode: Math.max(ACCOUNT_COLUMN_MIN_WIDTHS.mode, Number(parsed.mode) || ACCOUNT_COLUMN_DEFAULT_WIDTHS.mode), + status: Math.max(ACCOUNT_COLUMN_MIN_WIDTHS.status, Number(parsed.status) || ACCOUNT_COLUMN_DEFAULT_WIDTHS.status), + secret: Math.max(ACCOUNT_COLUMN_MIN_WIDTHS.secret, Number(parsed.secret) || ACCOUNT_COLUMN_DEFAULT_WIDTHS.secret) + }; + } catch { + return { ...ACCOUNT_COLUMN_DEFAULT_WIDTHS }; + } +} + +function findAccountOption(kind: AccountKind): AccountOption { + const option = ACCOUNT_OPTIONS.find((entry) => entry.kind === kind); + if (!option) { + throw new Error(`Unbekannter Account-Typ: ${kind}`); + } + return option; +} + +function getAccountServiceProvider(service: AccountService): DebridProvider { + return service as DebridProvider; +} + +function formatAccountDailyLimitInput(limitBytes: number): string { + if (limitBytes <= 0) { + return ""; + } + const gib = limitBytes / ACCOUNT_LIMIT_BYTES_PER_GIB; + const precision = gib >= 100 ? 0 : gib >= 10 ? 1 : 2; + return gib.toFixed(precision).replace(/\.0+$/, "").replace(/(\.\d*?)0+$/, "$1"); +} + +function parseAccountDailyLimitInputBytes(value: string): number | null { + const normalized = value.trim().replace(",", "."); + if (!normalized) { + return null; + } + const parsed = Number(normalized); + if (!Number.isFinite(parsed) || parsed <= 0) { + return null; + } + return Math.floor(parsed * ACCOUNT_LIMIT_BYTES_PER_GIB); +} + +function buildDebridLinkKeyLimitInputs(rawKeys: string, values?: Record<string, string>, settings?: AppSettings): Record<string, string> { + const next: Record<string, string> = {}; + for (const key of parseDebridLinkApiKeys(rawKeys)) { + next[key.id] = values?.[key.id] + ?? formatAccountDailyLimitInput(settings?.debridLinkApiKeyDailyLimitBytes?.[key.id] || 0); + } + return next; +} + +function getAccountPickerFunctionLabel(option: AccountOption): string { + switch (option.kind) { + case "realdebrid-api": + case "bestdebrid-api": + return "API-Token"; + case "realdebrid-web": + case "alldebrid-web": + return "Browser-Login"; + case "megadebrid-api": + return "Login:Passwort (API)"; + case "megadebrid-web": + return "Login:Passwort (Web)"; + case "bestdebrid-web": + return "Cookies.txt-Import"; + case "alldebrid-api": + case "onefichier-api": + return "API-Key"; + case "ddownload-login": + return "Login + Passwort"; + default: + return option.modeLabel; + } +} + +function hasMegaDebridCredentials(settings: AppSettings): boolean { + if (parseMegaDebridAccounts(settings.megaCredentials || "").length > 0) return true; + return Boolean(settings.megaLogin.trim() && settings.megaPassword.trim()); +} + +function getConfiguredProvidersFromSettings(settings: AppSettings): DebridProvider[] { + const list: DebridProvider[] = []; + if (settings.token.trim() || settings.realDebridUseWebLogin) { + list.push("realdebrid"); + } + if (hasMegaDebridCredentials(settings) && settings.megaDebridApiEnabled) { + list.push("megadebrid-api"); + } + if (hasMegaDebridCredentials(settings) && settings.megaDebridWebEnabled) { + list.push("megadebrid-web"); + } + if (settings.bestDebridUseWebLogin || settings.bestToken.trim()) { + list.push("bestdebrid"); + } + if (settings.allDebridUseWebLogin || settings.allDebridToken.trim()) { + list.push("alldebrid"); + } + if ((settings.debridLinkApiKeys || "").trim()) { + list.push("debridlink"); + } + if ((settings.linkSnappyLogin || "").trim() && (settings.linkSnappyPassword || "").trim()) { + list.push("linksnappy"); + } + return list; +} + +function getActiveProvidersFromSettings(settings: AppSettings): DebridProvider[] { + const disabled = new Set(settings.disabledProviders || []); + return getConfiguredProvidersFromSettings(settings).filter((p) => !disabled.has(p)); +} + +const DIRECT_HOSTERS: ReadonlySet<DebridProvider> = new Set(["onefichier", "ddownload"]); + +function normalizeProviderOrderForSettings(settings: AppSettings): DebridProvider[] { + const active = new Set(getActiveProvidersFromSettings(settings).filter((p) => !DIRECT_HOSTERS.has(p))); + const ordered = (settings.providerOrder || []).filter((p) => active.has(p)); + const inOrder = new Set(ordered); + for (const p of active) { + if (!inOrder.has(p)) ordered.push(p); + } + return ordered; +} + +function normalizeProviderSelectionForSettings( + settings: AppSettings +): Pick<AppSettings, "providerOrder" | "providerPrimary" | "providerSecondary" | "providerTertiary"> { + const providerOrder = normalizeProviderOrderForSettings(settings); + return { + providerOrder, + providerPrimary: providerOrder[0] ?? settings.providerPrimary, + providerSecondary: (providerOrder[1] ?? "none") as DebridFallbackProvider, + providerTertiary: (providerOrder[2] ?? "none") as DebridFallbackProvider + }; +} + +function getConfiguredAccountKind(settings: AppSettings, service: AccountService): AccountKind | null { + switch (service) { + case "realdebrid": + if (settings.realDebridUseWebLogin) return "realdebrid-web"; + return settings.token.trim() ? "realdebrid-api" : null; + case "megadebrid-api": + return hasMegaDebridCredentials(settings) && settings.megaDebridApiEnabled ? "megadebrid-api" : null; + case "megadebrid-web": + return hasMegaDebridCredentials(settings) && settings.megaDebridWebEnabled ? "megadebrid-web" : null; + case "bestdebrid": + if (settings.bestDebridUseWebLogin) return "bestdebrid-web"; + return settings.bestToken.trim() ? "bestdebrid-api" : null; + case "alldebrid": + if (settings.allDebridUseWebLogin) return "alldebrid-web"; + return settings.allDebridToken.trim() ? "alldebrid-api" : null; + case "ddownload": + return settings.ddownloadLogin.trim() && settings.ddownloadPassword.trim() ? "ddownload-login" : null; + case "onefichier": + return settings.oneFichierApiKey.trim() ? "onefichier-api" : null; + case "debridlink": + return (settings.debridLinkApiKeys || "").trim() ? "debridlink-api" : null; + case "linksnappy": + return (settings.linkSnappyLogin || "").trim() && (settings.linkSnappyPassword || "").trim() ? "linksnappy-login" : null; + default: + return null; + } +} + +function maskValue(value: string, keepStart = 2, keepEnd = 2): string { + const trimmed = value.trim(); + if (!trimmed) return "Nicht hinterlegt"; + if (trimmed.length <= keepStart + keepEnd) { + return "*".repeat(trimmed.length); + } + return `${trimmed.slice(0, keepStart)}${"*".repeat(Math.max(4, trimmed.length - keepStart - keepEnd))}${trimmed.slice(-keepEnd)}`; +} + +function summarizeAccount(kind: AccountKind, settings: AppSettings): string { + switch (kind) { + case "realdebrid-api": + return maskValue(settings.token, 3, 3); + case "realdebrid-web": + return "Browser-Login"; + case "megadebrid-api": + case "megadebrid-web": { + const megaAccounts = parseMegaDebridAccounts(settings.megaCredentials || "", settings.megaPassword); + if (megaAccounts.length > 1) return `${megaAccounts.length} Accounts`; + if (megaAccounts.length === 1) return megaAccounts[0].maskedLogin; + return settings.megaLogin.trim() ? maskValue(settings.megaLogin.trim(), 2, 6) : "Nicht hinterlegt"; + } + case "bestdebrid-api": + return maskValue(settings.bestToken, 3, 3); + case "bestdebrid-web": + return "Cookie-Import"; + case "alldebrid-api": + return maskValue(settings.allDebridToken, 3, 3); + case "alldebrid-web": + return "Browser-Login"; + case "ddownload-login": + return settings.ddownloadLogin.trim() ? maskValue(settings.ddownloadLogin.trim(), 2, 6) : "Login + Passwort"; + case "onefichier-api": + return maskValue(settings.oneFichierApiKey, 3, 3); + case "debridlink-api": { + const keys = (settings.debridLinkApiKeys || "").split(/[\n,]+/).filter((k: string) => k.trim()); + if (keys.length > 1) return `${keys.length} API-Keys`; + return keys.length === 1 ? maskValue(keys[0].trim(), 3, 3) : "Nicht hinterlegt"; + } + case "linksnappy-login": + return (settings.linkSnappyLogin || "").trim() ? maskValue((settings.linkSnappyLogin || "").trim(), 2, 4) : "Login + Passwort"; + default: + return "Konfiguriert"; + } +} + +function summarizeAccountLines(kind: AccountKind, settings: AppSettings): string[] { + if (kind === "debridlink-api" && settings.accountListShowDetailedDebridLinkKeys) { + const keys = parseDebridLinkApiKeys(settings.debridLinkApiKeys || ""); + if (keys.length > 1) { + return keys.map((entry) => `${entry.label}: ${entry.masked}`); + } + } + if (kind === "megadebrid-api" || kind === "megadebrid-web") { + const accounts = parseMegaDebridAccounts(settings.megaCredentials || "", settings.megaPassword); + if (accounts.length > 1) { + return accounts.map((entry) => `${entry.label}: ${entry.maskedLogin}`); + } + } + return [summarizeAccount(kind, settings)]; +} + +function createAccountDialogState(mode: "create" | "edit", kind: AccountKind | null, settings: AppSettings): AccountDialogState { + const baseMega: Pick<AccountDialogState, "megaAccounts" | "megaNewLogin" | "megaNewPassword" | "megaDisabledIds"> = { megaAccounts: [], megaNewLogin: "", megaNewPassword: "", megaDisabledIds: [] }; + if (!kind) { + return { + mode, + kind: null, + token: "", + login: "", + password: "", + dailyLimitGb: "", + keyDailyLimitGbById: {}, + ...baseMega + }; + } + const provider = getAccountServiceProvider(findAccountOption(kind).service); + const dailyLimitGb = formatAccountDailyLimitInput(getProviderDailyLimitBytes(settings, provider)); + switch (kind) { + case "realdebrid-api": + return { mode, kind, token: settings.token, login: "", password: "", dailyLimitGb, keyDailyLimitGbById: {}, ...baseMega }; + case "realdebrid-web": + return { mode, kind, token: "", login: "", password: "", dailyLimitGb, keyDailyLimitGbById: {}, ...baseMega }; + case "megadebrid-api": + case "megadebrid-web": { + let megaToken = (settings.megaCredentials || "").trim(); + if (!megaToken && settings.megaLogin.trim() && settings.megaPassword.trim()) { + megaToken = `${settings.megaLogin.trim()}:${settings.megaPassword.trim()}`; + } + const parsed = parseMegaDebridAccounts(megaToken); + const megaAccounts = parsed.map((a) => ({ login: a.login, password: a.password })); + const loadedIds = new Set(parsed.map((a) => a.id)); + const megaDisabledIds = (settings.megaDebridDisabledAccountIds || []).filter((id) => loadedIds.has(id)); + return { mode, kind, token: megaToken, login: "", password: "", dailyLimitGb, keyDailyLimitGbById: {}, megaAccounts, megaNewLogin: "", megaNewPassword: "", megaDisabledIds }; + } + case "bestdebrid-api": + return { mode, kind, token: settings.bestToken, login: "", password: "", dailyLimitGb, keyDailyLimitGbById: {}, ...baseMega }; + case "bestdebrid-web": + return { mode, kind, token: "", login: "", password: "", dailyLimitGb, keyDailyLimitGbById: {}, ...baseMega }; + case "alldebrid-api": + return { mode, kind, token: settings.allDebridToken, login: "", password: "", dailyLimitGb, keyDailyLimitGbById: {}, ...baseMega }; + case "alldebrid-web": + return { mode, kind, token: "", login: "", password: "", dailyLimitGb, keyDailyLimitGbById: {}, ...baseMega }; + case "ddownload-login": + return { mode, kind, token: "", login: settings.ddownloadLogin, password: settings.ddownloadPassword, dailyLimitGb, keyDailyLimitGbById: {}, ...baseMega }; + case "onefichier-api": + return { mode, kind, token: settings.oneFichierApiKey, login: "", password: "", dailyLimitGb, keyDailyLimitGbById: {}, ...baseMega }; + case "debridlink-api": + return { + mode, + kind, + token: settings.debridLinkApiKeys || "", + login: "", + password: "", + dailyLimitGb, + keyDailyLimitGbById: buildDebridLinkKeyLimitInputs(settings.debridLinkApiKeys || "", undefined, settings), + ...baseMega + }; + case "linksnappy-login": + return { mode, kind, token: "", login: settings.linkSnappyLogin || "", password: settings.linkSnappyPassword || "", dailyLimitGb, keyDailyLimitGbById: {}, ...baseMega }; + default: + return { mode, kind, token: "", login: "", password: "", dailyLimitGb, keyDailyLimitGbById: {}, ...baseMega }; + } +} + +export function applyAccountDialogToSettings(settings: AppSettings, dialog: AccountDialogState): AppSettings { + if (!dialog.kind) { + return settings; + } + const token = dialog.token.trim(); + const login = dialog.login.trim(); + const password = dialog.password; + const provider = getAccountServiceProvider(findAccountOption(dialog.kind).service); + const nextProviderDailyLimitBytes = { ...(settings.providerDailyLimitBytes || {}) }; + const nextDebridLinkApiKeyDailyLimitBytes = dialog.kind === "debridlink-api" + ? Object.fromEntries( + parseDebridLinkApiKeys(dialog.token).flatMap((entry) => { + const limitBytes = parseAccountDailyLimitInputBytes(dialog.keyDailyLimitGbById?.[entry.id] || ""); + return limitBytes && limitBytes > 0 ? [[entry.id, limitBytes]] : []; + }) + ) as Record<string, number> + : { ...(settings.debridLinkApiKeyDailyLimitBytes || {}) }; + const dailyLimitBytes = parseAccountDailyLimitInputBytes(dialog.dailyLimitGb); + if (dailyLimitBytes && dailyLimitBytes > 0) { + nextProviderDailyLimitBytes[provider] = dailyLimitBytes; + } else { + delete nextProviderDailyLimitBytes[provider]; + } + switch (dialog.kind) { + case "realdebrid-api": + return { ...settings, token, realDebridUseWebLogin: false, providerDailyLimitBytes: nextProviderDailyLimitBytes }; + case "realdebrid-web": + return { ...settings, token: "", realDebridUseWebLogin: true, providerDailyLimitBytes: nextProviderDailyLimitBytes }; + case "megadebrid-api": { + const megaSerialized = serializeMegaDebridAccounts(dialog.megaAccounts); + const megaParsed = parseMegaDebridAccounts(megaSerialized); + const firstLogin = megaParsed.length > 0 ? megaParsed[0].login : ""; + const firstPassword = megaParsed.length > 0 ? megaParsed[0].password : ""; + const validIds = new Set(megaParsed.map((a) => a.id)); + const megaDebridDisabledAccountIds = (dialog.megaDisabledIds || []).filter((id) => validIds.has(id)); + return { ...settings, megaCredentials: megaSerialized, megaLogin: firstLogin, megaPassword: firstPassword, megaDebridApiEnabled: true, megaDebridDisabledAccountIds, providerDailyLimitBytes: nextProviderDailyLimitBytes }; + } + case "megadebrid-web": { + const megaSerialized = serializeMegaDebridAccounts(dialog.megaAccounts); + const megaParsed = parseMegaDebridAccounts(megaSerialized); + const firstLogin = megaParsed.length > 0 ? megaParsed[0].login : ""; + const firstPassword = megaParsed.length > 0 ? megaParsed[0].password : ""; + const validIds = new Set(megaParsed.map((a) => a.id)); + const megaDebridDisabledAccountIds = (dialog.megaDisabledIds || []).filter((id) => validIds.has(id)); + return { ...settings, megaCredentials: megaSerialized, megaLogin: firstLogin, megaPassword: firstPassword, megaDebridWebEnabled: true, megaDebridDisabledAccountIds, providerDailyLimitBytes: nextProviderDailyLimitBytes }; + } + case "bestdebrid-api": + return { ...settings, bestToken: token, bestDebridUseWebLogin: false, providerDailyLimitBytes: nextProviderDailyLimitBytes }; + case "bestdebrid-web": + return { ...settings, bestToken: "", bestDebridUseWebLogin: true, providerDailyLimitBytes: nextProviderDailyLimitBytes }; + case "alldebrid-api": + return { ...settings, allDebridToken: token, allDebridUseWebLogin: false, providerDailyLimitBytes: nextProviderDailyLimitBytes }; + case "alldebrid-web": + return { ...settings, allDebridToken: "", allDebridUseWebLogin: true, providerDailyLimitBytes: nextProviderDailyLimitBytes }; + case "ddownload-login": + return { ...settings, ddownloadLogin: login, ddownloadPassword: password, providerDailyLimitBytes: nextProviderDailyLimitBytes }; + case "onefichier-api": + return { ...settings, oneFichierApiKey: token, providerDailyLimitBytes: nextProviderDailyLimitBytes }; + case "debridlink-api": + return { + ...settings, + debridLinkApiKeys: token, + providerDailyLimitBytes: nextProviderDailyLimitBytes, + debridLinkApiKeyDailyLimitBytes: nextDebridLinkApiKeyDailyLimitBytes + }; + case "linksnappy-login": + return { ...settings, linkSnappyLogin: login, linkSnappyPassword: password, providerDailyLimitBytes: nextProviderDailyLimitBytes }; + default: + return settings; + } +} + +function clearAccountServiceFromSettings(settings: AppSettings, service: AccountService): AppSettings { + const provider = getAccountServiceProvider(service); + const nextProviderDailyLimitBytes = { ...(settings.providerDailyLimitBytes || {}) }; + const nextProviderDailyUsageBytes = { ...(settings.providerDailyUsageBytes || {}) }; + const nextDebridLinkApiKeyDailyLimitBytes = { ...(settings.debridLinkApiKeyDailyLimitBytes || {}) }; + const nextDebridLinkApiKeyDailyUsageBytes = { ...(settings.debridLinkApiKeyDailyUsageBytes || {}) }; + delete nextProviderDailyLimitBytes[provider]; + delete nextProviderDailyUsageBytes[provider]; + if (service === "debridlink") { + for (const key of parseDebridLinkApiKeys(settings.debridLinkApiKeys || "")) { + delete nextDebridLinkApiKeyDailyLimitBytes[key.id]; + delete nextDebridLinkApiKeyDailyUsageBytes[key.id]; + } + } + switch (service) { + case "realdebrid": + return { ...settings, token: "", realDebridUseWebLogin: false, providerDailyLimitBytes: nextProviderDailyLimitBytes, providerDailyUsageBytes: nextProviderDailyUsageBytes }; + case "megadebrid-api": + return settings.megaDebridWebEnabled + ? { ...settings, megaDebridApiEnabled: false, providerDailyLimitBytes: nextProviderDailyLimitBytes, providerDailyUsageBytes: nextProviderDailyUsageBytes } + : { ...settings, megaCredentials: "", megaLogin: "", megaPassword: "", megaDebridApiEnabled: false, providerDailyLimitBytes: nextProviderDailyLimitBytes, providerDailyUsageBytes: nextProviderDailyUsageBytes }; + case "megadebrid-web": + return settings.megaDebridApiEnabled + ? { ...settings, megaDebridWebEnabled: false, providerDailyLimitBytes: nextProviderDailyLimitBytes, providerDailyUsageBytes: nextProviderDailyUsageBytes } + : { ...settings, megaCredentials: "", megaLogin: "", megaPassword: "", megaDebridWebEnabled: false, providerDailyLimitBytes: nextProviderDailyLimitBytes, providerDailyUsageBytes: nextProviderDailyUsageBytes }; + case "bestdebrid": + return { ...settings, bestToken: "", bestDebridUseWebLogin: false, providerDailyLimitBytes: nextProviderDailyLimitBytes, providerDailyUsageBytes: nextProviderDailyUsageBytes }; + case "alldebrid": + return { ...settings, allDebridToken: "", allDebridUseWebLogin: false, providerDailyLimitBytes: nextProviderDailyLimitBytes, providerDailyUsageBytes: nextProviderDailyUsageBytes }; + case "ddownload": + return { ...settings, ddownloadLogin: "", ddownloadPassword: "", providerDailyLimitBytes: nextProviderDailyLimitBytes, providerDailyUsageBytes: nextProviderDailyUsageBytes }; + case "onefichier": + return { ...settings, oneFichierApiKey: "", providerDailyLimitBytes: nextProviderDailyLimitBytes, providerDailyUsageBytes: nextProviderDailyUsageBytes }; + case "debridlink": + return { + ...settings, + debridLinkApiKeys: "", + providerDailyLimitBytes: nextProviderDailyLimitBytes, + providerDailyUsageBytes: nextProviderDailyUsageBytes, + debridLinkApiKeyDailyLimitBytes: nextDebridLinkApiKeyDailyLimitBytes, + debridLinkApiKeyDailyUsageBytes: nextDebridLinkApiKeyDailyUsageBytes + }; + case "linksnappy": + return { ...settings, linkSnappyLogin: "", linkSnappyPassword: "", providerDailyLimitBytes: nextProviderDailyLimitBytes, providerDailyUsageBytes: nextProviderDailyUsageBytes }; + default: + return settings; + } +} + +function validateAccountDialog(dialog: AccountDialogState): string | null { + if (!dialog.kind) { + return "Bitte zuerst einen Account-Typ auswählen."; + } + const option = findAccountOption(dialog.kind); + if (dialog.kind === "megadebrid-api" || dialog.kind === "megadebrid-web") { + if (dialog.megaAccounts.length === 0) { + return `${option.title}: Mindestens einen Account hinzufügen.`; + } + } else if (option.needsToken && !dialog.token.trim()) { + return `${option.title}: Bitte Zugangstoken eintragen.`; + } + if (option.needsCredentials) { + if (!dialog.login.trim()) { + return `${option.title}: Bitte Login oder E-Mail eintragen.`; + } + if (!dialog.password) { + return `${option.title}: Bitte Passwort eintragen.`; + } + } + if (dialog.dailyLimitGb.trim()) { + const parsed = Number(dialog.dailyLimitGb.trim().replace(",", ".")); + if (!Number.isFinite(parsed) || parsed < 0) { + return `${option.title}: Tageslimit muss eine Zahl >= 0 sein.`; + } + } + if (dialog.kind === "debridlink-api") { + for (const key of parseDebridLinkApiKeys(dialog.token)) { + const raw = dialog.keyDailyLimitGbById?.[key.id] || ""; + if (!raw.trim()) { + continue; + } + const parsed = Number(raw.trim().replace(",", ".")); + if (!Number.isFinite(parsed) || parsed < 0) { + return `${option.title}: ${key.label} Limit muss eine Zahl >= 0 sein.`; + } + } + } + return null; +} + +const emptyStats = (): DownloadStats => ({ + totalDownloaded: 0, + totalDownloadedAllTime: 0, + totalFiles: 0, + totalFilesSession: 0, + totalFilesAllTime: 0, + totalPackages: 0, + sessionStartedAt: 0, + appSessionStartedAt: 0, + sessionRuntimeMs: 0, + totalRuntimeMs: 0, + runtimeMeasuredAt: 0 +}); + +type StatsSectionItem = { + key: string; + eyebrow: string; + label: string; + value: string; + compactValue?: boolean; + danger?: boolean; + clickable?: boolean; + title?: string; + onClick?: () => void; +}; + +type StatsSection = { + key: string; + title: string; + items: StatsSectionItem[]; +}; + +const emptySnapshot = (): UiSnapshot => ({ + settings: { + token: "", realDebridUseWebLogin: false, megaLogin: "", megaPassword: "", megaCredentials: "", megaDebridApiEnabled: false, megaDebridWebEnabled: false, megaDebridPreferApi: true, bestToken: "", bestDebridUseWebLogin: false, allDebridToken: "", allDebridUseWebLogin: false, ddownloadLogin: "", ddownloadPassword: "", oneFichierApiKey: "", debridLinkApiKeys: "", linkSnappyLogin: "", linkSnappyPassword: "", + debridLinkDisabledKeyIds: [], + archivePasswordList: "", + rememberToken: true, providerOrder: [], providerPrimary: "realdebrid", providerSecondary: "none", + providerTertiary: "none", autoProviderFallback: true, outputDir: "", packageName: "", + autoExtract: true, autoRename4sf4sj: false, keepGermanAudioOnly: false, germanAudioMode: "tag", extractDir: "", createExtractSubfolder: true, hybridExtract: true, + collectMkvToLibrary: false, mkvLibraryDir: "", + cleanupMode: "none", extractConflictMode: "overwrite", removeLinkFilesAfterExtract: false, + removeSamplesAfterExtract: false, enableIntegrityCheck: true, autoResumeOnStart: true, + autoReconnect: false, reconnectWaitSeconds: 45, completedCleanupPolicy: "never", + maxParallel: 4, maxParallelExtract: 2, extractCpuPriority: "high", retryLimit: 0, speedLimitEnabled: false, speedLimitKbps: 0, speedLimitMode: "global", + updateRepo: "", autoUpdateCheck: true, clipboardWatch: false, minimizeToTray: false, + theme: "dark", collapseNewPackages: true, historyRetentionMode: "permanent", historyMaxEntries: 500, historyMaxAgeDays: 0, autoSortPackagesByProgress: true, autoSkipExtracted: false, hideExtractedItems: true, confirmDeleteSelection: true, backupIncludeDownloads: false, backupIncludeRemoteDiagnostics: false, + notifyUrl: "", notifyMention: "", notifyOnPackageCompleted: false, notifyOnPackageFailed: false, notifyOnRunFinished: false, + accountListShowDetailedDebridLinkKeys: false, + bandwidthSchedules: [], totalDownloadedAllTime: 0, totalCompletedFilesAllTime: 0, totalRuntimeAllTimeMs: 0, + columnOrder: ["name", "size", "progress", "hoster", "account", "prio", "status", "speed"], + autoExtractWhenStopped: true, + disabledProviders: [], + hosterRouting: {}, + providerDailyLimitBytes: {}, + providerDailyUsageBytes: {}, + providerTotalUsageBytes: {}, + debridLinkApiKeyDailyLimitBytes: {}, + debridLinkApiKeyDailyUsageBytes: {}, + debridLinkApiKeyTotalUsageBytes: {}, + megaDebridDisabledAccountIds: [], + megaDebridAccountDailyLimitBytes: {}, + megaDebridAccountDailyUsageBytes: {}, + megaDebridAccountTotalUsageBytes: {}, + debridAccountStatuses: {}, + providerDailyUsageDay: getProviderUsageDayKey(), + scheduledStartEpochMs: 0 + }, + session: { + version: 2, packageOrder: [], packages: {}, items: {}, runStartedAt: 0, + totalDownloadedBytes: 0, summaryText: "", reconnectUntil: 0, reconnectReason: "", + paused: false, running: false, updatedAt: Date.now() + }, + summary: null, stats: emptyStats(), speedText: "Geschwindigkeit: 0 B/s", etaText: "ETA: --", + canStart: true, canStop: false, canPause: false, clipboardActive: false, reconnectSeconds: 0, packageSpeedBps: {} +}); + +const cleanupLabels: Record<string, string> = { + never: "Nie", immediate: "Sofort", on_start: "Beim App-Start", package_done: "Sobald Paket fertig ist" +}; + +const historyRetentionLabels: Record<AppSettings["historyRetentionMode"], string> = { + never: "Nie", + session: "Nur aktuelle Session", + permanent: "Dauerhaft" +}; + +const AUTO_RENDER_PACKAGE_LIMIT = 260; + +const providerLabels: Record<DebridProvider, string> = { + realdebrid: "Real-Debrid", + megadebrid: "Mega-Debrid", + "megadebrid-api": "Mega-Debrid API", + "megadebrid-web": "Mega-Debrid Web", + bestdebrid: "BestDebrid", + alldebrid: "AllDebrid", + ddownload: "DDownload", + onefichier: "1Fichier", + debridlink: "Debrid-Link", + linksnappy: "LinkSnappy" +}; + +const KNOWN_HOSTERS: { id: string; label: string }[] = [ + { id: "rapidgator", label: "Rapidgator" }, + { id: "uploaded", label: "Uploaded" }, + { id: "1fichier", label: "1Fichier" }, + { id: "ddownload", label: "DDownload" }, + { id: "ddl", label: "DDL.to" }, + { id: "turbobit", label: "Turbobit" }, + { id: "nitroflare", label: "Nitroflare" }, + { id: "filefactory", label: "FileFactory" }, + { id: "katfile", label: "Katfile" }, + { id: "hitfile", label: "Hitfile" }, + { id: "alfafile", label: "Alfafile" }, + { id: "k2s", label: "Keep2Share" }, + { id: "keep2share", label: "Keep2Share (alt)" }, + { id: "tezfiles", label: "Tezfiles" }, + { id: "fileboom", label: "Fileboom" }, + { id: "mexashare", label: "Mexashare" }, + { id: "wdupload", label: "WDUpload" }, + { id: "rosefile", label: "Rosefile" }, + { id: "filejoker", label: "FileJoker" }, + { id: "worldbytez", label: "Worldbytez" }, + { id: "fileland", label: "Fileland" }, + { id: "depositfiles", label: "DepositFiles" }, + { id: "mediafire", label: "MediaFire" }, + { id: "mega", label: "Mega.nz" }, + { id: "frdl", label: "FreeDownload" }, + { id: "hexupload", label: "HexUpload" }, + { id: "isra", label: "Isra.cloud" } +]; + +function providerLabelWithMode(provider: DebridProvider, settings: AppSettings): string { + const base = providerLabels[provider]; + if (provider === "megadebrid" || provider === "megadebrid-api" || provider === "megadebrid-web") { + return base; + } + const kind = getConfiguredAccountKind(settings, provider as AccountService); + if (!kind) return base; + const opt = ACCOUNT_OPTIONS.find((o) => o.kind === kind); + return opt?.modeLabel ? `${base} (${opt.modeLabel})` : base; +} + +function compactProviderLabels(labels: string[]): string { + const unique = [...new Set(labels)]; + const groups = new Map<string, string[]>(); + for (const label of unique) { + const m = label.match(/^(.+?)\s*\((.+)\)$/); + if (m) { + const arr = groups.get(m[1]) || []; + arr.push(m[2]); + groups.set(m[1], arr); + } else { + groups.set(label, []); + } + } + return [...groups.entries()].map(([base, details]) => + details.length === 0 ? base : `${base} (${details.join(" + ")})` + ).join(", "); +} + +function formatDateTime(ts: number): string { + if (!ts) return ""; + const d = new Date(ts); + const dd = String(d.getDate()).padStart(2, "0"); + const mm = String(d.getMonth() + 1).padStart(2, "0"); + const yyyy = d.getFullYear(); + const hh = String(d.getHours()).padStart(2, "0"); + const min = String(d.getMinutes()).padStart(2, "0"); + return `${dd}.${mm}.${yyyy} - ${hh}:${min}`; +} + +function extractHoster(url: string): string { + try { + const host = new URL(url).hostname.replace(/^www\./, ""); + const parts = host.split("."); + return parts.length >= 2 ? parts[parts.length - 2] : host; + } catch { return ""; } +} + +function formatAudioStripSummary(summary: AudioStripSummary): { text: string; tooltip: string; attention: boolean } { + const parts: string[] = []; + const ok = summary.remuxed + summary.keptSingle; + if (ok > 0) parts.push(`${ok} OK`); + if (summary.skippedNoGerman > 0) parts.push(`${summary.skippedNoGerman} ohne DE-Tag`); + if (summary.skippedNoTool > 0) parts.push("ffmpeg fehlt"); + if (summary.failed > 0) parts.push(`${summary.failed} Fehler`); + const tooltip = summary.files + .map((f) => `${f.name}: ${f.action} (${f.reason}${f.languages ? `, Spuren: ${f.languages}` : ""})`) + .join("\n"); + return { + text: `Tonspur: ${parts.join(" · ") || "—"}`, + tooltip, + attention: summary.skippedNoGerman > 0 || summary.skippedNoTool > 0 || summary.failed > 0 + }; +} + +const settingsSubTabs: { key: SettingsSubTab; label: string }[] = [ + { key: "allgemein", label: "Allgemein" }, + { key: "accounts", label: "Accounts" }, + { key: "entpacken", label: "Entpacken" }, + { key: "geschwindigkeit", label: "Geschwindigkeit" }, + { key: "bereinigung", label: "Bereinigung" }, + { key: "updates", label: "Updates" }, +]; + +function formatSpeedMbps(speedBps: number): string { + const mbps = Math.max(0, speedBps || 0) / (1024 * 1024); + return `${mbps.toFixed(2)} MB/s`; +} + +function humanSize(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(2)} MB`; } + if (bytes < 1024 * 1024 * 1024 * 1024) { return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`; } + return `${(bytes / (1024 * 1024 * 1024 * 1024)).toFixed(3)} TB`; +} + +function formatRuntimeDuration(durationMs: number): string { + const totalSeconds = Math.max(0, Math.floor((durationMs || 0) / 1000)); + const minuteSeconds = 60; + const hourSeconds = 60 * minuteSeconds; + const daySeconds = 24 * hourSeconds; + const weekSeconds = 7 * daySeconds; + const monthSeconds = 30 * daySeconds; + + const formatUnit = (value: number, singular: string, plural: string, padTo = 0): string => { + const normalized = Math.max(0, Math.floor(value)); + const text = padTo > 0 ? String(normalized).padStart(padTo, "0") : String(normalized); + return `${text} ${normalized === 1 ? singular : plural}`; + }; + + if (totalSeconds < hourSeconds) { + const minutes = Math.floor(totalSeconds / minuteSeconds); + const seconds = totalSeconds % minuteSeconds; + return `${formatUnit(minutes, "Minute", "Minuten")}, ${formatUnit(seconds, "Sekunde", "Sekunden", 2)}`; + } + + if (totalSeconds < daySeconds) { + const hours = Math.floor(totalSeconds / hourSeconds); + const minutes = Math.floor((totalSeconds % hourSeconds) / minuteSeconds); + return `${formatUnit(hours, "Stunde", "Stunden")}, ${formatUnit(minutes, "Minute", "Minuten", 2)}`; + } + + if (totalSeconds < weekSeconds) { + const days = Math.floor(totalSeconds / daySeconds); + const hours = Math.floor((totalSeconds % daySeconds) / hourSeconds); + const minutes = Math.floor((totalSeconds % hourSeconds) / minuteSeconds); + return `${formatUnit(days, "Tag", "Tage")}, ${formatUnit(hours, "Stunde", "Stunden")}, ${formatUnit(minutes, "Minute", "Minuten")}`; + } + + if (totalSeconds < monthSeconds) { + const weeks = Math.floor(totalSeconds / weekSeconds); + const days = Math.floor((totalSeconds % weekSeconds) / daySeconds); + const hours = Math.floor((totalSeconds % daySeconds) / hourSeconds); + const minutes = Math.floor((totalSeconds % hourSeconds) / minuteSeconds); + return `${formatUnit(weeks, "Woche", "Wochen")}, ${formatUnit(days, "Tag", "Tage")}, ${formatUnit(hours, "Stunde", "Stunden")}, ${formatUnit(minutes, "Minute", "Minuten")}`; + } + + const months = Math.floor(totalSeconds / monthSeconds); + const weeks = Math.floor((totalSeconds % monthSeconds) / weekSeconds); + const days = Math.floor((totalSeconds % weekSeconds) / daySeconds); + const hours = Math.floor((totalSeconds % daySeconds) / hourSeconds); + const minutes = Math.floor((totalSeconds % hourSeconds) / minuteSeconds); + return `${formatUnit(months, "Monat", "Monate")}, ${formatUnit(weeks, "Woche", "Wochen")}, ${formatUnit(days, "Tag", "Tage")}, ${formatUnit(hours, "Stunde", "Stunden")}, ${formatUnit(minutes, "Minute", "Minuten")}`; +} + +function formatHistoryDuration(durationSeconds: number): string { + return formatRuntimeDuration(Math.max(0, durationSeconds || 0) * 1000); +} + +function formatAllDebridSourceLabel(source: AllDebridHostInfo["source"]): string { + return source === "web" ? "Web-Login" : "API-Key"; +} + +function formatAllDebridQuota(info: AllDebridHostInfo): string { + const suffix = info.quotaType ? ` (${info.quotaType})` : ""; + if (info.quota !== null && info.quotaMax !== null) { + return `${info.quota} / ${info.quotaMax}${suffix}`; + } + if (info.quota !== null) { + return `${info.quota}${suffix}`; + } + if (info.quotaMax !== null) { + return `max. ${info.quotaMax}${suffix}`; + } + return info.source === "web" ? "Nur per API-Key sichtbar" : "Nicht angegeben"; +} + +function formatAllDebridSimuLimit(info: AllDebridHostInfo): string { + if (info.limitSimuDl === null) { + return info.source === "web" ? "Nur per API-Key sichtbar" : "Nicht angegeben"; + } + return String(info.limitSimuDl); +} + +function formatAllDebridTimestamp(info: AllDebridHostInfo): string { + return formatDateTime(info.lastCheckedAt || info.fetchedAt); +} + +function formatDebridLinkTraffic(info: DebridLinkHostLimitInfo | null | undefined): string { + if (!info) { + return "Lade..."; + } + const toGb = (bytes: number): string => `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`; + if (info.trafficCurrentBytes !== null && info.trafficMaxBytes !== null) { + return `${toGb(info.trafficCurrentBytes)} / ${toGb(info.trafficMaxBytes)}`; + } + if (info.trafficMaxBytes !== null) { + return `max. ${toGb(info.trafficMaxBytes)}`; + } + if (info.trafficCurrentBytes !== null) { + return toGb(info.trafficCurrentBytes); + } + return info.note || "Nicht verfügbar"; +} + +function formatDebridLinkCountQuota(info: DebridLinkHostLimitInfo | null | undefined): string { + if (!info) { + return "Lade..."; + } + if (info.linksCurrent !== null && info.linksMax !== null) { + return `${info.linksCurrent} / ${info.linksMax}`; + } + if (info.linksMax !== null) { + return `max. ${info.linksMax}`; + } + if (info.linksCurrent !== null) { + return String(info.linksCurrent); + } + return info.note || "Nicht verfügbar"; +} + +function formatCheckedAgo(checkedAt: number): string { + const deltaMs = Date.now() - checkedAt; + if (!Number.isFinite(deltaMs) || deltaMs < 0) return "gerade eben"; + const min = Math.floor(deltaMs / 60000); + if (min < 1) return "gerade eben"; + if (min < 60) return `vor ${min} Min`; + const hours = Math.floor(min / 60); + if (hours < 24) return `vor ${hours} Std`; + const days = Math.floor(hours / 24); + return `vor ${days} Tag${days === 1 ? "" : "en"}`; +} + +function rotationEventText(ev: { event: string; cooldownSec?: number; next?: string; reason?: string }): string { + const untilRestart = /bis zum Tagesreset gesperrt/i.test(ev.reason || ""); + switch (ev.event) { + case "OK": return "erfolgreich"; + case "FAILED": { + if (untilRestart) { + const nx = ev.next && ev.next !== "ENDE" ? ` → ${ev.next}` : ""; + return `Tageslimit erreicht, bis zum Tagesreset gesperrt${nx}`; + } + const cd = ev.cooldownSec ? `, Cooldown ${ev.cooldownSec}s` : ""; + const nx = ev.next && ev.next !== "ENDE" ? ` → ${ev.next}` : ""; + return `fehlgeschlagen${cd}${nx}`; + } + case "FATAL": return "abgebrochen (fataler Fehler)"; + case "TIMEOUT_COOLDOWN": { + const cd = ev.cooldownSec ? `, Cooldown ${ev.cooldownSec}s` : ""; + return `Timeout/Abbruch${cd} → nächster Account beim Retry`; + } + case "SKIP_COOLDOWN": return untilRestart ? "übersprungen (bis zum Tagesreset gesperrt)" : "übersprungen (Cooldown aktiv)"; + case "SKIP_DISABLED": return "übersprungen (deaktiviert)"; + case "SKIP_DAILY_LIMIT": return "übersprungen (Tageslimit erreicht)"; + case "SKIP_HOST_COOLDOWN": return "übersprungen (Host-Cooldown)"; + case "PROVIDER_WIDE": return "Provider-weiter Fehler, restliche Keys übersprungen"; + case "TRANSPORT_CASCADE": return "Netzwerk-Kaskade, restliche Keys übersprungen"; + default: return ev.event; + } +} + +function getDebridLinkKeyStatusDisplay( + key: DebridLinkAccountKeyEntry, + info: DebridLinkHostLimitInfo | null | undefined +): { label: string; tone: "ok" | "warn" | "bad" | "muted"; title: string } { + if (key.disabled) { + return { + label: "Deaktiviert", + tone: "muted", + title: "Key ist manuell deaktiviert." + }; + } + if (key.dailyLimitReached) { + return { + label: "Lokales Limit", + tone: "warn", + title: key.dailyLimitBytes > 0 + ? `Lokales Tageslimit erreicht (${humanSize(key.dailyUsedBytes)} / ${humanSize(key.dailyLimitBytes)}).` + : "Lokales Tageslimit erreicht." + }; + } + if (!info) { + return { + label: "Pruefe...", + tone: "muted", + title: "Live-Status wird geladen." + }; + } + + const title = [info.stateDetail, info.note, info.hostNote] + .filter((value) => Boolean(String(value || "").trim())) + .join("\n"); + + if (info.state === "ready") { + if (info.hostState === "down") { + return { + label: "Host offline", + tone: "warn", + title: title || "Der Hoster ist laut Debrid-Link aktuell offline." + }; + } + return { + label: "Bereit", + tone: "ok", + title: title || "Key ist nutzbar." + }; + } + + if (info.state === "invalid" || info.state === "error") { + return { + label: info.stateLabel, + tone: "bad", + title: title || info.stateLabel + }; + } + + if (info.state === "quota" || info.state === "rate_limit" || info.state === "cooldown") { + return { + label: info.stateLabel, + tone: "warn", + title: title || info.stateLabel + }; + } + + return { + label: info.stateLabel || "Unbekannt", + tone: "muted", + title: title || info.stateLabel || "Unbekannt" + }; +} + +function splitStatValue(value: string): { num: string; unit: string; idle: boolean } { + const v = (value ?? "").trim(); + if (v === "" || v === "--" || v === "—") return { num: "—", unit: "", idle: true }; + const match = v.match(/^(-?[\d.,]+)\s*(.*)$/); + if (!match) return { num: v, unit: "", idle: false }; + const num = match[1]; + const unit = (match[2] || "").trim(); + const idle = /^0([.,]0+)?$/.test(num); + return { num, unit, idle }; +} + +function StatValueView({ value, compact, danger }: { value: string; compact?: boolean; danger?: boolean }): ReactElement { + const { num, unit, idle } = splitStatValue(value); + const cls = `stat-value${compact ? " stat-value-compact" : ""}${danger ? " danger" : ""}${idle ? " stat-idle" : ""}`; + if (compact) { + return <span className={cls}>{idle ? "—" : value}</span>; + } + return ( + <span className={cls}> + <span className="stat-num">{num}</span> + {unit ? <span className="stat-unit">{unit}</span> : null} + </span> + ); +} + +interface BandwidthChartProps { + items: Record<string, DownloadItem>; + running: boolean; + paused: boolean; + speedHistoryRef: React.MutableRefObject<{ time: number; speed: number }[]>; +} + +const BandwidthChart = memo(function BandwidthChart({ items, running, paused, speedHistoryRef }: BandwidthChartProps): ReactElement { + const canvasRef = useRef<HTMLCanvasElement>(null); + const containerRef = useRef<HTMLDivElement>(null); + const lastUpdateRef = useRef<number>(0); + + const animationFrameRef = useRef<number>(0); + + const drawChart = useCallback(() => { + const canvas = canvasRef.current; + const container = containerRef.current; + if (!canvas || !container) return; + + const ctx = canvas.getContext("2d"); + if (!ctx) return; + + const dpr = window.devicePixelRatio || 1; + const width = container.clientWidth; + const height = container.clientHeight; + if (width <= 0 || height <= 0) return; + + canvas.width = width * dpr; + canvas.height = height * dpr; + ctx.scale(dpr, dpr); + + ctx.clearRect(0, 0, width, height); + + const isDark = document.documentElement.getAttribute("data-theme") !== "light"; + const gridColor = isDark ? "rgba(35, 57, 84, 0.5)" : "rgba(199, 213, 234, 0.5)"; + const textColor = isDark ? "#90a4bf" : "#4e6482"; + const accentColor = isDark ? "#f2942d" : "#c2701a"; + const fillColor = isDark ? "rgba(242, 148, 45, 0.15)" : "rgba(194, 112, 26, 0.15)"; + + const history = speedHistoryRef.current; + const now = Date.now(); + const maxTime = now; + const minTime = now - 60000; + + let maxSpeed = 0; + for (const point of history) { + if (point.speed > maxSpeed) maxSpeed = point.speed; + } + maxSpeed = Math.max(maxSpeed, 1024 * 1024); + const niceMax = Math.pow(2, Math.ceil(Math.log2(maxSpeed))); + + ctx.font = "11px 'Manrope', sans-serif"; + let maxLabelWidth = 0; + for (let i = 0; i <= 5; i += 1) { + const speedVal = niceMax * (1 - i / 5); + const w = ctx.measureText(formatSpeedMbps(speedVal)).width; + if (w > maxLabelWidth) maxLabelWidth = w; + } + const padding = { top: 20, right: 20, bottom: 30, left: Math.ceil(maxLabelWidth) + 16 }; + const chartWidth = width - padding.left - padding.right; + const chartHeight = height - padding.top - padding.bottom; + + ctx.strokeStyle = gridColor; + ctx.lineWidth = 1; + for (let i = 0; i <= 5; i += 1) { + const y = padding.top + (chartHeight / 5) * i; + ctx.beginPath(); + ctx.moveTo(padding.left, y); + ctx.lineTo(width - padding.right, y); + ctx.stroke(); + } + + ctx.fillStyle = textColor; + ctx.font = "11px 'Manrope', sans-serif"; + ctx.textAlign = "right"; + ctx.textBaseline = "middle"; + + for (let i = 0; i <= 5; i += 1) { + const y = padding.top + (chartHeight / 5) * i; + const speedVal = niceMax * (1 - i / 5); + ctx.fillText(formatSpeedMbps(speedVal), padding.left - 8, y); + } + + ctx.textAlign = "center"; + ctx.textBaseline = "top"; + ctx.fillText("60s", padding.left, height - padding.bottom + 8); + ctx.fillText("30s", padding.left + chartWidth / 2, height - padding.bottom + 8); + ctx.fillText("0s", width - padding.right, height - padding.bottom + 8); + + if (history.length < 2) { + ctx.fillStyle = textColor; + ctx.font = "13px 'Manrope', sans-serif"; + ctx.textAlign = "center"; + ctx.textBaseline = "middle"; + ctx.fillText(running ? (paused ? "Pausiert" : "Sammle Daten...") : "Download starten für Statistiken", width / 2, height / 2); + return; + } + + const points: { x: number; y: number }[] = []; + for (const point of history) { + const x = padding.left + ((point.time - minTime) / 60000) * chartWidth; + const y = padding.top + chartHeight - (point.speed / niceMax) * chartHeight; + points.push({ x, y }); + } + + ctx.beginPath(); + ctx.moveTo(points[0].x, points[0].y); + for (let i = 1; i < points.length; i += 1) { + ctx.lineTo(points[i].x, points[i].y); + } + ctx.lineTo(points[points.length - 1].x, padding.top + chartHeight); + ctx.lineTo(points[0].x, padding.top + chartHeight); + ctx.closePath(); + ctx.fillStyle = fillColor; + ctx.fill(); + + ctx.beginPath(); + ctx.moveTo(points[0].x, points[0].y); + for (let i = 1; i < points.length; i += 1) { + ctx.lineTo(points[i].x, points[i].y); + } + ctx.strokeStyle = accentColor; + ctx.lineWidth = 2; + ctx.stroke(); + + const lastPoint = points[points.length - 1]; + ctx.beginPath(); + ctx.arc(lastPoint.x, lastPoint.y, 4, 0, Math.PI * 2); + ctx.fillStyle = accentColor; + ctx.fill(); + }, [running, paused]); + + useEffect(() => { + drawChart(); + if (!running || paused) { + return; + } + const interval = setInterval(() => { + drawChart(); + }, 250); + return () => clearInterval(interval); + }, [drawChart, running, paused]); + + useEffect(() => { + if (!running || paused) return; + + const now = Date.now(); + const activeItems = Object.values(items).filter((item) => item.status === "downloading"); + if (activeItems.length === 0) return; + + const totalSpeed = activeItems.reduce((sum, item) => sum + (item.speedBps || 0), 0); + + const history = speedHistoryRef.current; + history.push({ time: now, speed: totalSpeed }); + + const cutoff = now - 60000; + let trimIndex = 0; + while (trimIndex < history.length && history[trimIndex].time < cutoff) { + trimIndex += 1; + } + if (trimIndex > 0) { + speedHistoryRef.current = history.slice(trimIndex); + } + + lastUpdateRef.current = now; + }, [items, paused, running]); + + useEffect(() => { + const handleResize = () => { + if (animationFrameRef.current) cancelAnimationFrame(animationFrameRef.current); + animationFrameRef.current = requestAnimationFrame(drawChart); + }; + + window.addEventListener("resize", handleResize); + return () => { + window.removeEventListener("resize", handleResize); + if (animationFrameRef.current) { + cancelAnimationFrame(animationFrameRef.current); + } + }; + }, [drawChart]); + + useEffect(() => { + drawChart(); + }, [drawChart, items, paused]); + + return ( + <div ref={containerRef} className="bandwidth-chart-container"> + <canvas ref={canvasRef} /> + </div> + ); +}); + +interface DownloadSpeedSparklineProps { + items: Record<string, DownloadItem>; + running: boolean; + paused: boolean; +} + +const SPARKLINE_MAX_SAMPLES = 160; + +const DownloadSpeedSparkline = memo(function DownloadSpeedSparkline({ items, running, paused }: DownloadSpeedSparklineProps): ReactElement { + const canvasRef = useRef<HTMLCanvasElement>(null); + const histRef = useRef<number[]>([]); + const displayRef = useRef<number>(0); + const itemsRef = useRef(items); + const activeRef = useRef(running && !paused); + const [liveSpeed, setLiveSpeed] = useState(0); + itemsRef.current = items; + activeRef.current = running && !paused; + + useEffect(() => { + const draw = (): void => { + const canvas = canvasRef.current; + if (!canvas) return; + const ctx = canvas.getContext("2d"); + if (!ctx) return; + const dpr = window.devicePixelRatio || 1; + const cssW = canvas.clientWidth; + const cssH = canvas.clientHeight; + if (cssW <= 0 || cssH <= 0) return; + canvas.width = Math.round(cssW * dpr); + canvas.height = Math.round(cssH * dpr); + ctx.setTransform(dpr, 0, 0, dpr, 0, 0); + ctx.clearRect(0, 0, cssW, cssH); + + const hist = histRef.current; + if (hist.length < 2) return; + + const isDark = document.documentElement.getAttribute("data-theme") !== "light"; + const accent = isDark ? "#f2942d" : "#c2701a"; + const fill = isDark ? "rgba(242, 148, 45, 0.16)" : "rgba(194, 112, 26, 0.16)"; + + let maxV = 0; + for (const v of hist) if (v > maxV) maxV = v; + maxV = Math.max(maxV, 1024 * 1024); + + const pad = 2; + const h = cssH - pad * 2; + const step = cssW / (SPARKLINE_MAX_SAMPLES - 1); + const startIdx = SPARKLINE_MAX_SAMPLES - hist.length; + const px = (i: number): number => (startIdx + i) * step; + const py = (v: number): number => pad + h - (v / maxV) * h; + + ctx.beginPath(); + ctx.moveTo(px(0), py(hist[0])); + for (let i = 1; i < hist.length; i += 1) ctx.lineTo(px(i), py(hist[i])); + ctx.lineTo(px(hist.length - 1), pad + h); + ctx.lineTo(px(0), pad + h); + ctx.closePath(); + ctx.fillStyle = fill; + ctx.fill(); + + ctx.beginPath(); + ctx.moveTo(px(0), py(hist[0])); + for (let i = 1; i < hist.length; i += 1) ctx.lineTo(px(i), py(hist[i])); + ctx.strokeStyle = accent; + ctx.lineWidth = 1.5; + ctx.lineJoin = "round"; + ctx.stroke(); + }; + + const tick = (): void => { + let target = 0; + if (activeRef.current) { + for (const it of Object.values(itemsRef.current)) { + if (it.status === "downloading") target += it.speedBps || 0; + } + } + const cur = displayRef.current; + const alpha = target > cur ? 0.45 : 0.12; + let next = cur + (target - cur) * alpha; + if (next < 1) next = 0; + displayRef.current = next; + const hist = histRef.current; + hist.push(next); + if (hist.length > SPARKLINE_MAX_SAMPLES) hist.splice(0, hist.length - SPARKLINE_MAX_SAMPLES); + setLiveSpeed(target); + draw(); + }; + + const id = window.setInterval(tick, 250); + return () => window.clearInterval(id); + }, []); + + return ( + <div className="speed-sparkline" title="Aktuelle Download-Geschwindigkeit (geglättet)"> + <canvas ref={canvasRef} className="speed-sparkline-canvas" /> + <span className="speed-sparkline-value">{liveSpeed > 0 ? formatSpeedMbps(liveSpeed) : "0 B/s"}</span> + </div> + ); +}); + +let nextCollectorId = 1; + +function createScheduleId(): string { + return `schedule-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`; +} + +function sortPackageOrderBySize(order: string[], packages: Record<string, PackageEntry>, items: Record<string, DownloadItem>, descending: boolean): string[] { + const sorted = [...order]; + sorted.sort((a, b) => { + const sizeA = (packages[a]?.itemIds ?? []).reduce((sum, id) => sum + (items[id]?.totalBytes || items[id]?.downloadedBytes || 0), 0); + const sizeB = (packages[b]?.itemIds ?? []).reduce((sum, id) => sum + (items[id]?.totalBytes || items[id]?.downloadedBytes || 0), 0); + const cmp = sizeA - sizeB; + return descending ? -cmp : cmp; + }); + return sorted; +} + +function sortPackageOrderByHoster(order: string[], packages: Record<string, PackageEntry>, items: Record<string, DownloadItem>, descending: boolean): string[] { + const sorted = [...order]; + sorted.sort((a, b) => { + const hosterA = [...new Set((packages[a]?.itemIds ?? []).map((id) => extractHoster(items[id]?.url || "")).filter(Boolean))].join(",").toLowerCase(); + const hosterB = [...new Set((packages[b]?.itemIds ?? []).map((id) => extractHoster(items[id]?.url || "")).filter(Boolean))].join(",").toLowerCase(); + const cmp = hosterA.localeCompare(hosterB); + return descending ? -cmp : cmp; + }); + return sorted; +} + +function sortPackageOrderByProgress(order: string[], packages: Record<string, PackageEntry>, items: Record<string, DownloadItem>, descending: boolean): string[] { + const sorted = [...order]; + sorted.sort((a, b) => { + const progressA = computePackageProgress(packages[a], items); + const progressB = computePackageProgress(packages[b], items); + const cmp = progressA - progressB; + return descending ? -cmp : cmp; + }); + return sorted; +} + +function computePackageProgress(pkg: PackageEntry | undefined, items: Record<string, DownloadItem>): number { + if (!pkg) return 0; + const ids = pkg.itemIds ?? []; + if (ids.length === 0) return 0; + let totalDown = 0; + let totalSize = 0; + for (const id of ids) { + const item = items[id]; + if (!item) continue; + totalDown += item.downloadedBytes || 0; + totalSize += item.totalBytes || item.downloadedBytes || 0; + } + return totalSize > 0 ? totalDown / totalSize : 0; +} + +type PkgSortColumn = "name" | "size" | "hoster" | "progress"; + +const DEFAULT_COLUMN_ORDER = ["name", "size", "progress", "hoster", "account", "prio", "status", "speed"]; +const ALL_COLUMN_KEYS = ["name", "size", "progress", "hoster", "account", "prio", "status", "speed", "added"]; +const COLUMN_DEFS: Record<string, { label: string; width: string; sortable?: PkgSortColumn }> = { + name: { label: "Name", width: "minmax(0, 0.92fr)", sortable: "name" }, + size: { label: "Geladen / Größe", width: "160px", sortable: "size" }, + progress: { label: "Fortschritt", width: "80px", sortable: "progress" }, + hoster: { label: "Hoster", width: "110px", sortable: "hoster" }, + account: { label: "Service", width: "132px" }, + prio: { label: "Priorität", width: "70px" }, + status: { label: "Status", width: "160px" }, + speed: { label: "Geschwindigkeit", width: "90px" }, + added: { label: "Hinzugefügt am", width: "155px" }, +}; + +function sameStringArray(a: string[], b: string[]): boolean { + if (a.length !== b.length) { + return false; + } + for (let index = 0; index < a.length; index += 1) { + if (a[index] !== b[index]) { + return false; + } + } + return true; +} + +function formatMbpsInputFromKbps(kbps: number): string { + const mbps = Math.max(0, Number(kbps) || 0) / 1024; + return String(Number(mbps.toFixed(2))); +} + +function parseMbpsInput(value: string): number | null { + const normalized = String(value || "").trim().replace(/,/g, "."); + if (!normalized) { + return 0; + } + const parsed = Number(normalized); + if (!Number.isFinite(parsed) || parsed < 0) { + return null; + } + return parsed; +} + +function formatUpdateInstallProgress(progress: UpdateInstallProgress): string { + if (progress.stage === "downloading") { + if (progress.totalBytes && progress.totalBytes > 0 && progress.percent !== null) { + return `Update-Download: ${progress.percent}% (${humanSize(progress.downloadedBytes)} / ${humanSize(progress.totalBytes)})`; + } + return `Update-Download: ${humanSize(progress.downloadedBytes)}`; + } + if (progress.stage === "starting") { + return "Update wird vorbereitet..."; + } + if (progress.stage === "verifying") { + return "Download fertig | Prüfe Integrität..."; + } + if (progress.stage === "launching") { + return "Starte Installer..."; + } + if (progress.stage === "done") { + return "Installer gestartet"; + } + return `Update-Fehler: ${progress.message}`; +} + +export function App(): ReactElement { + const [snapshot, setSnapshot] = useState<UiSnapshot>(emptySnapshot); + const [appVersion, setAppVersion] = useState(""); + const [tab, setTab] = useState<Tab>("downloads"); + const [statusToast, setStatusToast] = useState(""); + const [updateInstallProgress, setUpdateInstallProgress] = useState<UpdateInstallProgress | null>(null); + const [settingsDraft, setSettingsDraft] = useState<AppSettings>(emptySnapshot().settings); + const [speedLimitInput, setSpeedLimitInput] = useState(() => formatMbpsInputFromKbps(emptySnapshot().settings.speedLimitKbps)); + const [scheduleSpeedInputs, setScheduleSpeedInputs] = useState<Record<string, string>>({}); + const [accountColumnWidths, setAccountColumnWidths] = useState<Record<AccountColumnKey, number>>(loadAccountColumnWidths); + const [settingsDirty, setSettingsDirty] = useState(false); + const [schedulePickerOpen, setSchedulePickerOpen] = useState(false); + const [scheduleTimeInput, setScheduleTimeInput] = useState(""); + const [scheduleCountdown, setScheduleCountdown] = useState(""); + const [runtimeNow, setRuntimeNow] = useState(() => Date.now()); + const settingsDirtyRef = useRef(false); + const settingsDraftRevisionRef = useRef(0); + const panelDirtyRevisionRef = useRef(0); + const latestStateRef = useRef<UiSnapshot | null>(null); + const masterSnapshotRef = useRef<UiSnapshot | null>(null); + const snapshotRef = useRef(snapshot); + snapshotRef.current = snapshot; + const tabRef = useRef(tab); + const autoExpandedPkgsRef = useRef(new Set<string>()); + const manualCollapsedPkgsRef = useRef(new Set<string>()); + tabRef.current = tab; + const stateFlushTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null); + const toastTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null); + const onImportDlcRef = useRef<() => Promise<void>>(() => Promise.resolve()); + const [dragOver, setDragOver] = useState(false); + const [draggedProvider, setDraggedProvider] = useState<DebridProvider | null>(null); + const [providerDropTarget, setProviderDropTarget] = useState<DebridProvider | null>(null); + const [editingPackageId, setEditingPackageId] = useState<string | null>(null); + const [editingName, setEditingName] = useState(""); + const [collectorTabs, setCollectorTabs] = useState<CollectorTab[]>([ + { id: `tab-${nextCollectorId++}`, name: "Tab 1", text: "" } + ]); + const [activeCollectorTab, setActiveCollectorTab] = useState(collectorTabs[0].id); + const collectorTabsRef = useRef<CollectorTab[]>(collectorTabs); + const activeCollectorTabRef = useRef(activeCollectorTab); + const activeTabRef = useRef<Tab>(tab); + const packageOrderRef = useRef<string[]>([]); + const serverPackageOrderRef = useRef<string[]>([]); + const pendingPackageOrderRef = useRef<string[] | null>(null); + const pendingPackageOrderAtRef = useRef(0); + const draggedPackageIdRef = useRef<string | null>(null); + const [collapsedPackages, setCollapsedPackages] = useState<Record<string, boolean>>({}); + const [downloadSearch, setDownloadSearch] = useState(""); + const [downloadsSortColumn, setDownloadsSortColumn] = useState<PkgSortColumn>("name"); + const [downloadsSortDescending, setDownloadsSortDescending] = useState(false); + const [showAllPackages, setShowAllPackages] = useState(false); + const [actionBusy, setActionBusy] = useState(false); + const [accountCheckBusy, setAccountCheckBusy] = useState(false); + const [megaCheckingIds, setMegaCheckingIds] = useState<Set<string>>(() => new Set()); + const actionBusyRef = useRef(false); + const actionUnlockTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null); + const mountedRef = useRef(true); + const [supportTraceEnabled, setSupportTraceEnabled] = useState(false); + const dragOverRef = useRef(false); + const dragDepthRef = useRef(0); + const [openMenu, setOpenMenu] = useState<string | null>(null); + const [settingsSubTab, setSettingsSubTab] = useState<SettingsSubTab>("allgemein"); + const [openSubmenu, setOpenSubmenu] = useState<string | null>(null); + const [startConflictPrompt, setStartConflictPrompt] = useState<StartConflictPromptState | null>(null); + const startConflictResolverRef = useRef<((result: { policy: Extract<DuplicatePolicy, "skip" | "overwrite">; applyToAll: boolean } | null) => void) | null>(null); + const [confirmPrompt, setConfirmPrompt] = useState<ConfirmPromptState | null>(null); + const [remoteDiag, setRemoteDiag] = useState<RemoteDiagnosticsInfo | null>(null); + const [remoteDiagOpen, setRemoteDiagOpen] = useState(false); + const [remoteDiagBusy, setRemoteDiagBusy] = useState(false); + const [rdHostMode, setRdHostMode] = useState<"local" | "network">("local"); + const [rdPublicHost, setRdPublicHost] = useState(""); + const [rdPort, setRdPort] = useState("9868"); + const [rdAllowlist, setRdAllowlist] = useState(""); + const [rdName, setRdName] = useState(""); + const confirmResolverRef = useRef<((confirmed: boolean) => void) | null>(null); + const confirmQueueRef = useRef<Array<{ prompt: ConfirmPromptState; resolve: (confirmed: boolean) => void }>>([]); + const importQueueFocusHandlerRef = useRef<(() => void) | null>(null); + const [contextMenu, setContextMenu] = useState<ContextMenuState | null>(null); + const ctxMenuRef = useRef<HTMLDivElement>(null); + const [linkPopup, setLinkPopup] = useState<LinkPopupState | null>(null); + const [accountDialog, setAccountDialog] = useState<AccountDialogState | null>(null); + const [accountDialogSearch, setAccountDialogSearch] = useState(""); + const [keyStatsPopup, setKeyStatsPopup] = useState<string | null>(null); + const [debridLinkHostLimits, setDebridLinkHostLimits] = useState<Record<string, DebridLinkHostLimitInfo>>({}); + const [debridLinkHostLimitsLoading, setDebridLinkHostLimitsLoading] = useState(false); + const [debridLinkHostLimitsError, setDebridLinkHostLimitsError] = useState(""); + const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set()); + const [deleteConfirm, setDeleteConfirm] = useState<{ ids: Set<string>; dontAsk: boolean } | null>(null); + const [columnOrder, setColumnOrder] = useState<string[]>(() => DEFAULT_COLUMN_ORDER); + const [dragColId, setDragColId] = useState<string | null>(null); + const [dropTargetCol, setDropTargetCol] = useState<string | null>(null); + const [colHeaderCtx, setColHeaderCtx] = useState<{ x: number; y: number } | null>(null); + const colHeaderCtxRef = useRef<HTMLDivElement>(null); + const colHeaderBarRef = useRef<HTMLDivElement>(null); + const [historyEntries, setHistoryEntries] = useState<HistoryEntry[]>([]); + const historyEntriesRef = useRef<HistoryEntry[]>([]); + const [historyCollapsed, setHistoryCollapsed] = useState<Record<string, boolean>>({}); + const [selectedHistoryIds, setSelectedHistoryIds] = useState<Set<string>>(new Set()); + const [historyCtxMenu, setHistoryCtxMenu] = useState<{ x: number; y: number; entryId: string } | null>(null); + const historyCtxMenuRef = useRef<HTMLDivElement>(null); + const [allDebridHostInfo, setAllDebridHostInfo] = useState<AllDebridHostInfo | null>(null); + const [allDebridHostLoading, setAllDebridHostLoading] = useState(false); + const allDebridHostRequestRef = useRef(0); + const debridLinkHostLimitsRequestRef = useRef(0); + const accountColumnResizeRef = useRef<{ key: AccountColumnKey; startX: number; startWidth: number } | null>(null); + const onAccountColumnResizeMove = useCallback((event: MouseEvent): void => { + const active = accountColumnResizeRef.current; + if (!active) { + return; + } + const nextWidth = Math.max( + ACCOUNT_COLUMN_MIN_WIDTHS[active.key], + Math.round(active.startWidth + (event.clientX - active.startX)) + ); + setAccountColumnWidths((prev) => ( + prev[active.key] === nextWidth ? prev : { ...prev, [active.key]: nextWidth } + )); + }, []); + + const stopAccountColumnResize = useCallback((): void => { + accountColumnResizeRef.current = null; + window.removeEventListener("mousemove", onAccountColumnResizeMove); + window.removeEventListener("mouseup", stopAccountColumnResize); + }, [onAccountColumnResizeMove]); + + const startAccountColumnResize = useCallback((key: AccountColumnKey, clientX: number): void => { + accountColumnResizeRef.current = { + key, + startX: clientX, + startWidth: accountColumnWidths[key] + }; + window.addEventListener("mousemove", onAccountColumnResizeMove); + window.addEventListener("mouseup", stopAccountColumnResize); + }, [accountColumnWidths, onAccountColumnResizeMove, stopAccountColumnResize]); + + useEffect(() => { + if (tab !== "history") return; + const loadHistory = async (): Promise<void> => { + try { + const entries = await window.rd.getHistory(); + if (mountedRef.current && entries) { + setHistoryEntries(entries); + } + } catch (err) { + console.error("Failed to load history:", err); + } + }; + void loadHistory(); + }, [tab]); + + useEffect(() => { historyEntriesRef.current = historyEntries; }, [historyEntries]); + + useEffect(() => { + try { + window.localStorage.setItem(ACCOUNT_COLUMN_STORAGE_KEY, JSON.stringify(accountColumnWidths)); + } catch { + } + }, [accountColumnWidths]); + + const resetAccountColumnWidths = useCallback((): void => { + setAccountColumnWidths({ ...ACCOUNT_COLUMN_DEFAULT_WIDTHS }); + try { + window.localStorage.removeItem(ACCOUNT_COLUMN_STORAGE_KEY); + } catch { + } + showToast("Accounts-Spalten zurückgesetzt", 1800); + }, []); + + const columnOrderKey = useMemo( + () => (snapshot.settings.columnOrder || []).join("|"), + [snapshot.settings.columnOrder] + ); + useEffect(() => { + const order = snapshot.settings.columnOrder; + if (order && order.length > 0) { + setColumnOrder(order); + } + }, [columnOrderKey]); + + const currentCollectorTab = collectorTabs.find((t) => t.id === activeCollectorTab) ?? collectorTabs[0]; + + useEffect(() => { + activeCollectorTabRef.current = activeCollectorTab; + }, [activeCollectorTab]); + + useEffect(() => { + collectorTabsRef.current = collectorTabs; + }, [collectorTabs]); + + useEffect(() => { + activeTabRef.current = tab; + }, [tab]); + + useEffect(() => { + const incoming = snapshot.session.packageOrder; + serverPackageOrderRef.current = incoming; + + const pending = pendingPackageOrderRef.current; + if (!pending) { + packageOrderRef.current = incoming; + return; + } + + if (sameStringArray(pending, incoming)) { + pendingPackageOrderRef.current = null; + pendingPackageOrderAtRef.current = 0; + packageOrderRef.current = incoming; + return; + } + + const maxOptimisticHoldMs = 1500; + if (Date.now() - pendingPackageOrderAtRef.current >= maxOptimisticHoldMs) { + pendingPackageOrderRef.current = null; + pendingPackageOrderAtRef.current = 0; + packageOrderRef.current = incoming; + return; + } + + packageOrderRef.current = pending; + }, [snapshot.session.packageOrder]); + + useEffect(() => { + setSpeedLimitInput(formatMbpsInputFromKbps(settingsDraft.speedLimitKbps)); + }, [settingsDraft.speedLimitKbps]); + + useEffect(() => { + const schedMs = snapshot.settings.scheduledStartEpochMs || 0; + if (schedMs <= 0) { setScheduleCountdown(""); return; } + const update = (): void => { + const remaining = schedMs - Date.now(); + if (remaining <= 0) { setScheduleCountdown(""); return; } + const totalSec = Math.ceil(remaining / 1000); + const h = Math.floor(totalSec / 3600); + const m = Math.floor((totalSec % 3600) / 60); + const s = totalSec % 60; + setScheduleCountdown(h > 0 ? `${h}h ${m}m` : m > 0 ? `${m}m ${s}s` : `${s}s`); + }; + update(); + const timer = setInterval(update, 1000); + return () => clearInterval(timer); + }, [snapshot.settings.scheduledStartEpochMs]); + + useEffect(() => { + const timer = setInterval(() => setRuntimeNow(Date.now()), 1000); + return () => clearInterval(timer); + }, []); + + const showToast = useCallback((message: string, timeoutMs = 2200): void => { + setStatusToast(message); + if (toastTimerRef.current) { clearTimeout(toastTimerRef.current); } + toastTimerRef.current = setTimeout(() => { + setStatusToast(""); + toastTimerRef.current = null; + }, timeoutMs); + }, []); + + const loadAllDebridHostInfo = useCallback(async (silent = false): Promise<void> => { + const requestId = allDebridHostRequestRef.current + 1; + allDebridHostRequestRef.current = requestId; + setAllDebridHostLoading(true); + try { + const info = await window.rd.getAllDebridHostInfo(); + if (!mountedRef.current || allDebridHostRequestRef.current !== requestId) { + return; + } + setAllDebridHostInfo(info); + } catch (error) { + if (!mountedRef.current || allDebridHostRequestRef.current !== requestId) { + return; + } + setAllDebridHostInfo(null); + if (!silent) { + showToast(`AllDebrid Status fehlgeschlagen: ${String(error)}`, 3200); + } + } finally { + if (mountedRef.current && allDebridHostRequestRef.current === requestId) { + setAllDebridHostLoading(false); + } + } + }, [showToast]); + + const loadDebridLinkHostLimits = useCallback(async (silent = false): Promise<void> => { + const requestId = debridLinkHostLimitsRequestRef.current + 1; + debridLinkHostLimitsRequestRef.current = requestId; + setDebridLinkHostLimitsLoading(true); + setDebridLinkHostLimitsError(""); + setDebridLinkHostLimits({}); + try { + const apiKeys = parseDebridLinkApiKeys(settingsDraft.debridLinkApiKeys || ""); + if (apiKeys.length === 0) { + throw new Error("Debrid-Link ist nicht konfiguriert"); + } + const limits = await window.rd.getDebridLinkHostLimits(); + if (!mountedRef.current || debridLinkHostLimitsRequestRef.current !== requestId) { + return; + } + setDebridLinkHostLimits( + Object.fromEntries(limits.map((info) => [info.keyId, info])) + ); + } catch (error) { + if (!mountedRef.current || debridLinkHostLimitsRequestRef.current !== requestId) { + return; + } + setDebridLinkHostLimits({}); + setDebridLinkHostLimitsError(String(error)); + if (!silent) { + showToast(`Debrid-Link Quota fehlgeschlagen: ${String(error)}`, 3200); + } + } finally { + if (mountedRef.current && debridLinkHostLimitsRequestRef.current === requestId) { + setDebridLinkHostLimitsLoading(false); + } + } + }, [settingsDraft.debridLinkApiKeys, showToast]); + + useEffect(() => { + if (keyStatsPopup !== "debridlink") { + setDebridLinkHostLimits({}); + setDebridLinkHostLimitsError(""); + setDebridLinkHostLimitsLoading(false); + return; + } + void loadDebridLinkHostLimits(true); + }, [keyStatsPopup, loadDebridLinkHostLimits]); + + const clearImportQueueFocusListener = useCallback((): void => { + const handler = importQueueFocusHandlerRef.current; + if (!handler) { + return; + } + window.removeEventListener("focus", handler); + importQueueFocusHandlerRef.current = null; + }, []); + + useEffect(() => { + document.title = `Multi Debrid Downloader${appVersion ? ` - v${appVersion}` : ""}`; + }, [appVersion]); + + useEffect(() => { + let unsubscribe: (() => void) | null = null; + let unsubClipboard: (() => void) | null = null; + let unsubUpdateInstallProgress: (() => void) | null = null; + void window.rd.getVersion().then((v) => { if (mountedRef.current) { setAppVersion(v); } }).catch(() => undefined); + void window.rd.getTraceConfig().then((config) => { + if (mountedRef.current) { + setSupportTraceEnabled(config.enabled); + } + }).catch(() => undefined); + void window.rd.getSnapshot().then((state) => { + if (!mountedRef.current) { + return; + } + masterSnapshotRef.current = state; + setSnapshot(state); + if (state.settings.columnOrder?.length > 0) { + setColumnOrder(state.settings.columnOrder); + } + setSettingsDraft(state.settings); + settingsDirtyRef.current = false; + panelDirtyRevisionRef.current = 0; + setSettingsDirty(false); + applyTheme(state.settings.theme); + if (state.settings.autoUpdateCheck) { + void window.rd.checkUpdates().then((result) => { + if (!mountedRef.current) { + return; + } + void handleUpdateResult(result, "startup"); + }).catch(() => undefined); + } + }).catch((error) => { + showToast(`Snapshot konnte nicht geladen werden: ${String(error)}`, 2800); + }); + unsubscribe = window.rd.onStateUpdate((wireState) => { + let merged: UiSnapshot; + const master = masterSnapshotRef.current; + if (wireState.payloadKind === "delta" && master) { + const newItems: Record<string, DownloadItem> = { ...master.session.items, ...wireState.session.items }; + if (wireState.removedItemIds && wireState.removedItemIds.length > 0) { + for (const id of wireState.removedItemIds) delete newItems[id]; + } + const newPackages: Record<string, PackageEntry> = { ...master.session.packages, ...wireState.session.packages }; + if (wireState.removedPackageIds && wireState.removedPackageIds.length > 0) { + for (const id of wireState.removedPackageIds) delete newPackages[id]; + } + merged = { + ...wireState, + session: { + ...wireState.session, + items: newItems, + packages: newPackages, + }, + }; + } else { + merged = wireState; + } + masterSnapshotRef.current = merged; + latestStateRef.current = merged; + if (stateFlushTimerRef.current) { return; } + + const itemCount = Object.keys(merged.session.items).length; + let flushDelay = itemCount >= 1500 + ? 900 + : itemCount >= 700 + ? 650 + : itemCount >= 250 + ? 400 + : 150; + if (!merged.session.running) { + flushDelay = Math.min(flushDelay, 200); + } + if (activeTabRef.current !== "downloads") { + flushDelay = Math.max(flushDelay, 800); + } + + stateFlushTimerRef.current = setTimeout(() => { + stateFlushTimerRef.current = null; + if (latestStateRef.current) { + const next = latestStateRef.current; + setSnapshot(next); + if (next.settings.columnOrder?.length > 0) { + setColumnOrder(next.settings.columnOrder); + } + if (!settingsDirtyRef.current) { + setSettingsDraft(next.settings); + } + latestStateRef.current = null; + } + }, flushDelay); + }); + unsubClipboard = window.rd.onClipboardDetected((links) => { + showToast(`Zwischenablage: ${links.length} Link(s) erkannt`, 3000); + setCollectorTabs((prev) => { + const active = prev.find((t) => t.id === activeCollectorTabRef.current) ?? prev[0]; + if (!active) { return prev; } + const newText = active.text ? `${active.text}\n${links.join("\n")}` : links.join("\n"); + return prev.map((t) => t.id === active.id ? { ...t, text: newText } : t); + }); + }); + unsubUpdateInstallProgress = window.rd.onUpdateInstallProgress((progress) => { + if (!mountedRef.current) { + return; + } + setUpdateInstallProgress(progress); + }); + return () => { + mountedRef.current = false; + if (stateFlushTimerRef.current) { clearTimeout(stateFlushTimerRef.current); } + if (toastTimerRef.current) { clearTimeout(toastTimerRef.current); } + if (actionUnlockTimerRef.current) { clearTimeout(actionUnlockTimerRef.current); } + clearImportQueueFocusListener(); + if (startConflictResolverRef.current) { + const resolver = startConflictResolverRef.current; + startConflictResolverRef.current = null; + resolver(null); + } + if (confirmResolverRef.current) { + const resolver = confirmResolverRef.current; + confirmResolverRef.current = null; + resolver(false); + } + while (confirmQueueRef.current.length > 0) { + const request = confirmQueueRef.current.shift(); + request?.resolve(false); + } + stopAccountColumnResize(); + if (unsubscribe) { unsubscribe(); } + if (unsubClipboard) { unsubClipboard(); } + if (unsubUpdateInstallProgress) { unsubUpdateInstallProgress(); } + }; + }, [clearImportQueueFocusListener, stopAccountColumnResize]); + + const downloadsTabActive = tab === "downloads"; + const deferredDownloadSearch = useDeferredValue(downloadSearch); + const downloadSearchQuery = deferredDownloadSearch.trim().toLowerCase(); + const downloadSearchActive = downloadSearchQuery.length > 0; + const gridTemplate = useMemo(() => columnOrder.map((col) => COLUMN_DEFS[col]?.width ?? "100px").join(" "), [columnOrder]); + const totalPackageCount = snapshot.session.packageOrder.length; + const shouldLimitPackageRendering = downloadsTabActive + && snapshot.session.running + && !downloadSearchActive + && totalPackageCount > AUTO_RENDER_PACKAGE_LIMIT + && !showAllPackages; + + const packageIdsForView = useMemo(() => { + if (!downloadsTabActive) { + return [] as string[]; + } + if (downloadSearchActive) { + return snapshot.session.packageOrder; + } + if (shouldLimitPackageRendering) { + return snapshot.session.packageOrder.slice(0, AUTO_RENDER_PACKAGE_LIMIT); + } + return snapshot.session.packageOrder; + }, [downloadsTabActive, downloadSearchActive, shouldLimitPackageRendering, snapshot.session.packageOrder]); + + const packageOrderKey = useMemo(() => { + if (!downloadsTabActive) { + return ""; + } + return packageIdsForView.join("|"); + }, [downloadsTabActive, packageIdsForView]); + + const packages = useMemo(() => { + if (!downloadsTabActive) { + return [] as PackageEntry[]; + } + + if (downloadSearchActive) { + return snapshot.session.packageOrder + .map((id: string) => snapshot.session.packages[id]) + .filter((pkg): pkg is PackageEntry => Boolean(pkg) && pkg.name.toLowerCase().includes(downloadSearchQuery)); + } + + return packageIdsForView + .map((id) => snapshot.session.packages[id]) + .filter((pkg): pkg is PackageEntry => Boolean(pkg)); + }, [downloadsTabActive, downloadSearchActive, downloadSearchQuery, packageIdsForView, snapshot.session.packageOrder, snapshot.session.packages]); + + const packagePosition = useMemo(() => { + if (!downloadsTabActive) { + return new Map<string, number>(); + } + const map = new Map<string, number>(); + snapshot.session.packageOrder.forEach((id, index) => { + map.set(id, index); + }); + return map; + }, [downloadsTabActive, snapshot.session.packageOrder]); + + const itemsByPackage = useMemo(() => { + if (!downloadsTabActive) { + return new Map<string, DownloadItem[]>(); + } + const map = new Map<string, DownloadItem[]>(); + for (const pkg of packages) { + const items = pkg.itemIds + .map((id) => snapshot.session.items[id]) + .filter(Boolean) as DownloadItem[]; + map.set(pkg.id, items); + } + return map; + }, [downloadsTabActive, packageOrderKey, packages, snapshot.session.items]); + + useEffect(() => { + if (!downloadsTabActive) { + return; + } + setCollapsedPackages((prev) => { + let changed = false; + const next: Record<string, boolean> = { ...prev }; + const defaultCollapsed = totalPackageCount >= 24; + for (const packageId of snapshot.session.packageOrder) { + if (!(packageId in prev)) { + next[packageId] = defaultCollapsed; + changed = true; + } + } + for (const packageId of Object.keys(next)) { + if (!snapshot.session.packages[packageId]) { + delete next[packageId]; + changed = true; + } + } + for (const packageId of Array.from(manualCollapsedPkgsRef.current)) { + if (!snapshot.session.packages[packageId]) { + manualCollapsedPkgsRef.current.delete(packageId); + } + } + return changed ? next : prev; + }); + }, [downloadsTabActive, packageOrderKey, snapshot.session.packageOrder, snapshot.session.packages, totalPackageCount]); + + // Prune selection when its packages/items disappear (e.g. via delta-removal or + // a backup-driven session swap). selectedIds holds BOTH package and item ids; + // a stale id would otherwise inflate the selection count and the "(N)" labels. + useEffect(() => { + setSelectedIds((prev) => pruneSelection(prev, snapshot.session)); + }, [snapshot.session.packages, snapshot.session.items]); + + const hiddenPackageCount = shouldLimitPackageRendering + ? Math.max(0, totalPackageCount - packages.length) + : 0; + const sortRelevantItems = (snapshot.session.running && settingsDraft.autoSortPackagesByProgress && packages.length > 1) + ? snapshot.session.items + : null; + const visiblePackages = useMemo(() => { + if (!sortRelevantItems) { + return packages; + } + return sortPackagesForDisplay( + packages, + sortRelevantItems, + true, + true + ); + }, [packages, sortRelevantItems]); + + const hasSavedAllDebridAccount = Boolean(snapshot.settings.allDebridUseWebLogin || snapshot.settings.allDebridToken.trim()); + const allDebridSettingsDirty = snapshot.settings.allDebridUseWebLogin !== settingsDraft.allDebridUseWebLogin + || snapshot.settings.allDebridToken !== settingsDraft.allDebridToken; + + useEffect(() => { + if (!snapshot.session.running) { + setShowAllPackages(false); + } + }, [snapshot.session.running]); + + useEffect(() => { + if (settingsSubTab !== "accounts") { + return; + } + if (!hasSavedAllDebridAccount) { + setAllDebridHostInfo(null); + setAllDebridHostLoading(false); + return; + } + void loadAllDebridHostInfo(true); + }, [settingsSubTab, hasSavedAllDebridAccount, snapshot.settings.allDebridToken, snapshot.settings.allDebridUseWebLogin, loadAllDebridHostInfo]); + + useEffect(() => { + const extractingPkgIds: string[] = []; + const currentlyExtracting = new Set<string>(); + for (const pkg of packages) { + const items = (pkg.itemIds ?? []).map((id) => snapshot.session.items[id]).filter(Boolean); + const isExtracting = items.some((item) => item.fullStatus?.startsWith("Entpacken -") && !item.fullStatus?.includes("Done")); + if (isExtracting) { + currentlyExtracting.add(pkg.id); + if (collapsedPackages[pkg.id] + && !manualCollapsedPkgsRef.current.has(pkg.id) + && !autoExpandedPkgsRef.current.has(pkg.id)) { + extractingPkgIds.push(pkg.id); + autoExpandedPkgsRef.current.add(pkg.id); + } + } + } + for (const id of autoExpandedPkgsRef.current) { + if (!currentlyExtracting.has(id)) { + autoExpandedPkgsRef.current.delete(id); + } + } + if (extractingPkgIds.length > 0) { + setCollapsedPackages((prev) => { + const next = { ...prev }; + for (const id of extractingPkgIds) next[id] = false; + return next; + }); + } + }, [packages, snapshot.session.items, collapsedPackages]); + + const allPackagesCollapsed = useMemo(() => ( + packages.length > 0 && packages.every((pkg) => collapsedPackages[pkg.id]) + ), [packages, collapsedPackages]); + + const configuredProviders = useMemo(() => getActiveProvidersFromSettings(settingsDraft), [settingsDraft]); + + const hasDdownloadAccount = useMemo(() => + Boolean((settingsDraft.ddownloadLogin || "").trim() && (settingsDraft.ddownloadPassword || "").trim()), + [settingsDraft.ddownloadLogin, settingsDraft.ddownloadPassword]); + + const hasOneFichierAccount = useMemo(() => + Boolean((settingsDraft.oneFichierApiKey || "").trim()), + [settingsDraft.oneFichierApiKey]); + + const totalConfiguredAccounts = configuredProviders.length + (hasDdownloadAccount ? 1 : 0) + (hasOneFichierAccount ? 1 : 0); + + const activeProviderOrder = useMemo(() => normalizeProviderOrderForSettings(settingsDraft), [settingsDraft]); + + const setProviderOrder = useCallback((newOrder: DebridProvider[]) => { + settingsDraftRevisionRef.current += 1; + panelDirtyRevisionRef.current += 1; + settingsDirtyRef.current = true; + setSettingsDirty(true); + setSettingsDraft((prev) => ({ + ...prev, + providerOrder: newOrder, + providerPrimary: newOrder[0] ?? prev.providerPrimary, + providerSecondary: newOrder[1] ?? "none", + providerTertiary: newOrder[2] ?? "none" + })); + }, []); + + const onProviderDragStart = useCallback((event: DragEvent<HTMLDivElement>, provider: DebridProvider): void => { + event.dataTransfer.effectAllowed = "move"; + event.dataTransfer.setData("text/plain", provider); + setDraggedProvider(provider); + setProviderDropTarget(provider); + }, []); + + const onProviderDragOver = useCallback((event: DragEvent<HTMLDivElement>, provider: DebridProvider): void => { + event.preventDefault(); + event.dataTransfer.dropEffect = "move"; + if (providerDropTarget !== provider) { + setProviderDropTarget(provider); + } + }, [providerDropTarget]); + + const onProviderDrop = useCallback((event: DragEvent<HTMLDivElement>, provider: DebridProvider): void => { + event.preventDefault(); + if (!draggedProvider || draggedProvider === provider) { + return; + } + const currentOrder = [...activeProviderOrder]; + const fromIndex = currentOrder.indexOf(draggedProvider); + const toIndex = currentOrder.indexOf(provider); + if (fromIndex === -1 || toIndex === -1 || fromIndex === toIndex) { + return; + } + currentOrder.splice(fromIndex, 1); + currentOrder.splice(toIndex, 0, draggedProvider); + setProviderOrder(currentOrder); + setProviderDropTarget(provider); + }, [activeProviderOrder, draggedProvider, setProviderOrder]); + + const onProviderDragEnd = useCallback((): void => { + setDraggedProvider(null); + setProviderDropTarget(null); + }, []); + + const normalizedSettingsDraft: AppSettings = useMemo(() => ({ + ...settingsDraft, + ...normalizeProviderSelectionForSettings(settingsDraft) + }), [settingsDraft]); + + const configuredAccounts = useMemo(() => { + const entries: ConfiguredAccountEntry[] = []; + for (const service of ACCOUNT_SERVICES) { + const kind = getConfiguredAccountKind(settingsDraft, service); + if (!kind) { + continue; + } + const option = findAccountOption(kind); + let statusLabel = "Aktiviert"; + let note = ""; + if (kind === "megadebrid-api") { + const megaAccountCount = parseMegaDebridAccounts(settingsDraft.megaCredentials || "", settingsDraft.megaPassword).length; + statusLabel = megaAccountCount > 1 ? `${megaAccountCount} Accounts` : "Aktiviert"; + note = "Nur API aktiv. Kein Web-Fallback."; + } else if (kind === "megadebrid-web") { + const megaAccountCount = parseMegaDebridAccounts(settingsDraft.megaCredentials || "", settingsDraft.megaPassword).length; + statusLabel = megaAccountCount > 1 ? `${megaAccountCount} Accounts` : "Aktiviert"; + note = "Nur Web aktiv. Kein API-Fallback."; + } else if (kind === "realdebrid-web") { + note = "Login kann bei Bedarf direkt aus der Liste geöffnet werden."; + } else if (kind === "bestdebrid-web") { + note = "Cookie-Import lässt sich direkt aus der Liste erneut starten."; + } else if (service === "alldebrid") { + if (allDebridHostLoading) { + statusLabel = "Lade Status"; + note = "Rapidgator-Status wird aktualisiert."; + } else if (allDebridHostInfo) { + statusLabel = allDebridHostInfo.statusLabel; + note = allDebridHostInfo.note || `Update: ${formatAllDebridTimestamp(allDebridHostInfo)}`; + } else if (hasSavedAllDebridAccount) { + note = "Rapidgator-Status kann direkt aus der Liste geladen werden."; + } + if (allDebridSettingsDirty && hasSavedAllDebridAccount) { + note = "Status basiert auf den zuletzt gespeicherten AllDebrid-Daten."; + } + } + if (kind === "debridlink-api") { + const keyCount = parseDebridLinkApiKeys(settingsDraft.debridLinkApiKeys || "").length; + statusLabel = keyCount > 1 ? `${keyCount} API-Keys` : "Aktiviert"; + } + const provider = getAccountServiceProvider(service); + const dailyUsedBytes = getProviderDailyUsageBytes(snapshot.settings, provider); + const totalUsedBytes = getProviderTotalUsageBytes(snapshot.settings, provider); + const dailyLimitBytes = getProviderDailyLimitBytes(settingsDraft, provider); + const dailyRemainingBytes = getProviderDailyRemainingBytes({ + providerDailyLimitBytes: settingsDraft.providerDailyLimitBytes, + providerDailyUsageBytes: snapshot.settings.providerDailyUsageBytes, + providerDailyUsageDay: snapshot.settings.providerDailyUsageDay + }, provider); + let dailyLimitReached = dailyLimitBytes > 0 && dailyUsedBytes >= dailyLimitBytes; + const isDisabled = (settingsDraft.disabledProviders || []).includes(provider); + const debridLinkKeys = kind === "debridlink-api" + ? parseDebridLinkApiKeys(settingsDraft.debridLinkApiKeys || "").map((key) => { + const keyDailyUsedBytes = getDebridLinkApiKeyDailyUsageBytes(snapshot.settings, key.id); + const keyDailyLimitBytes = getDebridLinkApiKeyDailyLimitBytes(settingsDraft, key.id); + const keyDailyRemainingBytes = getDebridLinkApiKeyDailyRemainingBytes({ + debridLinkApiKeyDailyLimitBytes: settingsDraft.debridLinkApiKeyDailyLimitBytes, + debridLinkApiKeyDailyUsageBytes: snapshot.settings.debridLinkApiKeyDailyUsageBytes, + providerDailyLimitBytes: settingsDraft.providerDailyLimitBytes, + providerDailyUsageBytes: snapshot.settings.providerDailyUsageBytes, + providerDailyUsageDay: snapshot.settings.providerDailyUsageDay + }, key.id); + return { + id: key.id, + label: key.label, + token: key.token, + masked: key.masked, + disabled: (settingsDraft.debridLinkDisabledKeyIds || []).includes(key.id), + dailyUsedBytes: keyDailyUsedBytes, + totalUsedBytes: getDebridLinkApiKeyTotalUsageBytes(snapshot.settings, key.id), + dailyLimitBytes: keyDailyLimitBytes, + dailyRemainingBytes: keyDailyRemainingBytes, + dailyLimitReached: keyDailyLimitBytes > 0 && keyDailyUsedBytes >= keyDailyLimitBytes + }; + }) + : []; + if (kind === "debridlink-api" && debridLinkKeys.length > 0) { + const limitedCount = debridLinkKeys.filter((entry) => entry.dailyLimitReached).length; + const disabledKeyCount = debridLinkKeys.filter((entry) => entry.disabled).length; + const keyNotes: string[] = []; + if (limitedCount > 0) { + keyNotes.push(`${limitedCount}/${debridLinkKeys.length} API-Keys am Limit.`); + } + if (disabledKeyCount > 0) { + keyNotes.push(`${disabledKeyCount}/${debridLinkKeys.length} API-Keys deaktiviert.`); + } + if (keyNotes.length > 0) { + const combinedKeyNote = keyNotes.join(" "); + note = note ? `${combinedKeyNote} ${note}` : combinedKeyNote; + } + if (debridLinkKeys.every((entry) => entry.disabled || entry.dailyLimitReached)) { + dailyLimitReached = true; + } + } + if (dailyLimitReached) { + note = note + ? `Tageslimit erreicht. Neue Links wechseln auf den nächsten Hoster. ${note}` + : "Tageslimit erreicht. Neue Links wechseln auf den nächsten Hoster."; + } + entries.push({ + kind, + service, + provider, + serviceLabel: option.serviceLabel, + modeLabel: option.modeLabel, + statusLabel: isDisabled ? "Deaktiviert" : statusLabel, + summary: summarizeAccount(kind, settingsDraft), + summaryLines: summarizeAccountLines(kind, settingsDraft), + note, + disabled: isDisabled, + dailyUsedBytes, + totalUsedBytes, + dailyLimitBytes, + dailyRemainingBytes, + dailyLimitReached, + debridLinkKeys + }); + } + return entries; + }, [settingsDraft, snapshot.settings, allDebridHostInfo, allDebridHostLoading, hasSavedAllDebridAccount, allDebridSettingsDirty]); + + const configuredAccountServices = useMemo(() => new Set(configuredAccounts.map((entry) => entry.service)), [configuredAccounts]); + + const accountRows = useMemo(() => { + type AccountRow = { + rowKey: string; + entry: ConfiguredAccountEntry; + hosterLabel: string; + modeLabel: string; + username: string; + accountId: string | null; + checkable: boolean; + disabled: boolean; + dailyUsedBytes: number; + dailyLimitBytes: number; + dailyRemainingBytes: number; + totalUsedBytes: number; + toggleKind: "mega" | "dl" | "single"; + megaLogin?: string; + dlKey?: DebridLinkAccountKeyEntry; + }; + const rows: AccountRow[] = []; + for (const entry of configuredAccounts) { + if (entry.kind === "megadebrid-api" || entry.kind === "megadebrid-web") { + const accounts = parseMegaDebridAccounts(settingsDraft.megaCredentials || "", settingsDraft.megaPassword || ""); + for (const acc of accounts) { + const used = getMegaDebridAccountDailyUsageBytes(snapshot.settings, acc.id); + const limit = getMegaDebridAccountDailyLimitBytes(settingsDraft, acc.id); + rows.push({ + rowKey: `mega-${entry.kind}-${acc.id}`, + entry, + hosterLabel: entry.serviceLabel, + modeLabel: entry.modeLabel, + username: acc.maskedLogin, + accountId: acc.id, + checkable: true, + disabled: (settingsDraft.megaDebridDisabledAccountIds || []).includes(acc.id), + dailyUsedBytes: used, + dailyLimitBytes: limit, + dailyRemainingBytes: limit > 0 ? Math.max(0, limit - used) : 0, + totalUsedBytes: getMegaDebridAccountTotalUsageBytes(snapshot.settings, acc.id), + toggleKind: "mega", + megaLogin: acc.login + }); + } + } else if (entry.kind === "debridlink-api") { + for (const key of entry.debridLinkKeys) { + rows.push({ + rowKey: `dl-${key.id}`, + entry, + hosterLabel: entry.serviceLabel, + modeLabel: entry.modeLabel, + username: key.masked, + accountId: key.id, + checkable: true, + disabled: key.disabled, + dailyUsedBytes: key.dailyUsedBytes, + dailyLimitBytes: key.dailyLimitBytes, + dailyRemainingBytes: key.dailyLimitBytes > 0 ? Math.max(0, key.dailyLimitBytes - key.dailyUsedBytes) : 0, + totalUsedBytes: key.totalUsedBytes, + toggleKind: "dl", + dlKey: key + }); + } + } else { + rows.push({ + rowKey: `svc-${entry.service}`, + entry, + hosterLabel: entry.serviceLabel, + modeLabel: entry.modeLabel, + username: entry.summary, + accountId: null, + checkable: false, + disabled: entry.disabled, + dailyUsedBytes: entry.dailyUsedBytes, + dailyLimitBytes: entry.dailyLimitBytes, + dailyRemainingBytes: entry.dailyLimitBytes > 0 ? Math.max(0, entry.dailyRemainingBytes ?? 0) : 0, + totalUsedBytes: entry.totalUsedBytes, + toggleKind: "single" + }); + } + } + return rows; + }, [configuredAccounts, settingsDraft, snapshot.settings]); + + const [accountStatusSort, setAccountStatusSort] = useState<"none" | "desc" | "asc">("none"); + const cycleAccountStatusSort = (): void => setAccountStatusSort((s) => (s === "none" ? "desc" : s === "desc" ? "asc" : "none")); + + const groupedAccountRows = useMemo(() => { + const groups: { service: string; serviceLabel: string; rows: typeof accountRows }[] = []; + const byService = new Map<string, { service: string; serviceLabel: string; rows: typeof accountRows }>(); + for (const row of accountRows) { + const key = row.entry.service; + let group = byService.get(key); + if (!group) { + group = { service: key, serviceLabel: row.hosterLabel, rows: [] }; + byService.set(key, group); + groups.push(group); + } + group.rows.push(row); + } + if (accountStatusSort !== "none") { + const statuses = snapshot.settings?.debridAccountStatuses || {}; + const now = Date.now(); + const remainingMs = (row: (typeof accountRows)[number]): number => { + const st = row.accountId ? statuses[row.accountId] : null; + return st && st.premiumUntilMs && st.premiumUntilMs > now ? st.premiumUntilMs - now : -1; + }; + for (const group of groups) { + group.rows = [...group.rows].sort((a, b) => { + const ra = remainingMs(a); + const rb = remainingMs(b); + if (ra > 0 && rb > 0) return accountStatusSort === "desc" ? rb - ra : ra - rb; + if (ra > 0) return -1; + if (rb > 0) return 1; + return 0; + }); + } + } + return groups; + }, [accountRows, accountStatusSort, snapshot.settings]); + + const [collapsedAccountGroups, setCollapsedAccountGroups] = useState<Set<string>>(() => { + try { + const raw = localStorage.getItem("rd-account-collapsed-groups-v1"); + if (raw) return new Set(JSON.parse(raw) as string[]); + } catch { /* ignore */ } + return new Set<string>(); + }); + + const toggleAccountGroup = (service: string): void => { + setCollapsedAccountGroups((prev) => { + const next = new Set(prev); + if (next.has(service)) next.delete(service); else next.add(service); + try { localStorage.setItem("rd-account-collapsed-groups-v1", JSON.stringify([...next])); } catch { /* ignore */ } + return next; + }); + }; + + const renderAccountRow = (row: (typeof accountRows)[number], isMember: boolean): ReactElement => { + const st = row.accountId ? (snapshot.settings?.debridAccountStatuses?.[row.accountId] ?? null) : null; + const checking = row.accountId ? megaCheckingIds.has(row.accountId) : false; + let statusCls = "none"; + let statusText = "—"; + if (row.disabled) { statusCls = "disabled"; statusText = "Deaktiviert"; } + else if (!row.checkable) { statusCls = "none"; statusText = "—"; } + else if (checking) { statusCls = "unknown"; statusText = "Prüfe…"; } + else if (!st) { statusCls = "unknown"; statusText = "Noch nicht geprüft"; } + else if (!st.valid) { statusCls = "invalid"; statusText = st.message || "Login ungültig"; } + else if (!st.isPremium) { statusCls = "free"; statusText = "Free Account"; } + else { statusCls = "ok"; statusText = st.message || "Premium Account"; } + const isProblem = statusCls === "invalid"; + const username = row.username; + const usernameTitle = st && st.email && st.email.toLowerCase() !== row.username.toLowerCase() ? `${row.username} (Mail: ${st.email})` : row.username; + const expiry = st && st.premiumUntilMs && st.premiumUntilMs > 0 ? new Date(st.premiumUntilMs).toLocaleDateString("de-DE") : "—"; + const traffic = row.dailyLimitBytes > 0 + ? `${humanSize(row.dailyRemainingBytes)} von ${humanSize(row.dailyLimitBytes)} übrig` + : "Unbeschränkt"; + return ( + <div key={row.rowKey} className={`acct2-row${row.disabled ? " acct2-disabled" : ""}${isProblem ? " acct2-problem" : ""}${isMember ? " acct2-member" : ""}`}> + <span className="acct2-c-check"> + <input + type="checkbox" + checked={!row.disabled} + disabled={actionBusy} + title={row.disabled ? "Account aktivieren" : "Account deaktivieren (bleibt gespeichert)"} + onChange={() => { + if (row.toggleKind === "mega" && row.megaLogin) { void onToggleMegaAccountEnabled(row.megaLogin, row.disabled); } + else if (row.toggleKind === "dl" && row.dlKey) { void onToggleDebridLinkApiKeyEnabled(row.entry, row.dlKey); } + else { void onToggleAccountEnabled(row.entry); } + }} + /> + </span> + <span className="acct2-hoster"> + <strong>{row.hosterLabel}</strong> + <span className="acct2-mode">{row.modeLabel}</span> + </span> + <span className="acct2-traffic">{traffic}</span> + <span className="acct2-status"> + {statusCls === "none" + ? <span className="acct2-nostatus" title="Für diesen Anbieter gibt es keine Status-Prüfung">—</span> + : <span className={`account-validity-badge ${statusCls}`}>{statusText}</span>} + </span> + <span className="acct2-user" title={usernameTitle}>{username}</span> + <span className="acct2-expiry">{expiry}</span> + <span className="acct2-c-actions"> + {row.toggleKind === "single" && getAccountQuickActionMeta(row.entry.kind) && ( + <button className="btn btn-sm" disabled={actionBusy} onClick={() => { void onAccountRowQuickAction(row.entry); }}> + {getAccountQuickActionMeta(row.entry.kind)?.label} + </button> + )} + <button className="btn btn-sm" disabled={actionBusy} title="Account bearbeiten" onClick={() => openEditAccountDialog(row.entry.kind)}>Bearbeiten</button> + <button className="btn btn-sm danger" disabled={actionBusy} title="Account entfernen" onClick={() => { + if (row.toggleKind === "mega" && row.megaLogin) { void onRemoveMegaAccount(row.megaLogin); } + else if (row.toggleKind === "dl" && row.dlKey) { void onRemoveDebridLinkKey(row.dlKey); } + else { void onRemoveAccount(row.entry); } + }}>Entfernen</button> + </span> + </div> + ); + }; + const availableAccountOptions = useMemo(() => ( + ACCOUNT_OPTIONS.filter((option) => !configuredAccountServices.has(option.service)) + ), [configuredAccountServices]); + const accountDialogOption = accountDialog?.kind ? findAccountOption(accountDialog.kind) : null; + const accountDialogSelectableOptions = useMemo(() => { + if (!accountDialog) { + return []; + } + if (accountDialog.mode === "edit") { + if (!accountDialogOption) { + return []; + } + return ACCOUNT_OPTIONS.filter((option) => option.service === accountDialogOption.service); + } + return availableAccountOptions; + }, [accountDialog, accountDialogOption, availableAccountOptions]); + const accountDialogSearchQuery = accountDialogSearch.trim().toLowerCase(); + const filteredAccountDialogOptions = useMemo(() => ( + accountDialogSelectableOptions.filter((option) => { + if (!accountDialogSearchQuery) { + return true; + } + const haystack = [ + option.title, + option.serviceLabel, + option.modeLabel, + option.pickerDescription, + getAccountPickerFunctionLabel(option) + ].join(" ").toLowerCase(); + return haystack.includes(accountDialogSearchQuery); + }) + ), [accountDialogSearchQuery, accountDialogSelectableOptions]); + const accountTableStyle = useMemo(() => ({ + "--account-col-service": `${accountColumnWidths.service}px`, + "--account-col-mode": `${accountColumnWidths.mode}px`, + "--account-col-status": `${accountColumnWidths.status}px`, + "--account-col-secret": `${accountColumnWidths.secret}px` + } as CSSProperties), [accountColumnWidths]); + + const handleUpdateResult = async (result: UpdateCheckResult, source: "manual" | "startup"): Promise<void> => { + if (!mountedRef.current) { + return; + } + if (result.error) { + if (source === "manual") { showToast(`Update-Check fehlgeschlagen: ${result.error}`, 2800); } + return; + } + if (!result.updateAvailable) { + setUpdateInstallProgress(null); + if (source === "manual") { showToast(`Kein Update verfügbar (v${result.currentVersion})`, 2000); } + return; + } + let changelogText = ""; + if (result.releaseNotes) { + changelogText = result.releaseNotes + .split("\n") + .filter((line) => !/^#{1,6}\s/.test(line)) + .map((line) => line + .replace(/\*\*([^*]+)\*\*/g, "$1") + .replace(/\*([^*]+)\*/g, "$1") + .replace(/`([^`]+)`/g, "$1")) + .join("\n") + .replace(/\n{3,}/g, "\n\n") + .trim(); + } + const approved = await askConfirmPrompt({ + title: "Update verfügbar", + message: `${result.latestTag} (aktuell v${result.currentVersion})\n\nJetzt automatisch herunterladen und installieren?`, + confirmLabel: "Jetzt installieren", + details: changelogText || undefined + }); + if (!mountedRef.current) { + return; + } + if (!approved) { showToast(`Update verfügbar: ${result.latestTag}`, 2600); return; } + setUpdateInstallProgress({ + stage: "starting", + percent: 0, + downloadedBytes: 0, + totalBytes: null, + message: "Update wird vorbereitet" + }); + const install = await window.rd.installUpdate(); + if (!mountedRef.current) { + return; + } + if (install.started) { showToast("Stilles Update gestartet - App wird neu gestartet", 2600); return; } + setUpdateInstallProgress({ + stage: "error", + percent: null, + downloadedBytes: 0, + totalBytes: null, + message: install.message + }); + showToast(`Auto-Update fehlgeschlagen: ${install.message}`, 3200); + }; + + const onSaveSettings = async (): Promise<void> => { + await performQuickAction(async () => { + const result = await persistDraftSettings(); + applyTheme(result.theme); + showToast("Einstellungen gespeichert", 1800); + }, (error) => { + showToast(`Einstellungen konnten nicht gespeichert werden: ${String(error)}`, 2800); + }); + }; + + const onOpenRealDebridLogin = async (): Promise<void> => { + await performQuickAction(async () => { + await persistDraftSettings(); + await window.rd.openRealDebridLogin(); + showToast("Real-Debrid Login-Fenster geöffnet", 2200); + }, (error) => { + showToast(`Real-Debrid Login fehlgeschlagen: ${String(error)}`, 2800); + }); + }; + + const onOpenAllDebridLogin = async (): Promise<void> => { + await performQuickAction(async () => { + await persistDraftSettings(); + await window.rd.openAllDebridLogin(); + showToast("AllDebrid Login-Fenster geöffnet", 2200); + }, (error) => { + showToast(`AllDebrid Login fehlgeschlagen: ${String(error)}`, 2800); + }); + }; + + const onImportBestDebridCookies = async (): Promise<void> => { + await performQuickAction(async () => { + await persistDraftSettings(); + const count = await window.rd.importBestDebridCookies(); + if (count > 0) { + showToast(`${count} BestDebrid-Cookies importiert`, 2200); + } else { + showToast("Keine Cookie-Datei ausgewählt", 2200); + } + }, (error) => { + showToast(`BestDebrid Cookie-Import fehlgeschlagen: ${String(error)}`, 2800); + }); + }; + + const applyPersistedSettings = (result: AppSettings): void => { + setSettingsDraft(result); + settingsDirtyRef.current = false; + panelDirtyRevisionRef.current = 0; + setSettingsDirty(false); + applyTheme(result.theme); + }; + + const syncLiveProviderUsageSettings = (result: AppSettings): void => { + setSnapshot((prev) => ({ ...prev, settings: result })); + if (!settingsDirtyRef.current) { + applyPersistedSettings(result); + return; + } + setSettingsDraft((prev) => ({ + ...prev, + totalDownloadedAllTime: Math.max(prev.totalDownloadedAllTime, result.totalDownloadedAllTime), + providerDailyUsageDay: result.providerDailyUsageDay, + providerDailyUsageBytes: { ...(result.providerDailyUsageBytes || {}) }, + providerTotalUsageBytes: { ...(result.providerTotalUsageBytes || {}) }, + debridLinkApiKeyDailyUsageBytes: { ...(result.debridLinkApiKeyDailyUsageBytes || {}) }, + debridLinkApiKeyTotalUsageBytes: { ...(result.debridLinkApiKeyTotalUsageBytes || {}) } + })); + }; + + const persistSpecificSettings = async (nextDraft: AppSettings): Promise<AppSettings> => { + const normalizedDraft = { + ...nextDraft, + ...normalizeProviderSelectionForSettings(nextDraft) + }; + const result = await window.rd.updateSettings(normalizedDraft); + applyPersistedSettings(result); + return result; + }; + + const runAccountQuickAction = async (action: AccountQuickAction): Promise<void> => { + switch (action) { + case "realdebrid-login": + await window.rd.openRealDebridLogin(); + showToast("Real-Debrid Login-Fenster geöffnet", 2200); + return; + case "bestdebrid-cookies": { + const count = await window.rd.importBestDebridCookies(); + showToast(count > 0 ? `${count} BestDebrid-Cookies importiert` : "Keine Cookie-Datei ausgewählt", 2200); + return; + } + case "alldebrid-login": + await window.rd.openAllDebridLogin(); + showToast("AllDebrid Login-Fenster geöffnet", 2200); + return; + case "alldebrid-status": + await loadAllDebridHostInfo(false); + return; + default: + return; + } + }; + + const getAccountQuickActionMeta = (kind: AccountKind): { label: string; action: AccountQuickAction } | null => { + switch (kind) { + case "realdebrid-web": + return { label: "Login", action: "realdebrid-login" }; + case "bestdebrid-web": + return { label: "Cookies", action: "bestdebrid-cookies" }; + case "alldebrid-api": + return { label: "Status", action: "alldebrid-status" }; + case "alldebrid-web": + return { label: "Login", action: "alldebrid-login" }; + default: + return null; + } + }; + + const checkAllAccounts = useCallback(async (): Promise<void> => { + setAccountCheckBusy(true); + try { + const statuses = await window.rd.checkDebridAccounts(); + if (!statuses || statuses.length === 0) { + showToast("Keine Mega-Debrid-/Debrid-Link-Accounts zum Prüfen konfiguriert.", 3200); + } else { + const valid = statuses.filter((st) => st.valid).length; + const premium = statuses.filter((st) => st.isPremium).length; + showToast(`Account-Check: ${valid}/${statuses.length} Login gültig, ${premium} mit Premium.`, 3600); + } + } catch (error) { + showToast(`Account-Check fehlgeschlagen: ${String(error)}`, 3600); + } finally { + setAccountCheckBusy(false); + } + }, [showToast]); + + const runMegaAccountCheck = useCallback(async (login: string, password: string): Promise<void> => { + const trimmedLogin = login.trim(); + const trimmedPassword = password.trim(); + if (!trimmedLogin || !trimmedPassword) return; + const accId = getMegaDebridAccountId(trimmedLogin); + setMegaCheckingIds((prev) => { const next = new Set(prev); next.add(accId); return next; }); + try { + const status = await window.rd.checkMegaDebridAccount(trimmedLogin, trimmedPassword); + if (status) { + showToast(status.valid ? `Account geprüft — ${status.message}` : `Account ungültig — ${status.message}`, 3200); + } + } catch (error) { + showToast(`Account-Check fehlgeschlagen: ${String(error)}`, 3200); + } finally { + setMegaCheckingIds((prev) => { const next = new Set(prev); next.delete(accId); return next; }); + } + }, [showToast]); + + const openCreateAccountDialog = (): void => { + setAccountDialogSearch(""); + setAccountDialog(createAccountDialogState("create", null, settingsDraft)); + }; + + const openEditAccountDialog = (kind: AccountKind): void => { + setAccountDialogSearch(""); + setAccountDialog(createAccountDialogState("edit", kind, settingsDraft)); + }; + + const updateAccountDialogKind = (kind: AccountKind): void => { + setAccountDialog((prev) => { + const next = createAccountDialogState(prev?.mode ?? "create", kind, settingsDraft); + if (!prev) { + return next; + } + if (findAccountOption(kind).needsToken) { + next.token = prev.token; + } + if (findAccountOption(kind).needsCredentials) { + next.login = prev.login; + next.password = prev.password; + } + if (kind === "megadebrid-api" || kind === "megadebrid-web") { + next.megaAccounts = prev.megaAccounts; + next.megaNewLogin = prev.megaNewLogin; + next.megaNewPassword = prev.megaNewPassword; + } + next.dailyLimitGb = prev.dailyLimitGb; + return next; + }); + }; + + const closeAccountDialog = useCallback((): void => { + setAccountDialog(null); + setAccountDialogSearch(""); + }, []); + + const onSaveAccountDialog = async (quickAction?: AccountQuickAction): Promise<void> => { + if (!accountDialog) { + return; + } + const validationError = validateAccountDialog(accountDialog); + if (validationError) { + showToast(validationError, 2800); + return; + } + const dialogSnapshot = accountDialog; + const selectedOption = dialogSnapshot.kind ? findAccountOption(dialogSnapshot.kind) : null; + await performQuickAction(async () => { + const nextDraft = applyAccountDialogToSettings(settingsDraft, dialogSnapshot); + await persistSpecificSettings(nextDraft); + closeAccountDialog(); + if (quickAction) { + await runAccountQuickAction(quickAction); + } else if (selectedOption) { + showToast(`${selectedOption.title} gespeichert`, 2200); + } + void checkAllAccounts(); + }, (error) => { + showToast(`Account konnte nicht gespeichert werden: ${String(error)}`, 3200); + }); + }; + + const onRemoveAccount = async (entry: ConfiguredAccountEntry): Promise<void> => { + const confirmed = await askConfirmPrompt({ + title: `${entry.serviceLabel} entfernen`, + message: `Soll ${entry.serviceLabel} wirklich aus der Accountliste entfernt werden?`, + confirmLabel: "Entfernen", + danger: true + }); + if (!confirmed) { + return; + } + await performQuickAction(async () => { + const nextDraft = clearAccountServiceFromSettings(settingsDraft, entry.service); + await persistSpecificSettings(nextDraft); + if (entry.service === "alldebrid") { + setAllDebridHostInfo(null); + } + showToast(`${entry.serviceLabel} entfernt`, 2200); + }, (error) => { + showToast(`Account konnte nicht entfernt werden: ${String(error)}`, 3200); + }); + }; + + const onResetAccountDailyUsage = async (entry: ConfiguredAccountEntry): Promise<void> => { + await performQuickAction(async () => { + const result = await window.rd.resetProviderDailyUsage(getAccountServiceProvider(entry.service)); + syncLiveProviderUsageSettings(result); + showToast(`${entry.serviceLabel}: Tageszähler zurückgesetzt`, 2200); + }, (error) => { + showToast(`${entry.serviceLabel}: Reset fehlgeschlagen: ${String(error)}`, 3200); + }); + }; + + const onResetDebridLinkApiKeyDailyUsage = async (entry: ConfiguredAccountEntry, keyId: string, keyLabel: string): Promise<void> => { + await performQuickAction(async () => { + const result = await window.rd.resetDebridLinkApiKeyDailyUsage(keyId); + syncLiveProviderUsageSettings(result); + showToast(`${entry.serviceLabel} ${keyLabel}: Tageszähler zurückgesetzt`, 2200); + }, (error) => { + showToast(`${entry.serviceLabel} ${keyLabel}: Reset fehlgeschlagen: ${String(error)}`, 3200); + }); + }; + + const onToggleDebridLinkApiKeyEnabled = async (entry: ConfiguredAccountEntry, key: DebridLinkAccountKeyEntry): Promise<void> => { + await performQuickAction(async () => { + const currentDisabledIds = settingsDraft.debridLinkDisabledKeyIds || []; + const nextDisabledIds = key.disabled + ? currentDisabledIds.filter((existingId) => existingId !== key.id) + : [...currentDisabledIds, key.id]; + const nextDraft: AppSettings = { + ...settingsDraft, + debridLinkDisabledKeyIds: nextDisabledIds + }; + await persistSpecificSettings(nextDraft); + showToast( + key.disabled + ? `${entry.serviceLabel} ${key.label} aktiviert` + : `${entry.serviceLabel} ${key.label} deaktiviert`, + 2200 + ); + }, (error) => { + showToast(`${entry.serviceLabel} ${key.label}: Umschalten fehlgeschlagen: ${String(error)}`, 3200); + }); + }; + + const onAccountRowQuickAction = async (entry: ConfiguredAccountEntry): Promise<void> => { + const meta = getAccountQuickActionMeta(entry.kind); + if (!meta) { + return; + } + await performQuickAction(async () => { + await runAccountQuickAction(meta.action); + }, (error) => { + showToast(`${entry.serviceLabel}: Aktion fehlgeschlagen: ${String(error)}`, 3200); + }); + }; + + const onToggleMegaAccountEnabled = async (login: string, currentlyDisabled: boolean): Promise<void> => { + const accId = getMegaDebridAccountId(login.trim()); + await performQuickAction(async () => { + const current = settingsDraft.megaDebridDisabledAccountIds || []; + const next = currentlyDisabled ? current.filter((id) => id !== accId) : [...current, accId]; + await persistSpecificSettings({ ...settingsDraft, megaDebridDisabledAccountIds: next }); + showToast(currentlyDisabled ? "Account aktiviert" : "Account deaktiviert", 2000); + }, (error) => { + showToast(`Umschalten fehlgeschlagen: ${String(error)}`, 3200); + }); + }; + + const onRemoveMegaAccount = async (login: string): Promise<void> => { + const confirmed = await askConfirmPrompt({ title: "Account entfernen", message: `Soll der Mega-Debrid-Account ${maskMegaDebridLogin(login)} wirklich entfernt werden?`, confirmLabel: "Entfernen", danger: true }); + if (!confirmed) return; + await performQuickAction(async () => { + const remaining = parseMegaDebridAccounts(settingsDraft.megaCredentials || "", settingsDraft.megaPassword || "") + .filter((a) => a.login.trim().toLowerCase() !== login.trim().toLowerCase()); + const nextCreds = serializeMegaDebridAccounts(remaining.map((a) => ({ login: a.login, password: a.password }))); + await persistSpecificSettings({ ...settingsDraft, megaCredentials: nextCreds, megaLogin: remaining[0]?.login ?? "", megaPassword: remaining[0]?.password ?? "" }); + showToast("Account entfernt", 2000); + }, (error) => { showToast(`Entfernen fehlgeschlagen: ${String(error)}`, 3200); }); + }; + + const onRemoveDebridLinkKey = async (key: DebridLinkAccountKeyEntry): Promise<void> => { + const confirmed = await askConfirmPrompt({ title: "Key entfernen", message: `Soll der Debrid-Link-Key ${key.masked} wirklich entfernt werden?`, confirmLabel: "Entfernen", danger: true }); + if (!confirmed) return; + await performQuickAction(async () => { + const remaining = parseDebridLinkApiKeys(settingsDraft.debridLinkApiKeys || "").filter((k) => k.id !== key.id); + await persistSpecificSettings({ ...settingsDraft, debridLinkApiKeys: remaining.map((k) => k.token).join("\n") }); + showToast("Key entfernt", 2000); + }, (error) => { showToast(`Entfernen fehlgeschlagen: ${String(error)}`, 3200); }); + }; + + const onToggleAccountEnabled = async (entry: ConfiguredAccountEntry): Promise<void> => { + await performQuickAction(async () => { + const provider = entry.service as DebridProvider; + const current = settingsDraft.disabledProviders || []; + const nextDisabledProviders = current.includes(provider) + ? current.filter((existing) => existing !== provider) + : [...current, provider]; + const nextDraft: AppSettings = { + ...settingsDraft, + disabledProviders: nextDisabledProviders + }; + await persistSpecificSettings(nextDraft); + showToast( + nextDisabledProviders.includes(provider) + ? `${entry.serviceLabel} deaktiviert` + : `${entry.serviceLabel} aktiviert`, + 2200 + ); + }, (error) => { + showToast(`${entry.serviceLabel} konnte nicht umgeschaltet werden: ${String(error)}`, 3200); + }); + }; + + const onCheckUpdates = async (): Promise<void> => { + let updateResult: UpdateCheckResult | null = null; + await performQuickAction(async () => { + setUpdateInstallProgress(null); + updateResult = await window.rd.checkUpdates(); + }, (error) => { + showToast(`Update-Check fehlgeschlagen: ${String(error)}`, 2800); + }); + if (updateResult) await handleUpdateResult(updateResult, "manual"); + }; + + const persistDraftSettings = async (): Promise<AppSettings> => { + const revisionAtStart = settingsDraftRevisionRef.current; + const result = await window.rd.updateSettings(normalizedSettingsDraft); + if (settingsDraftRevisionRef.current === revisionAtStart) { + applyPersistedSettings(result); + } + return result; + }; + + const closeStartConflictPrompt = (result: { policy: Extract<DuplicatePolicy, "skip" | "overwrite">; applyToAll: boolean } | null): void => { + const resolver = startConflictResolverRef.current; + startConflictResolverRef.current = null; + setStartConflictPrompt(null); + if (resolver) { + resolver(result); + } + }; + + const askStartConflictDecision = (entry: StartConflictEntry): Promise<{ policy: Extract<DuplicatePolicy, "skip" | "overwrite">; applyToAll: boolean } | null> => { + return new Promise((resolve) => { + startConflictResolverRef.current = resolve; + setStartConflictPrompt({ + entry, + applyToAll: false + }); + }); + }; + + const pumpConfirmQueue = useCallback((): void => { + if (confirmResolverRef.current) { + return; + } + const next = confirmQueueRef.current.shift(); + if (!next) { + return; + } + confirmResolverRef.current = next.resolve; + setConfirmPrompt(next.prompt); + }, []); + + const closeConfirmPrompt = useCallback((confirmed: boolean): void => { + const resolver = confirmResolverRef.current; + confirmResolverRef.current = null; + setConfirmPrompt(null); + if (resolver) { + resolver(confirmed); + } + pumpConfirmQueue(); + }, [pumpConfirmQueue]); + + const askConfirmPrompt = useCallback((prompt: ConfirmPromptState): Promise<boolean> => { + return new Promise((resolve) => { + confirmQueueRef.current.push({ prompt, resolve }); + pumpConfirmQueue(); + }); + }, [pumpConfirmQueue]); + + const onStartDownloads = async (): Promise<void> => { + await performQuickAction(async () => { + if (totalConfiguredAccounts === 0) { + setTab("settings"); + showToast("Bitte zuerst mindestens einen Hoster-Account eintragen", 3000); + return; + } + + await persistDraftSettings(); + const conflicts = await window.rd.getStartConflicts(); + let skipped = 0; + let overwritten = 0; + let rememberedPolicy: Extract<DuplicatePolicy, "skip" | "overwrite"> | null = null; + + if (settingsDraft.autoSkipExtracted && conflicts.length > 0) { + rememberedPolicy = "skip"; + } + + for (const conflict of conflicts) { + let decisionPolicy = rememberedPolicy; + if (!decisionPolicy) { + const decision = await askStartConflictDecision(conflict); + if (!decision) { + showToast("Start abgebrochen", 1800); + return; + } + decisionPolicy = decision.policy; + if (decision.applyToAll) { + rememberedPolicy = decision.policy; + } + } + + const result = await window.rd.resolveStartConflict(conflict.packageId, decisionPolicy); + if (result.skipped) { + skipped += 1; + } + if (result.overwritten) { + overwritten += 1; + } + } + + if (conflicts.length > 0 && !settingsDraft.autoSkipExtracted) { + showToast(`Konflikte gelöst: ${overwritten} überschrieben, ${skipped} übersprungen`, 2800); + } + + await window.rd.start(); + }); + }; + + const collapseNewPackages = async (existingIds: Set<string>): Promise<void> => { + const fresh = await window.rd.getSnapshot(); + const newIds = Object.keys(fresh.session.packages).filter((id) => !existingIds.has(id)); + if (newIds.length > 0) { + setCollapsedPackages((prev) => { + const next = { ...prev }; + for (const id of newIds) { next[id] = true; } + return next; + }); + } + }; + + const onAddLinks = async (): Promise<void> => { + await performQuickAction(async () => { + const activeId = activeCollectorTabRef.current; + const active = collectorTabsRef.current.find((t) => t.id === activeId) ?? collectorTabsRef.current[0]; + const rawText = active?.text ?? ""; + const persisted = await persistDraftSettings(); + const existingIds = new Set(Object.keys(snapshotRef.current.session.packages)); + const result = await window.rd.addLinks({ rawText, packageName: persisted.packageName }); + if (result.addedLinks > 0) { + showToast(`${result.addedPackages} Paket(e), ${result.addedLinks} Link(s) hinzugefügt`); + setCollectorTabs((prev) => prev.map((t) => t.id === activeId ? { ...t, text: "" } : t)); + if (snapshotRef.current.settings.collapseNewPackages) { await collapseNewPackages(existingIds); } + } else { + showToast("Keine gültigen Links gefunden"); + } + }, (error) => { + showToast(`Fehler beim Hinzufügen: ${String(error)}`, 2600); + }); + }; + + const onImportDlc = async (): Promise<void> => { + await performQuickAction(async () => { + const files = await window.rd.pickContainers(); + if (files.length === 0) { return; } + await persistDraftSettings(); + const existingIds = new Set(Object.keys(snapshotRef.current.session.packages)); + const result = await window.rd.addContainers(files); + if (result.addedLinks > 0) { + showToast(`DLC importiert: ${result.addedPackages} Paket(e), ${result.addedLinks} Link(s)`); + if (snapshotRef.current.settings.collapseNewPackages) { await collapseNewPackages(existingIds); } + } else { + showToast("Keine gültigen Links in den DLC-Dateien gefunden", 3000); + } + }, (error) => { + showToast(`Fehler beim DLC-Import: ${String(error)}`, 2600); + }); + }; + + const onExportPackageSelection = async (packageIds: string[]): Promise<void> => { + closeMenus(); + await performQuickAction(async () => { + const result = await window.rd.exportPackageSelection(packageIds); + if (result.saved) { + showToast(`${result.packageCount} Paket(e), ${result.linkCount} Link(s) exportiert`, 2800); + } + }, (error) => { + showToast(`Export fehlgeschlagen: ${String(error)}`, 2600); + }); + }; + + const onExportItemSelection = async (itemIds: string[]): Promise<void> => { + closeMenus(); + await performQuickAction(async () => { + const result = await window.rd.exportItemSelection(itemIds); + if (result.saved) { + showToast(`${result.packageCount} Paket(e), ${result.linkCount} Link(s) exportiert`, 2800); + } + }, (error) => { + showToast(`Export fehlgeschlagen: ${String(error)}`, 2600); + }); + }; + + onImportDlcRef.current = onImportDlc; + + const onDrop = async (event: DragEvent<HTMLElement>): Promise<void> => { + event.preventDefault(); + dragDepthRef.current = 0; + dragOverRef.current = false; + setDragOver(false); + const hasFiles = event.dataTransfer.types.includes("Files"); + const hasUri = event.dataTransfer.types.includes("text/uri-list"); + if (!hasFiles && !hasUri) { return; } + const files = Array.from(event.dataTransfer.files ?? []) as File[]; + const dlc = files.filter((f) => f.name.toLowerCase().endsWith(".dlc")).map((f) => (f as unknown as { path?: string }).path).filter((v): v is string => !!v); + const importFiles = files.filter((f) => /\.(json|txt)$/i.test(f.name)); + const droppedText = event.dataTransfer.getData("text/plain") || event.dataTransfer.getData("text/uri-list") || ""; + if (dlc.length > 0) { + await performQuickAction(async () => { + await persistDraftSettings(); + const existingIds = new Set(Object.keys(snapshotRef.current.session.packages)); + const result = await window.rd.addContainers(dlc); + if (result.addedLinks > 0) { + showToast(`Drag-and-Drop: ${result.addedPackages} Paket(e), ${result.addedLinks} Link(s)`); + if (snapshotRef.current.settings.collapseNewPackages) { await collapseNewPackages(existingIds); } + } else { + showToast("Keine gültigen Links in den DLC-Dateien gefunden", 3000); + } + }, (error) => { + showToast(`Fehler bei Drag-and-Drop: ${String(error)}`, 2600); + }); + } else if (importFiles.length > 0) { + await performQuickAction(async () => { + await persistDraftSettings(); + const existingIds = new Set(Object.keys(snapshotRef.current.session.packages)); + let addedPackages = 0; + let addedLinks = 0; + for (const file of importFiles) { + const text = await file.text(); + const result = await window.rd.importQueue(text); + addedPackages += result.addedPackages; + addedLinks += result.addedLinks; + } + if (addedLinks > 0) { + showToast(`Importiert: ${addedPackages} Paket(e), ${addedLinks} Link(s)`); + if (snapshotRef.current.settings.collapseNewPackages) { await collapseNewPackages(existingIds); } + } else { + showToast("Keine gültigen Links in den Import-Dateien gefunden", 3000); + } + }, (error) => { + showToast(`Fehler bei Drag-and-Drop: ${String(error)}`, 2600); + }); + } else if (droppedText.trim()) { + const activeCollectorId = activeCollectorTabRef.current; + setCollectorTabs((prev) => prev.map((t) => t.id === activeCollectorId + ? { ...t, text: t.text ? `${t.text}\n${droppedText}` : droppedText } : t)); + setTab("collector"); + showToast("Links per Drag-and-Drop eingefügt"); + } + }; + + const onExportQueue = async (): Promise<void> => { + await performQuickAction(async () => { + const result = await window.rd.exportQueue(); + if (result.saved) { + showToast("Queue exportiert"); + } + }, (error) => { + showToast(`Export fehlgeschlagen: ${String(error)}`, 2600); + }); + }; + + const onImportQueue = async (): Promise<void> => { + if (actionBusyRef.current) { + return; + } + + actionBusyRef.current = true; + setActionBusy(true); + + const input = document.createElement("input"); + input.type = "file"; + input.accept = ".json,.txt"; + + const releasePickerBusy = (): void => { + actionBusyRef.current = false; + setActionBusy(false); + }; + + const onWindowFocus = (): void => { + clearImportQueueFocusListener(); + if (!input.files || input.files.length === 0) { + releasePickerBusy(); + } + }; + + input.onchange = async () => { + clearImportQueueFocusListener(); + const file = input.files?.[0]; + if (!file) { + releasePickerBusy(); + return; + } + releasePickerBusy(); + await performQuickAction(async () => { + await persistDraftSettings(); + const existingIds = new Set(Object.keys(snapshotRef.current.session.packages)); + const text = await file.text(); + const result = await window.rd.importQueue(text); + if (result.addedLinks > 0) { + showToast(`Importiert: ${result.addedPackages} Paket(e), ${result.addedLinks} Link(s)`); + if (snapshotRef.current.settings.collapseNewPackages) { await collapseNewPackages(existingIds); } + } else { + showToast("Keine gültigen Links in der Datei gefunden", 3000); + } + }, (error) => { + showToast(`Import fehlgeschlagen: ${String(error)}`, 2600); + }); + }; + + clearImportQueueFocusListener(); + importQueueFocusHandlerRef.current = onWindowFocus; + window.addEventListener("focus", onWindowFocus, { once: true }); + input.click(); + }; + + const setBool = (key: keyof AppSettings, value: boolean): void => { + settingsDraftRevisionRef.current += 1; + panelDirtyRevisionRef.current += 1; + settingsDirtyRef.current = true; + setSettingsDirty(true); + setSettingsDraft((prev) => ({ ...prev, [key]: value })); + }; + const setText = (key: keyof AppSettings, value: string): void => { + settingsDraftRevisionRef.current += 1; + panelDirtyRevisionRef.current += 1; + settingsDirtyRef.current = true; + setSettingsDirty(true); + setSettingsDraft((prev) => ({ ...prev, [key]: value })); + }; + const setNum = (key: keyof AppSettings, value: number): void => { + settingsDraftRevisionRef.current += 1; + panelDirtyRevisionRef.current += 1; + settingsDirtyRef.current = true; + setSettingsDirty(true); + setSettingsDraft((prev) => ({ ...prev, [key]: value })); + }; + const setSpeedLimitMbps = (value: number): void => { + const mbps = Number.isFinite(value) ? Math.max(0, value) : 0; + settingsDraftRevisionRef.current += 1; + panelDirtyRevisionRef.current += 1; + settingsDirtyRef.current = true; + setSettingsDirty(true); + setSettingsDraft((prev) => ({ ...prev, speedLimitKbps: Math.floor(mbps * 1024) })); + }; + + const performQuickAction = async ( + action: () => Promise<unknown>, + onError?: (error: unknown) => void + ): Promise<void> => { + if (actionBusyRef.current) { + return; + } + actionBusyRef.current = true; + setActionBusy(true); + try { + await action(); + } catch (error) { + if (onError) { + onError(error); + } else { + showToast(`Fehler: ${String(error)}`, 2600); + } + } finally { + if (actionUnlockTimerRef.current) { + clearTimeout(actionUnlockTimerRef.current); + } + actionUnlockTimerRef.current = setTimeout(() => { + if (!mountedRef.current) { + actionUnlockTimerRef.current = null; + return; + } + actionBusyRef.current = false; + setActionBusy(false); + actionUnlockTimerRef.current = null; + }, 80); + } + }; + + const movePackage = useCallback((packageId: string, direction: "up" | "down") => { + const currentOrder = packageOrderRef.current; + const order = [...currentOrder]; + const idx = order.indexOf(packageId); + if (idx < 0) { return; } + const target = direction === "up" ? idx - 1 : idx + 1; + if (target < 0 || target >= order.length) { return; } + [order[idx], order[target]] = [order[target], order[idx]]; + setDownloadsSortDescending(false); + pendingPackageOrderRef.current = [...order]; + pendingPackageOrderAtRef.current = Date.now(); + packageOrderRef.current = [...order]; + setSnapshot((prev) => { + if (!prev) return prev; + return { ...prev, session: { ...prev.session, packageOrder: [...order] } }; + }); + void window.rd.reorderPackages(order).catch((error) => { + pendingPackageOrderRef.current = null; + pendingPackageOrderAtRef.current = 0; + packageOrderRef.current = serverPackageOrderRef.current; + setSnapshot((prev) => { + if (!prev) return prev; + return { ...prev, session: { ...prev.session, packageOrder: serverPackageOrderRef.current } }; + }); + showToast(`Sortierung fehlgeschlagen: ${String(error)}`, 2400); + }); + }, [showToast]); + + const reorderPackagesByDrop = useCallback((draggedPackageId: string, targetPackageId: string) => { + const currentOrder = packageOrderRef.current; + const nextOrder = reorderPackageOrderByDrop(currentOrder, draggedPackageId, targetPackageId); + const unchanged = nextOrder.length === currentOrder.length + && nextOrder.every((id, index) => id === currentOrder[index]); + if (unchanged) { + return; + } + setDownloadsSortDescending(false); + pendingPackageOrderRef.current = [...nextOrder]; + pendingPackageOrderAtRef.current = Date.now(); + packageOrderRef.current = [...nextOrder]; + setSnapshot((prev) => { + if (!prev) return prev; + return { ...prev, session: { ...prev.session, packageOrder: [...nextOrder] } }; + }); + void window.rd.reorderPackages(nextOrder).catch((error) => { + pendingPackageOrderRef.current = null; + pendingPackageOrderAtRef.current = 0; + packageOrderRef.current = serverPackageOrderRef.current; + setSnapshot((prev) => { + if (!prev) return prev; + return { ...prev, session: { ...prev.session, packageOrder: serverPackageOrderRef.current } }; + }); + showToast(`Sortierung fehlgeschlagen: ${String(error)}`, 2400); + }); + }, [showToast]); + + const addCollectorTab = (): void => { + const id = `tab-${nextCollectorId++}`; + setCollectorTabs((prev) => { + const name = `Tab ${prev.length + 1}`; + return [...prev, { id, name, text: "" }]; + }); + setActiveCollectorTab(id); + }; + + const removeCollectorTab = (id: string): void => { + let fallbackId = ""; + setCollectorTabs((prev) => { + if (prev.length <= 1) return prev; + const index = prev.findIndex((tabEntry) => tabEntry.id === id); + if (index < 0) return prev; + const next = prev.filter((tabEntry) => tabEntry.id !== id); + if (activeCollectorTabRef.current === id) { + fallbackId = next[Math.max(0, index - 1)]?.id ?? next[0]?.id ?? ""; + } + return next; + }); + if (fallbackId) setActiveCollectorTab(fallbackId); + }; + + const onPackageDragStart = useCallback((packageId: string) => { + draggedPackageIdRef.current = packageId; + }, []); + + const onPackageDrop = useCallback((targetPackageId: string) => { + const draggedPackageId = draggedPackageIdRef.current; + draggedPackageIdRef.current = null; + if (!draggedPackageId || draggedPackageId === targetPackageId) { + return; + } + reorderPackagesByDrop(draggedPackageId, targetPackageId); + }, [reorderPackagesByDrop]); + + const onPackageDragEnd = useCallback(() => { + draggedPackageIdRef.current = null; + }, []); + + const onPackageStartEdit = useCallback((packageId: string, packageName: string): void => { + setEditingPackageId(packageId); + setEditingName(packageName); + }, []); + + const onPackageFinishEdit = useCallback((packageId: string, currentName: string, nextName: string): void => { + let shouldRename = false; + setEditingPackageId((prev) => { + if (prev !== packageId) return prev; + shouldRename = true; + return null; + }); + if (shouldRename) { + const normalized = nextName.trim(); + if (normalized && normalized !== currentName.trim()) { + void window.rd.renamePackage(packageId, normalized).catch((error) => { + showToast(`Umbenennen fehlgeschlagen: ${String(error)}`, 2400); + }); + } + } + }, [showToast]); + + const onPackageToggleCollapse = useCallback((packageId: string): void => { + setCollapsedPackages((prev) => { + const nextCollapsed = !(prev[packageId] ?? false); + if (nextCollapsed) { + manualCollapsedPkgsRef.current.add(packageId); + } else { + manualCollapsedPkgsRef.current.delete(packageId); + autoExpandedPkgsRef.current.delete(packageId); + } + return { ...prev, [packageId]: nextCollapsed }; + }); + }, []); + + const onPackageCancel = useCallback((packageId: string): void => { + setSnapshot((prev) => { + if (!prev) { return prev; } + const nextPackages = { ...prev.session.packages }; + const nextItems = { ...prev.session.items }; + const pkg = nextPackages[packageId]; + if (pkg) { + for (const itemId of pkg.itemIds) { + delete nextItems[itemId]; + } + delete nextPackages[packageId]; + } + return { + ...prev, + session: { + ...prev.session, + packages: nextPackages, + items: nextItems, + packageOrder: prev.session.packageOrder.filter((id) => id !== packageId) + } + }; + }); + void window.rd.cancelPackage(packageId).catch((error) => { + showToast(`Paket-Löschung fehlgeschlagen: ${String(error)}`, 2400); + }); + }, [showToast]); + + const onPackageMoveUp = useCallback((packageId: string): void => { + movePackage(packageId, "up"); + }, [movePackage]); + + const onPackageMoveDown = useCallback((packageId: string): void => { + movePackage(packageId, "down"); + }, [movePackage]); + + const moveSelectedPackages = useCallback((direction: "up" | "down") => { + const currentOrder = packageOrderRef.current; + const selPkgs = new Set([...selectedIds].filter((id) => snapshot.session.packages[id])); + if (selPkgs.size === 0) return; + const order = [...currentOrder]; + if (direction === "up") { + for (let i = 0; i < order.length; i++) { + if (selPkgs.has(order[i]) && i > 0 && !selPkgs.has(order[i - 1])) { + [order[i - 1], order[i]] = [order[i], order[i - 1]]; + } + } + } else { + for (let i = order.length - 1; i >= 0; i--) { + if (selPkgs.has(order[i]) && i < order.length - 1 && !selPkgs.has(order[i + 1])) { + [order[i], order[i + 1]] = [order[i + 1], order[i]]; + } + } + } + const unchanged = order.length === currentOrder.length && order.every((id, idx) => id === currentOrder[idx]); + if (unchanged) return; + setDownloadsSortDescending(false); + pendingPackageOrderRef.current = [...order]; + pendingPackageOrderAtRef.current = Date.now(); + packageOrderRef.current = [...order]; + setSnapshot((prev) => { + if (!prev) return prev; + return { ...prev, session: { ...prev.session, packageOrder: [...order] } }; + }); + void window.rd.reorderPackages(order).catch((error) => { + pendingPackageOrderRef.current = null; + pendingPackageOrderAtRef.current = 0; + packageOrderRef.current = serverPackageOrderRef.current; + setSnapshot((prev) => { + if (!prev) return prev; + return { ...prev, session: { ...prev.session, packageOrder: serverPackageOrderRef.current } }; + }); + showToast(`Sortierung fehlgeschlagen: ${String(error)}`, 2400); + }); + }, [selectedIds, snapshot.session.packages, showToast]); + + const onPackageToggle = useCallback((packageId: string): void => { + let previousEnabled: boolean | null = null; + setSnapshot((prev) => { + const pkg = prev.session.packages[packageId]; + if (!pkg) { + return prev; + } + previousEnabled = pkg.enabled; + const nextEnabled = !pkg.enabled; + const nextItems = { ...prev.session.items }; + if (!nextEnabled) { + for (const itemId of pkg.itemIds) { + const item = nextItems[itemId]; + if (!item) { + continue; + } + if (item.status === "queued" || item.status === "reconnect_wait") { + nextItems[itemId] = { + ...item, + fullStatus: "Paket gestoppt", + updatedAt: Date.now() + }; + } + } + } else { + for (const itemId of pkg.itemIds) { + const item = nextItems[itemId]; + if (!item) { + continue; + } + if (item.status === "queued" && item.fullStatus === "Paket gestoppt") { + nextItems[itemId] = { + ...item, + fullStatus: "Wartet", + updatedAt: Date.now() + }; + } + } + } + const nextPkgStatus = !nextEnabled + ? (pkg.status === "downloading" || pkg.status === "extracting" ? "paused" : pkg.status) + : (pkg.status === "paused" ? "queued" : pkg.status); + const nextSnapshot: UiSnapshot = { + ...prev, + session: { + ...prev.session, + items: nextItems, + packages: { + ...prev.session.packages, + [packageId]: { + ...pkg, + enabled: nextEnabled, + status: nextPkgStatus, + updatedAt: Date.now() + } + }, + updatedAt: Date.now() + } + }; + latestStateRef.current = nextSnapshot; + return nextSnapshot; + }); + void window.rd.togglePackage(packageId).catch((error) => { + if (previousEnabled !== null) { + const revertedEnabled = previousEnabled; + setSnapshot((prev) => { + const pkg = prev.session.packages[packageId]; + if (!pkg) { + return prev; + } + const revertedSnapshot: UiSnapshot = { + ...prev, + session: { + ...prev.session, + packages: { + ...prev.session.packages, + [packageId]: { + ...pkg, + enabled: revertedEnabled, + status: revertedEnabled && pkg.status === "paused" ? "queued" : pkg.status, + updatedAt: Date.now() + } + }, + updatedAt: Date.now() + } + }; + latestStateRef.current = revertedSnapshot; + return revertedSnapshot; + }); + } + showToast(`Paket-Umschalten fehlgeschlagen: ${String(error)}`, 2400); + }); + }, [showToast]); + + const onPackageRemoveItem = useCallback((itemId: string): void => { + setSnapshot((prev) => { + if (!prev) { return prev; } + const item = prev.session.items[itemId]; + if (!item) { return prev; } + const nextItems = { ...prev.session.items }; + delete nextItems[itemId]; + const nextPackages = { ...prev.session.packages }; + const pkg = nextPackages[item.packageId]; + if (pkg) { + const nextItemIds = pkg.itemIds.filter((id) => id !== itemId); + if (nextItemIds.length === 0) { + delete nextPackages[item.packageId]; + return { + ...prev, + session: { + ...prev.session, + packages: nextPackages, + items: nextItems, + packageOrder: prev.session.packageOrder.filter((id) => id !== item.packageId) + } + }; + } + nextPackages[item.packageId] = { ...pkg, itemIds: nextItemIds }; + } + return { ...prev, session: { ...prev.session, packages: nextPackages, items: nextItems } }; + }); + void window.rd.removeItem(itemId).catch((error) => { + showToast(`Entfernen fehlgeschlagen: ${String(error)}`, 2400); + }); + }, [showToast]); + + const onPackageContextMenu = useCallback((packageId: string, itemId: string | undefined, x: number, y: number): void => { + const clickedId = itemId ?? packageId; + setSelectedIds((prev) => { + if (prev.has(clickedId)) return prev; + return new Set([clickedId]); + }); + setContextMenu({ x, y, packageId, itemId }); + }, []); + + const speedHistoryRef = useRef<{ time: number; speed: number }[]>([]); + const dragSelectRef = useRef(false); + const dragAnchorRef = useRef<string | null>(null); + const dragDidMoveRef = useRef(false); + const lastClickedIdRef = useRef<string | null>(null); + + const visibleOrderIds = useMemo(() => { + const ids: string[] = []; + for (const pkg of visiblePackages) { + ids.push(pkg.id); + if (!(collapsedPackages[pkg.id] ?? false)) { + const items = itemsByPackage.get(pkg.id) ?? []; + for (const item of items) { + if (snapshot.settings.hideExtractedItems && item.fullStatus?.startsWith("Entpackt")) continue; + ids.push(item.id); + } + } + } + return ids; + }, [visiblePackages, collapsedPackages, itemsByPackage, snapshot.settings.hideExtractedItems]); + + // Keep a ref of the currently VISIBLE ids so the (deps-[]) Ctrl+A keyboard + // handler can select exactly what the user sees — not the whole unfiltered map. + const visibleOrderIdsRef = useRef<string[]>(visibleOrderIds); + visibleOrderIdsRef.current = visibleOrderIds; + + const onSelectId = useCallback((id: string, ctrlKey: boolean, shiftKey: boolean): void => { + if (dragDidMoveRef.current) return; + if (shiftKey && lastClickedIdRef.current) { + const anchorIdx = visibleOrderIds.indexOf(lastClickedIdRef.current); + const targetIdx = visibleOrderIds.indexOf(id); + if (anchorIdx !== -1 && targetIdx !== -1) { + const from = Math.min(anchorIdx, targetIdx); + const to = Math.max(anchorIdx, targetIdx); + const rangeIds = visibleOrderIds.slice(from, to + 1); + setSelectedIds((prev) => { + const next = ctrlKey ? new Set(prev) : new Set<string>(); + for (const rid of rangeIds) next.add(rid); + return next; + }); + return; + } + } + lastClickedIdRef.current = id; + setSelectedIds((prev) => { + if (ctrlKey) { + const next = new Set(prev); + if (next.has(id)) next.delete(id); else next.add(id); + return next; + } + if (prev.size === 1 && prev.has(id)) return new Set(); + return new Set([id]); + }); + }, [visibleOrderIds]); + + const onSelectMouseDown = useCallback((id: string, e: React.MouseEvent): void => { + if (!e.ctrlKey || e.button !== 0) return; + e.preventDefault(); + dragSelectRef.current = true; + dragAnchorRef.current = id; + dragDidMoveRef.current = false; + const onUp = (): void => { + dragSelectRef.current = false; + dragAnchorRef.current = null; + dragDidMoveRef.current = false; + window.removeEventListener("mouseup", onUp); + }; + window.addEventListener("mouseup", onUp); + }, []); + + const onSelectMouseEnter = useCallback((id: string): void => { + if (!dragSelectRef.current) return; + if (!dragDidMoveRef.current) { + dragDidMoveRef.current = true; + const anchor = dragAnchorRef.current; + if (anchor) { + setSelectedIds((prev) => { if (prev.has(anchor)) return prev; const next = new Set(prev); next.add(anchor); return next; }); + } + } + setSelectedIds((prev) => { if (prev.has(id)) return prev; const next = new Set(prev); next.add(id); return next; }); + }, []); + + const showLinksPopup = useCallback((packageId: string, itemId?: string): void => { + const sel = selectedIds; + const currentPackages = snapshotRef.current.session.packages; + const currentItems = snapshotRef.current.session.items; + if (sel.size > 1) { + const allLinks: { name: string; url: string }[] = []; + for (const id of sel) { + const pkg = currentPackages[id]; + if (pkg) { + for (const iid of pkg.itemIds) { + const item = currentItems[iid]; + if (item) allLinks.push({ name: item.fileName, url: item.url }); + } + } else { + const item = currentItems[id]; + if (item) allLinks.push({ name: item.fileName, url: item.url }); + } + } + setLinkPopup({ title: `${sel.size} ausgewählt`, links: allLinks, isPackage: allLinks.length > 1 }); + setContextMenu(null); + return; + } + const pkg = currentPackages[packageId]; + if (!pkg) { return; } + if (itemId) { + const item = currentItems[itemId]; + if (item) { + setLinkPopup({ title: item.fileName, links: [{ name: item.fileName, url: item.url }], isPackage: false }); + } + } else { + const links = pkg.itemIds + .map((id) => currentItems[id]) + .filter(Boolean) + .map((item) => ({ name: item.fileName, url: item.url })); + setLinkPopup({ title: pkg.name, links, isPackage: true }); + } + setContextMenu(null); + }, [selectedIds]); + + const schedules = settingsDraft.bandwidthSchedules ?? []; + + useEffect(() => { + setScheduleSpeedInputs((prev) => { + const syncFromSettings = !settingsDirtyRef.current; + let changed = false; + const next: Record<string, string> = {}; + for (let index = 0; index < schedules.length; index += 1) { + const schedule = schedules[index]; + const key = schedule.id || `schedule-${index}`; + const normalized = formatMbpsInputFromKbps(schedule.speedLimitKbps); + if (syncFromSettings || !Object.prototype.hasOwnProperty.call(prev, key)) { + next[key] = normalized; + if (prev[key] !== normalized) { + changed = true; + } + } else { + next[key] = prev[key]; + } + } + const prevKeys = Object.keys(prev); + if (prevKeys.length !== Object.keys(next).length) { + changed = true; + } + return changed ? next : prev; + }); + }, [schedules, settingsDirty]); + + const addSchedule = (): void => { + settingsDraftRevisionRef.current += 1; + panelDirtyRevisionRef.current += 1; + settingsDirtyRef.current = true; + setSettingsDirty(true); + setSettingsDraft((prev) => ({ + ...prev, + bandwidthSchedules: [...(prev.bandwidthSchedules ?? []), { id: createScheduleId(), startHour: 0, endHour: 8, speedLimitKbps: 0, enabled: true }] + })); + }; + const removeSchedule = (idx: number): void => { + settingsDraftRevisionRef.current += 1; + panelDirtyRevisionRef.current += 1; + settingsDirtyRef.current = true; + setSettingsDirty(true); + setSettingsDraft((prev) => ({ + ...prev, + bandwidthSchedules: (prev.bandwidthSchedules ?? []).filter((_, i) => i !== idx) + })); + }; + const updateSchedule = (idx: number, field: keyof BandwidthScheduleEntry, value: number | boolean): void => { + settingsDraftRevisionRef.current += 1; + panelDirtyRevisionRef.current += 1; + settingsDirtyRef.current = true; + setSettingsDirty(true); + setSettingsDraft((prev) => ({ + ...prev, + bandwidthSchedules: (prev.bandwidthSchedules ?? []).map((s, i) => i === idx ? { ...s, [field]: value } : s) + })); + }; + + const applyTheme = (theme: AppTheme): void => { + document.documentElement.setAttribute("data-theme", theme); + }; + + const closeMenus = (): void => { + setOpenMenu(null); + setOpenSubmenu(null); + }; + + useEffect(() => { + if (!contextMenu) { return; } + const close = (): void => setContextMenu(null); + window.addEventListener("click", close); + window.addEventListener("contextmenu", close); + return () => { + window.removeEventListener("click", close); + window.removeEventListener("contextmenu", close); + }; + }, [contextMenu]); + + useLayoutEffect(() => { + if (!contextMenu || !ctxMenuRef.current) return; + const el = ctxMenuRef.current; + const rect = el.getBoundingClientRect(); + if (rect.bottom > window.innerHeight) { + el.style.top = `${Math.max(0, contextMenu.y - rect.height)}px`; + } + if (rect.right > window.innerWidth) { + el.style.left = `${Math.max(0, contextMenu.x - rect.width)}px`; + } + }, [contextMenu]); + + useEffect(() => { + if (!colHeaderCtx) return; + const close = (e: MouseEvent): void => { + if (colHeaderCtxRef.current && colHeaderCtxRef.current.contains(e.target as Node)) return; + if (colHeaderBarRef.current && colHeaderBarRef.current.contains(e.target as Node)) return; + setColHeaderCtx(null); + }; + window.addEventListener("mousedown", close); + return () => { + window.removeEventListener("mousedown", close); + }; + }, [colHeaderCtx]); + + useLayoutEffect(() => { + if (!colHeaderCtx || !colHeaderCtxRef.current) return; + const el = colHeaderCtxRef.current; + const rect = el.getBoundingClientRect(); + if (rect.bottom > window.innerHeight) { + el.style.top = `${Math.max(0, colHeaderCtx.y - rect.height)}px`; + } + if (rect.right > window.innerWidth) { + el.style.left = `${Math.max(0, colHeaderCtx.x - rect.width)}px`; + } + }, [colHeaderCtx]); + + useEffect(() => { + if (!historyCtxMenu) return; + const close = (): void => setHistoryCtxMenu(null); + window.addEventListener("click", close); + window.addEventListener("contextmenu", close); + return () => { + window.removeEventListener("click", close); + window.removeEventListener("contextmenu", close); + }; + }, [historyCtxMenu]); + + useLayoutEffect(() => { + if (!historyCtxMenu || !historyCtxMenuRef.current) return; + const el = historyCtxMenuRef.current; + const rect = el.getBoundingClientRect(); + if (rect.bottom > window.innerHeight) { + el.style.top = `${Math.max(0, historyCtxMenu.y - rect.height)}px`; + } + if (rect.right > window.innerWidth) { + el.style.left = `${Math.max(0, historyCtxMenu.x - rect.width)}px`; + } + }, [historyCtxMenu]); + + const executeDeleteSelection = useCallback((ids: Set<string>): void => { + const current = snapshotRef.current; + const promises: Promise<void>[] = []; + for (const id of ids) { + if (current.session.items[id]) promises.push(window.rd.removeItem(id)); + else if (current.session.packages[id]) promises.push(window.rd.cancelPackage(id)); + } + void Promise.all(promises).catch(() => {}); + setSelectedIds(new Set()); + }, []); + + const requestDeleteSelection = useCallback((): void => { + if (selectedIds.size === 0) return; + if (!settingsDraft.confirmDeleteSelection) { + executeDeleteSelection(selectedIds); + return; + } + setDeleteConfirm({ ids: new Set(selectedIds), dontAsk: false }); + }, [selectedIds, settingsDraft.confirmDeleteSelection, executeDeleteSelection]); + + useEffect(() => { + const onKey = (e: globalThis.KeyboardEvent): void => { + if (e.key === "Escape") { + const target = e.target as HTMLElement; + if (target.tagName !== "INPUT" && target.tagName !== "TEXTAREA") { + if (document.querySelector(".ctx-menu") || document.querySelector(".modal-backdrop")) return; + if (tabRef.current === "downloads") setSelectedIds(new Set()); + else if (tabRef.current === "history") setSelectedHistoryIds(new Set()); + } + } + if (e.key === "Delete" && tabRef.current === "downloads" && selectedIds.size > 0) { + const target = e.target as HTMLElement; + if (target.tagName === "INPUT" || target.tagName === "TEXTAREA") return; + e.preventDefault(); + requestDeleteSelection(); + } + }; + const onDown = (e: MouseEvent): void => { + const target = e.target as HTMLElement; + if (target.closest(".package-card") || target.closest(".ctx-menu") || target.closest(".modal-backdrop") || target.closest(".modal-card")) return; + setSelectedIds(new Set()); + }; + window.addEventListener("keydown", onKey); + window.addEventListener("mousedown", onDown); + return () => { window.removeEventListener("keydown", onKey); window.removeEventListener("mousedown", onDown); }; + }, [selectedIds, requestDeleteSelection]); + + const onExportBackup = async (): Promise<void> => { + closeMenus(); + await performQuickAction(async () => { + const result = await window.rd.exportBackup(); + if (result.saved) { + showToast("Sicherung exportiert"); + } + }, (error) => { + showToast(`Sicherung fehlgeschlagen: ${String(error)}`, 2600); + }); + }; + + const onImportBackup = async (): Promise<void> => { + closeMenus(); + await performQuickAction(async () => { + const result = await window.rd.importBackup(); + if (result.restored) { + showToast(result.message, 4000); + // A settings-only import applies live without a relaunch, so the editable + // settings form would otherwise keep showing the old values. Pull the + // fresh settings and re-seed the draft so the UI reflects the import. + if (!result.relaunch) { + const fresh = await window.rd.getSnapshot(); + applyPersistedSettings(fresh.settings); + } + } else if (result.message !== "Abgebrochen") { + showToast(`Sicherung laden fehlgeschlagen: ${result.message}`, 3000); + } + }, (error) => { + showToast(`Sicherung laden fehlgeschlagen: ${String(error)}`, 2600); + }); + }; + + const onExportSupportBundle = async (): Promise<void> => { + closeMenus(); + await performQuickAction(async () => { + const result = await window.rd.exportSupportBundle(); + if (result.saved) { + showToast("Support-Bundle exportiert", 2600); + } + }, (error) => { + showToast(`Support-Bundle fehlgeschlagen: ${String(error)}`, 2800); + }); + }; + + const onToggleSupportTrace = async (): Promise<void> => { + closeMenus(); + const nextEnabled = !supportTraceEnabled; + await performQuickAction(async () => { + const result = await window.rd.setTraceEnabled(nextEnabled, "UI support toggle", 120); + setSupportTraceEnabled(result.enabled); + showToast(result.enabled ? "Support-Trace für 2 Stunden aktiviert" : "Support-Trace deaktiviert", 2600); + }, (error) => { + showToast(`Support-Trace fehlgeschlagen: ${String(error)}`, 2800); + }); + }; + + const onRunDebugSetupCheck = async (): Promise<void> => { + closeMenus(); + try { + const setup = await window.rd.getDebugSetupCheck(); + const warningText = setup.warnings.length > 0 ? `Warnungen: ${setup.warnings.length}` : "Keine akuten Warnungen"; + const reachabilityText = setup.localOnly ? "Nur lokal gebunden" : "Remote-fähig konfiguriert"; + const details = buildDebugSetupDetails(setup); + await askConfirmPrompt({ + title: "Debug-Setup prüfen", + message: `${warningText}\n${reachabilityText}\nHost: ${setup.host}:${setup.port}`, + confirmLabel: "Schließen", + cancelLabel: "Schließen", + details, + detailsLabel: "Details anzeigen" + }); + } catch (error) { + showToast(`Debug-Setup-Check fehlgeschlagen: ${String(error)}`, 3000); + } + }; + + const onShowRecentErrors = async (): Promise<void> => { + closeMenus(); + try { + const entries = await window.rd.getRecentErrors(); + const errorCount = entries.filter((e) => e.level === "ERROR").length; + const warnCount = entries.filter((e) => e.level === "WARN").length; + const details = entries.map((e) => `${e.ts} [${e.level}] ${e.message}`).join("\n"); + const copy = await askConfirmPrompt({ + title: "Letzte Fehler", + message: entries.length === 0 + ? "Keine Fehler oder Warnungen seit dem App-Start aufgezeichnet." + : `${errorCount} Fehler, ${warnCount} Warnungen (letzte ${entries.length})`, + confirmLabel: entries.length > 0 ? "In Zwischenablage kopieren" : "Schließen", + cancelLabel: "Schließen", + details: details || undefined, + detailsLabel: "Einträge anzeigen" + }); + if (copy && entries.length > 0) { + await navigator.clipboard.writeText(details); + showToast("Fehlerliste kopiert", 2600); + } + } catch (error) { + showToast(`Fehler-Ansicht fehlgeschlagen: ${String(error)}`, 3000); + } + }; + + const onRotateDebugToken = async (): Promise<void> => { + closeMenus(); + const confirmed = await askConfirmPrompt({ + title: "Debug-Token rotieren", + message: "Das aktuelle Debug-Token wird ersetzt. Externe Debug-Links mit altem Token funktionieren danach nicht mehr.", + confirmLabel: "Token rotieren", + danger: true + }); + if (!confirmed) { + return; + } + await performQuickAction(async () => { + const result = await window.rd.rotateDebugToken(); + showToast(`Debug-Token rotiert: ${result.path}`, 4200); + }, (error) => { + showToast(`Token-Rotation fehlgeschlagen: ${String(error)}`, 3000); + }); + }; + + const applyRemoteDiagInfo = (info: RemoteDiagnosticsInfo): void => { + setRemoteDiag(info); + setRdHostMode(info.status.localOnly ? "local" : "network"); + setRdPublicHost(info.publicHost || (info.status.localOnly ? "" : (info.suggestedHosts[0] || ""))); + setRdPort(String(info.status.port || 9868)); + setRdAllowlist(info.allowlist.join("\n")); + setRdName(info.name || ""); + }; + + const onOpenRemoteDiagnostics = async (): Promise<void> => { + closeMenus(); + try { + const info = await window.rd.getRemoteDiagnostics(); + applyRemoteDiagInfo(info); + setRemoteDiagOpen(true); + } catch (error) { + showToast(`Ferndiagnose-Status fehlgeschlagen: ${String(error)}`, 3000); + } + }; + + const onSubmitRemoteDiagnostics = async (): Promise<void> => { + const port = Number(rdPort) || 9868; + const allowlist = rdAllowlist.split(/\r?\n/).map((line) => line.trim()).filter((line) => line.length > 0); + if (rdHostMode === "network" && allowlist.length === 0) { + showToast("Netzwerkmodus braucht mindestens eine IP oder CIDR in der Allowlist", 3600); + return; + } + setRemoteDiagBusy(true); + try { + const info = await window.rd.enableRemoteDiagnostics({ + hostMode: rdHostMode, + publicHost: rdPublicHost, + port, + allowlist, + name: rdName + }); + applyRemoteDiagInfo(info); + showToast(info.status.running ? "Ferndiagnose aktiv" : "Ferndiagnose konfiguriert", 2600); + } catch (error) { + showToast(`Aktivieren fehlgeschlagen: ${String(error)}`, 3600); + } finally { + setRemoteDiagBusy(false); + } + }; + + const onDisableRemoteDiagnostics = async (): Promise<void> => { + setRemoteDiagBusy(true); + try { + const info = await window.rd.disableRemoteDiagnostics(); + applyRemoteDiagInfo(info); + showToast("Ferndiagnose deaktiviert", 2400); + } catch (error) { + showToast(`Deaktivieren fehlgeschlagen: ${String(error)}`, 3000); + } finally { + setRemoteDiagBusy(false); + } + }; + + const onRotateRemoteDiagnosticsToken = async (): Promise<void> => { + setRemoteDiagBusy(true); + try { + const info = await window.rd.rotateRemoteDiagnosticsToken(); + applyRemoteDiagInfo(info); + showToast("Neues Token - alter Verbindungscode ist ungueltig", 3000); + } catch (error) { + showToast(`Token-Rotation fehlgeschlagen: ${String(error)}`, 3000); + } finally { + setRemoteDiagBusy(false); + } + }; + + const onCopyRemoteDiagnosticsCode = async (): Promise<void> => { + if (!remoteDiag?.code) { + return; + } + try { + await navigator.clipboard.writeText(remoteDiag.code); + showToast("Verbindungscode kopiert", 2200); + } catch { + showToast("Kopieren fehlgeschlagen", 2200); + } + }; + + const onMenuRestart = (): void => { + closeMenus(); + void window.rd.restart(); + }; + + const onMenuQuit = (): void => { + closeMenus(); + void window.rd.quit(); + }; + + useEffect(() => { + const handler = (e: globalThis.KeyboardEvent): void => { + if (e.ctrlKey && !e.altKey && !e.metaKey) { + const target = e.target as HTMLElement; + const inInput = target.tagName === "INPUT" || target.tagName === "TEXTAREA"; + if (e.shiftKey && e.key.toLowerCase() === "r") { + if (inInput) return; + e.preventDefault(); + void window.rd.restart(); + return; + } + if (!e.shiftKey && e.key.toLowerCase() === "q") { + if (inInput) return; + e.preventDefault(); + void window.rd.quit(); + return; + } + if (!e.shiftKey && e.key.toLowerCase() === "l") { + if (inInput) return; + e.preventDefault(); + setTab("collector"); + setOpenMenu(null); + return; + } + if (!e.shiftKey && e.key.toLowerCase() === "p") { + if (inInput) return; + e.preventDefault(); + setTab("settings"); + setOpenMenu(null); + return; + } + if (!e.shiftKey && e.key.toLowerCase() === "o") { + if (inInput) return; + e.preventDefault(); + setOpenMenu(null); + void onImportDlcRef.current(); + return; + } + if (!e.shiftKey && e.key.toLowerCase() === "a") { + if (inInput) return; + if (tabRef.current === "downloads") { + e.preventDefault(); + // Select exactly the VISIBLE rows (packages + their items), honouring + // the active search / collapse / hide-extracted filters — selecting + // the unfiltered package map would let a later delete hit hidden ones. + setSelectedIds(new Set(visibleOrderIdsRef.current)); + } else if (tabRef.current === "history") { + e.preventDefault(); + setSelectedHistoryIds(new Set(historyEntriesRef.current.map(e => e.id))); + } + return; + } + } + }; + window.addEventListener("keydown", handler); + return () => window.removeEventListener("keydown", handler); + }, []); + + useEffect(() => { + if (!openMenu) { return; } + const handler = (e: MouseEvent): void => { + const target = e.target as HTMLElement; + if (!target.closest(".menu-bar")) { + setOpenMenu(null); + setOpenSubmenu(null); + } + }; + document.addEventListener("mousedown", handler); + return () => document.removeEventListener("mousedown", handler); + }, [openMenu]); + + const packageSpeedMap = useMemo(() => { + const map = new Map<string, number>(); + for (const [pid, bps] of Object.entries(snapshot.packageSpeedBps)) { + if (bps > 0) map.set(pid, bps); + } + return map; + }, [snapshot.packageSpeedBps]); + + const itemStatusCounts = useMemo(() => { + const counts = { downloading: 0, queued: 0, failed: 0 }; + for (const item of Object.values(snapshot.session.items)) { + if (item.status === "downloading") { + counts.downloading += 1; + } else if (item.status === "queued" || item.status === "reconnect_wait") { + counts.queued += 1; + } else if (item.status === "failed") { + counts.failed += 1; + } + } + return counts; + }, [snapshot.session.items]); + + const providerStats = useMemo(() => { + const stats: Record<string, { total: number; completed: number; failed: number; bytes: number }> = {}; + for (const item of Object.values(snapshot.session.items)) { + const hoster = extractHoster(item.url) || "unknown"; + if (!stats[hoster]) { + stats[hoster] = { total: 0, completed: 0, failed: 0, bytes: 0 }; + } + stats[hoster].total += 1; + if (item.status === "completed") stats[hoster].completed += 1; + if (item.status === "failed") stats[hoster].failed += 1; + stats[hoster].bytes += item.downloadedBytes; + } + return Object.entries(stats); + }, [snapshot.session.items]); + + const runtimeOffsetMs = snapshot.stats.runtimeMeasuredAt > 0 + ? Math.max(0, runtimeNow - snapshot.stats.runtimeMeasuredAt) + : 0; + const liveSessionRuntimeMs = Math.max(0, (snapshot.stats.sessionRuntimeMs || 0) + runtimeOffsetMs); + const liveTotalRuntimeMs = Math.max(0, (snapshot.stats.totalRuntimeMs || 0) + runtimeOffsetMs); + const etaSeparatorIndex = snapshot.etaText.includes(": ") ? snapshot.etaText.indexOf(": ") : -1; + const etaValue = etaSeparatorIndex >= 0 + ? snapshot.etaText.slice(etaSeparatorIndex + 2) + : "--"; + const resetFailedDownloads = (): void => { + if (itemStatusCounts.failed === 0) return; + const failedIds = Object.values(snapshot.session.items) + .filter((it) => it.status === "failed") + .map((it) => it.id); + void window.rd.resetItems(failedIds).catch(() => {}); + }; + const statsSections: StatsSection[] = [ + { + key: "live", + title: "Aktuell", + items: [ + { key: "speed", eyebrow: "Live", label: "Geschwindigkeit", value: snapshot.speedText.replace("Geschwindigkeit: ", "") }, + { key: "eta", eyebrow: "Live", label: "Restzeit", value: etaValue, compactValue: true }, + { key: "active", eyebrow: "Queue", label: "Aktive Downloads", value: String(itemStatusCounts.downloading) }, + { key: "queued", eyebrow: "Queue", label: "In Warteschlange", value: String(itemStatusCounts.queued) }, + { + key: "failed", + eyebrow: "Status", + label: "Fehlerhaft", + value: String(itemStatusCounts.failed), + danger: itemStatusCounts.failed > 0, + clickable: itemStatusCounts.failed > 0, + title: itemStatusCounts.failed > 0 ? "Klicken zum Zurücksetzen aller fehlerhaften Downloads" : undefined, + onClick: resetFailedDownloads + }, + { key: "packages", eyebrow: "Queue", label: "Pakete", value: String(snapshot.stats.totalPackages) } + ] + }, + { + key: "summary", + title: "Bilanz", + items: [ + { key: "downloaded-session", eyebrow: "Session", label: "Heruntergeladen", value: humanSize(snapshot.stats.totalDownloaded) }, + { key: "downloaded-total", eyebrow: "Gesamt", label: "Heruntergeladen", value: humanSize(snapshot.stats.totalDownloadedAllTime) }, + { key: "runtime-session", eyebrow: "Session", label: "Laufzeit", value: formatRuntimeDuration(liveSessionRuntimeMs), compactValue: true }, + { key: "runtime-total", eyebrow: "Gesamt", label: "Laufzeit", value: formatRuntimeDuration(liveTotalRuntimeMs), compactValue: true }, + { key: "files-session", eyebrow: "Session", label: "Fertige Dateien", value: String(snapshot.stats.totalFilesSession) }, + { key: "files-total", eyebrow: "Gesamt", label: "Fertige Dateien", value: String(snapshot.stats.totalFilesAllTime) } + ] + } + ]; + + return ( + <div + className={`app-shell${dragOver ? " drag-over" : ""}`} + onDragEnter={(event) => { + event.preventDefault(); + if (draggedPackageIdRef.current) { return; } + const hasFiles = event.dataTransfer.types.includes("Files"); + const hasUri = event.dataTransfer.types.includes("text/uri-list"); + if (!hasFiles && !hasUri) { return; } + dragDepthRef.current += 1; + if (!dragOverRef.current) { + dragOverRef.current = true; + setDragOver(true); + } + }} + onDragOver={(e) => { + e.preventDefault(); + }} + onDragLeave={() => { + if (draggedPackageIdRef.current) { return; } + dragDepthRef.current = Math.max(0, dragDepthRef.current - 1); + if (dragDepthRef.current === 0 && dragOverRef.current) { + dragOverRef.current = false; + setDragOver(false); + } + }} + onDrop={onDrop} + > + <nav className="menu-bar"> + <div className="menu-bar-item"> + <button + className={`menu-bar-trigger${openMenu === "datei" ? " open" : ""}`} + onClick={() => setOpenMenu(openMenu === "datei" ? null : "datei")} + onMouseEnter={() => { if (openMenu && openMenu !== "datei") { setOpenMenu("datei"); setOpenSubmenu(null); } }} + > + Datei + </button> + {openMenu === "datei" && ( + <div className="menu-dropdown"> + <button className="menu-dropdown-item" onClick={() => { closeMenus(); setTab("collector"); }}> + <span>Text mit Links analysieren</span> + <span className="shortcut">Strg+L</span> + </button> + <button className="menu-dropdown-item" onClick={() => { closeMenus(); void onImportQueue(); }}> + <span>Datei importieren</span> + </button> + <button className="menu-dropdown-item" onClick={() => { closeMenus(); void onImportDlc(); }}> + <span>Linkcontainer laden</span> + <span className="shortcut">Strg+O</span> + </button> + <div className="menu-separator" /> + <div + className="menu-submenu" + onMouseEnter={() => setOpenSubmenu("sicherung")} + onMouseLeave={() => setOpenSubmenu(null)} + > + <button className="menu-submenu-trigger">Sicherung</button> + {openSubmenu === "sicherung" && ( + <div className="menu-submenu-dropdown"> + <button className="menu-dropdown-item" onClick={() => { void onExportBackup(); }}>Exportieren</button> + <button className="menu-dropdown-item" onClick={() => { void onImportBackup(); }}>Importieren</button> + </div> + )} + </div> + <div className="menu-separator" /> + <button className="menu-dropdown-item" onClick={onMenuRestart}> + <span>Neustart</span> + <span className="shortcut">Strg+Umschalt+R</span> + </button> + <button className="menu-dropdown-item" onClick={onMenuQuit}> + <span>Beenden</span> + <span className="shortcut">Strg+Q</span> + </button> + </div> + )} + </div> + <div className="menu-bar-item"> + <button + className={`menu-bar-trigger${openMenu === "einstellungen" ? " open" : ""}`} + onClick={() => setOpenMenu(openMenu === "einstellungen" ? null : "einstellungen")} + onMouseEnter={() => { if (openMenu && openMenu !== "einstellungen") { setOpenMenu("einstellungen"); setOpenSubmenu(null); } }} + > + Einstellungen + </button> + {openMenu === "einstellungen" && ( + <div className="menu-dropdown"> + <button className="menu-dropdown-item" onClick={() => { closeMenus(); setTab("settings"); }}> + <span>Einstellungen</span> + <span className="shortcut">Strg+P</span> + </button> + <div className="menu-separator" /> + <div className="menu-settings-grid" onClick={(e) => e.stopPropagation()}> + <span>Max. gleichzeitige Downloads</span> + <span /> + <div className="menu-spinner"> + <input + type="text" + inputMode="numeric" + value={settingsDraft.maxParallel} + onChange={(e) => { + const val = Math.max(1, Math.min(50, Number(e.target.value) || 1)); + settingsDirtyRef.current = true; + const rev = ++settingsDraftRevisionRef.current; + setSettingsDraft((prev) => ({ ...prev, maxParallel: val })); + void window.rd.updateSettings({ maxParallel: val }).finally(() => { if (settingsDraftRevisionRef.current === rev && panelDirtyRevisionRef.current === 0) settingsDirtyRef.current = false; }); + }} + /> + <div className="menu-spinner-arrows"> + <button onClick={() => { + const val = Math.min(50, settingsDraft.maxParallel + 1); + settingsDirtyRef.current = true; + const rev = ++settingsDraftRevisionRef.current; + setSettingsDraft((prev) => ({ ...prev, maxParallel: val })); + void window.rd.updateSettings({ maxParallel: val }).finally(() => { if (settingsDraftRevisionRef.current === rev && panelDirtyRevisionRef.current === 0) settingsDirtyRef.current = false; }); + }}>▲</button> + <button onClick={() => { + const val = Math.max(1, settingsDraft.maxParallel - 1); + settingsDirtyRef.current = true; + const rev = ++settingsDraftRevisionRef.current; + setSettingsDraft((prev) => ({ ...prev, maxParallel: val })); + void window.rd.updateSettings({ maxParallel: val }).finally(() => { if (settingsDraftRevisionRef.current === rev && panelDirtyRevisionRef.current === 0) settingsDirtyRef.current = false; }); + }}>▼</button> + </div> + </div> + <span /> + + <span>Geschwindigkeitslimit</span> + <input + type="checkbox" + checked={settingsDraft.speedLimitEnabled} + onChange={(e) => { + const next = e.target.checked; + settingsDirtyRef.current = true; + const rev = ++settingsDraftRevisionRef.current; + setSettingsDraft((prev) => ({ ...prev, speedLimitEnabled: next })); + void window.rd.updateSettings({ speedLimitEnabled: next }).finally(() => { if (settingsDraftRevisionRef.current === rev && panelDirtyRevisionRef.current === 0) settingsDirtyRef.current = false; }); + }} + /> + <div className={`menu-spinner${!settingsDraft.speedLimitEnabled ? " disabled" : ""}`}> + <input + type="text" + inputMode="decimal" + value={speedLimitInput} + onChange={(e) => { + setSpeedLimitInput(e.target.value); + }} + onBlur={() => { + const parsed = parseMbpsInput(speedLimitInput); + if (parsed === null) { + setSpeedLimitInput(formatMbpsInputFromKbps(settingsDraft.speedLimitKbps)); + return; + } + const kbps = Math.floor(parsed * 1024); + settingsDirtyRef.current = true; + const rev = ++settingsDraftRevisionRef.current; + setSettingsDraft((prev) => ({ ...prev, speedLimitKbps: kbps })); + void window.rd.updateSettings({ speedLimitKbps: kbps }).finally(() => { if (settingsDraftRevisionRef.current === rev && panelDirtyRevisionRef.current === 0) settingsDirtyRef.current = false; }); + setSpeedLimitInput(formatMbpsInputFromKbps(kbps)); + }} + /> + <div className="menu-spinner-arrows"> + <button onClick={() => { + const cur = (settingsDraft.speedLimitKbps || 0) / 1024; + const next = Math.floor((cur + 1) * 1024); + settingsDirtyRef.current = true; + const rev = ++settingsDraftRevisionRef.current; + setSettingsDraft((prev) => ({ ...prev, speedLimitKbps: next })); + void window.rd.updateSettings({ speedLimitKbps: next }).finally(() => { if (settingsDraftRevisionRef.current === rev && panelDirtyRevisionRef.current === 0) settingsDirtyRef.current = false; }); + setSpeedLimitInput(formatMbpsInputFromKbps(next)); + }}>▲</button> + <button onClick={() => { + const cur = (settingsDraft.speedLimitKbps || 0) / 1024; + const next = Math.max(0, Math.floor((cur - 1) * 1024)); + settingsDirtyRef.current = true; + const rev = ++settingsDraftRevisionRef.current; + setSettingsDraft((prev) => ({ ...prev, speedLimitKbps: next })); + void window.rd.updateSettings({ speedLimitKbps: next }).finally(() => { if (settingsDraftRevisionRef.current === rev && panelDirtyRevisionRef.current === 0) settingsDirtyRef.current = false; }); + setSpeedLimitInput(formatMbpsInputFromKbps(next)); + }}>▼</button> + </div> + </div> + <span className="menu-speed-unit">MB/s</span> + </div> + </div> + )} + </div> + <div className="menu-bar-item"> + <button + className={`menu-bar-trigger${openMenu === "hilfe" ? " open" : ""}`} + onClick={() => setOpenMenu(openMenu === "hilfe" ? null : "hilfe")} + onMouseEnter={() => { if (openMenu && openMenu !== "hilfe") { setOpenMenu("hilfe"); setOpenSubmenu(null); } }} + > + Hilfe + </button> + {openMenu === "hilfe" && ( + <div className="menu-dropdown"> + <div + className="menu-submenu" + onMouseEnter={() => setOpenSubmenu("hilfe-log")} + onMouseLeave={() => setOpenSubmenu(null)} + > + <button className="menu-submenu-trigger">Logs öffnen</button> + {openSubmenu === "hilfe-log" && ( + <div className="menu-submenu-dropdown"> + <button className="menu-dropdown-item" onClick={() => { closeMenus(); void window.rd.openLog().catch(() => {}); }}><span>Haupt-Log</span></button> + <button className="menu-dropdown-item" onClick={() => { closeMenus(); void window.rd.openAuditLog().catch(() => {}); }}><span>Audit-Log</span></button> + <button className="menu-dropdown-item" onClick={() => { closeMenus(); void window.rd.openRenameLog().catch(() => {}); }}><span>Rename-Log</span></button> + <button className="menu-dropdown-item" onClick={() => { closeMenus(); void window.rd.openSessionLog().catch(() => {}); }}><span>Session-Log</span></button> + <button className="menu-dropdown-item" onClick={() => { closeMenus(); void window.rd.openTraceLog().catch(() => {}); }}><span>Trace-Log</span></button> + </div> + )} + </div> + <div + className="menu-submenu" + onMouseEnter={() => setOpenSubmenu("hilfe-remote")} + onMouseLeave={() => setOpenSubmenu(null)} + > + <button className="menu-submenu-trigger">Remote-Support</button> + {openSubmenu === "hilfe-remote" && ( + <div className="menu-submenu-dropdown"> + <button className="menu-dropdown-item" onClick={() => { void onOpenRemoteDiagnostics(); }}><span>Ferndiagnose …</span></button> + <button className="menu-dropdown-item" onClick={() => { void onExportSupportBundle(); }}><span>Support-Bundle exportieren</span></button> + <button className="menu-dropdown-item" onClick={() => { void onToggleSupportTrace(); }}><span>{supportTraceEnabled ? "Support-Trace deaktivieren" : "Support-Trace aktivieren"}</span></button> + </div> + )} + </div> + <div className="menu-separator" /> + <button className="menu-dropdown-item" onClick={() => { void onShowRecentErrors(); }}> + <span>Letzte Fehler anzeigen</span> + </button> + <div + className="menu-submenu" + onMouseEnter={() => setOpenSubmenu("hilfe-diagnose")} + onMouseLeave={() => setOpenSubmenu(null)} + > + <button className="menu-submenu-trigger">Diagnose</button> + {openSubmenu === "hilfe-diagnose" && ( + <div className="menu-submenu-dropdown"> + <button className="menu-dropdown-item" onClick={() => { void onRunDebugSetupCheck(); }}><span>Debug-Setup prüfen</span></button> + <button className="menu-dropdown-item" onClick={() => { void onRotateDebugToken(); }}><span>Debug-Token rotieren</span></button> + </div> + )} + </div> + <div className="menu-separator" /> + <button className="menu-dropdown-item" onClick={() => { closeMenus(); void onCheckUpdates(); }}> + <span>Suche Aktualisierungen</span> + </button> + </div> + )} + </div> + </nav> + + <section className="control-strip"> + <div className="buttons buttons-left"> + <button + className="ctrl-icon-btn ctrl-play" + title={snapshot.session.paused ? "Fortsetzen" : "Start"} + disabled={actionBusy || (!snapshot.canStart && !snapshot.session.paused)} + onClick={() => { + if (snapshot.session.paused) { + setSnapshot((prev) => ({ ...prev, session: { ...prev.session, paused: false } })); + void window.rd.togglePause().catch(() => {}); + } else { + void onStartDownloads(); + } + }} + > + <svg viewBox="0 0 24 24" width="18" height="18"><polygon points="6,3 20,12 6,21" fill="currentColor" /></svg> + </button> + <button + className={`ctrl-icon-btn ctrl-pause${snapshot.session.paused ? " paused" : ""}`} + title="Pause" + disabled={!snapshot.canPause || snapshot.session.paused} + onClick={() => { + setSnapshot((prev) => ({ ...prev, session: { ...prev.session, paused: true } })); + void window.rd.togglePause().catch(() => {}); + }} + > + <svg viewBox="0 0 24 24" width="18" height="18"><rect x="5" y="3" width="4.5" height="18" rx="1" fill="currentColor" /><rect x="14.5" y="3" width="4.5" height="18" rx="1" fill="currentColor" /></svg> + </button> + <button + className="ctrl-icon-btn ctrl-stop" + title="Stop" + disabled={actionBusy || !snapshot.canStop} + onClick={() => { void performQuickAction(() => window.rd.stop()); }} + > + <svg viewBox="0 0 24 24" width="18" height="18"><rect x="4" y="4" width="16" height="16" rx="2" fill="currentColor" /></svg> + </button> + <div className="ctrl-separator" /> + <div className="schedule-ctrl"> + {(snapshot.settings.scheduledStartEpochMs || 0) > 0 ? ( + <div className="schedule-active"> + <span className="schedule-badge" title="Geplanter Start">⏰ {scheduleCountdown || new Date(snapshot.settings.scheduledStartEpochMs).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })}</span> + <button className="schedule-cancel" title="Geplanten Start abbrechen" onClick={() => { void window.rd.updateSettings({ scheduledStartEpochMs: 0 }).catch(() => {}); }}>{"\u2715"}</button> + </div> + ) : ( + <button + className={`ctrl-icon-btn schedule-btn${schedulePickerOpen ? " active" : ""}`} + title="Download-Start planen" + onClick={() => { setSchedulePickerOpen((v) => !v); setScheduleTimeInput(""); }} + > + <svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" strokeWidth="2"><circle cx="12" cy="12" r="9"/><polyline points="12,6 12,12 16,14"/></svg> + </button> + )} + {schedulePickerOpen && (snapshot.settings.scheduledStartEpochMs || 0) === 0 && ( + <div className="schedule-picker"> + <span className="schedule-picker-label">Starten um</span> + <input + type="time" + className="schedule-time-input" + value={scheduleTimeInput} + onChange={(e) => setScheduleTimeInput(e.target.value)} + /> + <button className="btn btn-sm btn-primary" onClick={() => { + if (!scheduleTimeInput) return; + const [hStr, mStr] = scheduleTimeInput.split(":"); + const now = new Date(); + const target = new Date(now); + target.setHours(Number(hStr), Number(mStr), 0, 0); + if (target.getTime() <= now.getTime()) target.setDate(target.getDate() + 1); + void window.rd.updateSettings({ scheduledStartEpochMs: target.getTime() }).catch(() => {}); + setSchedulePickerOpen(false); + }}>Aktivieren</button> + </div> + )} + </div> + <div className="ctrl-separator" /> + <button + className="ctrl-icon-btn ctrl-move" + title="Ausgewählte nach oben" + disabled={tab !== "downloads" || [...selectedIds].filter((id) => snapshot.session.packages[id]).length === 0} + onClick={() => moveSelectedPackages("up")} + > + <svg viewBox="0 0 24 24" width="16" height="16"><path d="M12 4l-7 7h4.5v9h5v-9H19z" fill="currentColor" /></svg> + </button> + <button + className="ctrl-icon-btn ctrl-move" + title="Ausgewählte nach unten" + disabled={tab !== "downloads" || [...selectedIds].filter((id) => snapshot.session.packages[id]).length === 0} + onClick={() => moveSelectedPackages("down")} + > + <svg viewBox="0 0 24 24" width="16" height="16"><path d="M12 20l7-7h-4.5V4h-5v9H5z" fill="currentColor" /></svg> + </button> + </div> + {snapshot.reconnectSeconds > 0 && tab !== "downloads" && ( + <div className="reconnect-badge" style={{ marginLeft: "auto" }}>Reconnect: {snapshot.reconnectSeconds}s</div> + )} + </section> + + <nav className="tabs"> + <button className={tab === "downloads" ? "tab active" : "tab"} onClick={() => setTab("downloads")}>Downloads</button> + <button className={tab === "collector" ? "tab active" : "tab"} onClick={() => setTab("collector")}>Linksammler</button> + <button className={tab === "settings" ? "tab active" : "tab"} onClick={() => setTab("settings")}>Einstellungen</button> + <button className={tab === "history" ? "tab active" : "tab"} onClick={() => setTab("history")}>Verlauf</button> + <button className={tab === "statistics" ? "tab active" : "tab"} onClick={() => setTab("statistics")}>Statistiken</button> + <div className="tab-actions"> + {tab === "downloads" && ( + <DownloadSpeedSparkline + items={snapshot.session.items} + running={snapshot.session.running} + paused={snapshot.session.paused} + /> + )} + {tab === "downloads" && ( + <input + className="search-input tab-search" + type="search" + value={downloadSearch} + onChange={(event) => setDownloadSearch(event.target.value)} + placeholder="Pakete durchsuchen..." + /> + )} + </div> + </nav> + + <main className="tab-content"> + {tab === "collector" && ( + <section className="grid-two collector-view"> + <article className="card wide"> + <div className="collector-header"> + <h3>Linksammler</h3> + <div className="link-actions"> + <button className="btn" disabled={actionBusy} onClick={onImportDlc}>DLC import</button> + <button className="btn" disabled={actionBusy} onClick={onExportQueue}>Queue Export</button> + <button className="btn" disabled={actionBusy} onClick={onImportQueue}>Datei Import</button> + <button className="btn accent" disabled={actionBusy} onClick={onAddLinks}>Zur Queue hinzufügen</button> + </div> + </div> + <div className="collector-tabs"> + {collectorTabs.map((ct) => ( + <div key={ct.id} className={`collector-tab${ct.id === activeCollectorTab ? " active" : ""}`}> + <button onClick={() => setActiveCollectorTab(ct.id)}>{ct.name}</button> + {collectorTabs.length > 1 && <button className="close-tab" onClick={() => removeCollectorTab(ct.id)}>x</button>} + </div> + ))} + <button className="btn add-tab" onClick={addCollectorTab}>+</button> + </div> + <textarea + value={currentCollectorTab.text} + onChange={(e) => setCollectorTabs((prev) => prev.map((t) => t.id === currentCollectorTab.id ? { ...t, text: e.target.value } : t))} + onDragOver={(e) => e.preventDefault()} + placeholder={"# package: Release-Name\n# file: Folge 01.rar\nhttps://...\nhttps://...\n\nLinks, .dlc oder Export-Dateien hier ablegen"} + /> + </article> + </section> + )} + + {tab === "downloads" && ( + <section className="downloads-view"> + {snapshot.reconnectSeconds > 0 && ( + <div className="reconnect-banner"> + Reconnect aktiv: {snapshot.reconnectSeconds}s verbleibend + {snapshot.session.reconnectReason && <span> ({snapshot.session.reconnectReason})</span>} + </div> + )} + {} + <div ref={colHeaderBarRef} className="pkg-column-header" style={{ gridTemplateColumns: gridTemplate }} onContextMenu={(e) => { e.preventDefault(); setColHeaderCtx({ x: e.clientX, y: e.clientY }); }}> + {columnOrder.map((col) => { + const def = COLUMN_DEFS[col]; + if (!def) return null; + const sortCol = def.sortable; + const isActive = sortCol ? downloadsSortColumn === sortCol : false; + return ( + <span + key={col} + className={`pkg-col pkg-col-${col}${sortCol ? " sortable" : ""}${isActive ? " sort-active" : ""}${dragColId === col ? " pkg-col-dragging" : ""}${dropTargetCol === col ? " pkg-col-drop-target" : ""}`} + draggable + onDragStart={(e) => { e.dataTransfer.effectAllowed = "move"; setDragColId(col); }} + onDragOver={(e) => { if (dragColId && dragColId !== col) { e.preventDefault(); setDropTargetCol(col); } }} + onDragLeave={() => { if (dropTargetCol === col) setDropTargetCol(null); }} + onDrop={(e) => { + e.preventDefault(); + setDropTargetCol(null); + if (!dragColId || dragColId === col) return; + const newOrder = [...columnOrder]; + const fromIdx = newOrder.indexOf(dragColId); + const toIdx = newOrder.indexOf(col); + if (fromIdx < 0 || toIdx < 0) return; + newOrder.splice(fromIdx, 1); + newOrder.splice(toIdx, 0, dragColId); + setColumnOrder(newOrder); + setDragColId(null); + void window.rd.updateSettings({ columnOrder: newOrder }).catch(() => {}); + }} + onDragEnd={() => { setDragColId(null); setDropTargetCol(null); }} + onClick={sortCol ? () => { + const nextDesc = isActive ? !downloadsSortDescending : false; + setDownloadsSortColumn(sortCol); + setDownloadsSortDescending(nextDesc); + const baseOrder = packageOrderRef.current.length > 0 ? packageOrderRef.current : snapshot.session.packageOrder; + let sorted: string[]; + if (sortCol === "progress") { + sorted = sortPackageOrderByProgress(baseOrder, snapshot.session.packages, snapshot.session.items, nextDesc); + } else if (sortCol === "size") { + sorted = sortPackageOrderBySize(baseOrder, snapshot.session.packages, snapshot.session.items, nextDesc); + } else if (sortCol === "hoster") { + sorted = sortPackageOrderByHoster(baseOrder, snapshot.session.packages, snapshot.session.items, nextDesc); + } else { + sorted = sortPackageOrderByName(baseOrder, snapshot.session.packages, nextDesc); + } + pendingPackageOrderRef.current = [...sorted]; + pendingPackageOrderAtRef.current = Date.now(); + packageOrderRef.current = sorted; + setSnapshot((prev) => { + if (!prev) return prev; + return { ...prev, session: { ...prev.session, packageOrder: [...sorted] } }; + }); + void window.rd.reorderPackages(sorted).catch((error) => { + pendingPackageOrderRef.current = null; + pendingPackageOrderAtRef.current = 0; + packageOrderRef.current = serverPackageOrderRef.current; + setSnapshot((prev) => { + if (!prev) return prev; + return { ...prev, session: { ...prev.session, packageOrder: serverPackageOrderRef.current } }; + }); + showToast(`Sortierung fehlgeschlagen: ${String(error)}`, 2400); + }); + } : undefined} + > + {def.label} {isActive ? (downloadsSortDescending ? "\u25BC" : "\u25B2") : ""} + </span> + ); + })} + </div> + {totalPackageCount === 0 && <div className="empty">Noch keine Pakete in der Queue.</div>} + {totalPackageCount > 0 && packages.length === 0 && <div className="empty">Keine Pakete passend zur Suche.</div>} + {hiddenPackageCount > 0 && ( + <div className="reconnect-banner"> + Performance-Modus aktiv: {hiddenPackageCount} Paket(e) sind temporar ausgeblendet. + <button className="btn" onClick={() => setShowAllPackages(true)}>Alle trotzdem anzeigen</button> + </div> + )} + {visiblePackages.map((pkg, idx) => ( + <PackageCard + key={pkg.id} + pkg={pkg} + items={itemsByPackage.get(pkg.id) ?? []} + packageSpeed={packageSpeedMap.get(pkg.id) ?? 0} + stripeVariant={idx % 2 === 0 ? "a" : "b"} + isFirst={idx === 0} + isLast={idx === visiblePackages.length - 1} + isEditing={editingPackageId === pkg.id} + editingName={editingName} + collapsed={collapsedPackages[pkg.id] ?? false} + hideExtractedItems={snapshot.settings.hideExtractedItems} + sessionRunning={snapshot.session.running} + selectedIds={selectedIds} + columnOrder={columnOrder} + gridTemplate={gridTemplate} + onSelect={onSelectId} + onSelectMouseDown={onSelectMouseDown} + onSelectMouseEnter={onSelectMouseEnter} + onStartEdit={onPackageStartEdit} + onFinishEdit={onPackageFinishEdit} + onEditChange={setEditingName} + onToggleCollapse={onPackageToggleCollapse} + onCancel={onPackageCancel} + onMoveUp={onPackageMoveUp} + onMoveDown={onPackageMoveDown} + onToggle={onPackageToggle} + onRemoveItem={onPackageRemoveItem} + onContextMenu={onPackageContextMenu} + onDragStart={onPackageDragStart} + onDrop={onPackageDrop} + onDragEnd={onPackageDragEnd} + /> + ))} + </section> + )} + + {tab === "history" && ( + <section className="history-view"> + <div className="history-toolbar"> + <span className="history-count"> + {selectedHistoryIds.size > 0 + ? `${selectedHistoryIds.size} von ${historyEntries.length} ausgewählt` + : `${historyEntries.length} Paket${historyEntries.length !== 1 ? "e" : ""} im Verlauf`} + </span> + <div className="history-toolbar-actions"> + {selectedHistoryIds.size > 0 && ( + <button className="btn danger" onClick={() => { + const idSet = new Set(selectedHistoryIds); + void Promise.all([...idSet].map(id => window.rd.removeHistoryEntry(id))).then(() => { + setHistoryEntries((prev) => prev.filter((e) => !idSet.has(e.id))); + setSelectedHistoryIds(new Set()); + }).catch(() => { + void window.rd.getHistory().then((entries) => { setHistoryEntries(entries); setSelectedHistoryIds(new Set()); }).catch(() => {}); + }); + }}>Ausgewählte entfernen ({selectedHistoryIds.size})</button> + )} + {historyEntries.length > 0 && ( + <button className="btn danger" onClick={() => { void window.rd.clearHistory().then(() => { setHistoryEntries([]); setSelectedHistoryIds(new Set()); }).catch(() => {}); }}>Verlauf leeren</button> + )} + </div> + </div> + {historyEntries.length === 0 && <div className="empty">Noch keine abgeschlossenen Pakete im Verlauf.</div>} + {historyEntries.map((entry) => { + const collapsed = historyCollapsed[entry.id] ?? true; + const isSelected = selectedHistoryIds.has(entry.id); + return ( + <article + key={entry.id} + className={`package-card history-card${isSelected ? " pkg-selected" : ""}`} + onClick={(e) => { + if (e.ctrlKey) { + e.preventDefault(); + setSelectedHistoryIds((prev) => { + const next = new Set(prev); + if (next.has(entry.id)) next.delete(entry.id); else next.add(entry.id); + return next; + }); + } + }} + onContextMenu={(e) => { + e.preventDefault(); + e.stopPropagation(); + setSelectedHistoryIds((prev) => prev.has(entry.id) ? prev : new Set([entry.id])); + setHistoryCtxMenu({ x: e.clientX, y: e.clientY, entryId: entry.id }); + }} + > + <header onClick={(e) => { if (e.ctrlKey) return; setHistoryCollapsed((prev) => ({ ...prev, [entry.id]: !collapsed })); }} style={{ cursor: "pointer" }}> + <div className="pkg-columns" style={{ gridTemplateColumns: gridTemplate }}> + {columnOrder.map((col) => { + switch (col) { + case "name": return ( + <div key={col} className="pkg-col pkg-col-name"> + <button className="pkg-toggle" title={collapsed ? "Ausklappen" : "Einklappen"}>{collapsed ? "+" : "\u2212"}</button> + <h4>{entry.name}</h4> + </div> + ); + case "size": return ( + <span key={col} className="pkg-col pkg-col-size">{(() => { + const pct = entry.totalBytes > 0 ? Math.min(100, Math.round((entry.downloadedBytes / entry.totalBytes) * 100)) : 0; + const label = `${humanSize(entry.downloadedBytes)} / ${humanSize(entry.totalBytes)}`; + return entry.totalBytes > 0 ? ( + <span className="progress-size"> + <span className="progress-size-bar" style={{ width: `${pct}%` }} /> + <span className="progress-size-text">{label}</span> + <span className="progress-size-text-filled" style={{ clipPath: `inset(0 ${100 - pct}% 0 0)` }}>{label}</span> + </span> + ) : ""; + })()}</span> + ); + case "progress": return <span key={col} className="pkg-col pkg-col-progress">{entry.status === "completed" ? "100%" : ""}</span>; + case "hoster": return <span key={col} className="pkg-col pkg-col-hoster"></span>; + case "account": return <span key={col} className="pkg-col pkg-col-account">{entry.provider ? providerLabels[entry.provider] : ""}</span>; + case "prio": return <span key={col} className="pkg-col pkg-col-prio"></span>; + case "status": return <span key={col} className="pkg-col pkg-col-status">{entry.status === "completed" ? "Abgeschlossen" : "Gelöscht"}</span>; + case "speed": return <span key={col} className="pkg-col pkg-col-speed"></span>; + case "added": return <span key={col} className="pkg-col pkg-col-added">{formatDateTime(entry.completedAt)}</span>; + default: return null; + } + })} + </div> + </header> + <div className="progress"><div className="progress-dl" style={{ width: entry.status === "completed" ? "100%" : "0%" }} /></div> + {!collapsed && ( + <div className="history-details"> + <div className="history-detail-grid"> + <span className="history-label">Abgeschlossen am</span> + <span>{new Date(entry.completedAt).toLocaleString("de-DE")}</span> + <span className="history-label">Dateien</span> + <span>{entry.fileCount} Datei{entry.fileCount !== 1 ? "en" : ""}</span> + <span className="history-label">Gesamtgröße</span> + <span>{humanSize(entry.totalBytes)}</span> + <span className="history-label">Heruntergeladen</span> + <span>{humanSize(entry.downloadedBytes)}</span> + <span className="history-label">Dauer</span> + <span>{formatHistoryDuration(entry.durationSeconds)}</span> + <span className="history-label">Durchschnitt</span> + <span>{entry.durationSeconds > 0 ? formatSpeedMbps(Math.round(entry.downloadedBytes / entry.durationSeconds)) : ""}</span> + <span className="history-label">Provider</span> + <span>{entry.provider ? providerLabels[entry.provider] : ""}</span> + <span className="history-label">Zielordner</span> + <span className="history-path" title={entry.outputDir}>{entry.outputDir || ""}</span> + <span className="history-label">Status</span> + <span>{entry.status === "completed" ? "Abgeschlossen" : "Gelöscht"}</span> + </div> + <div className="history-actions"> + <button className="btn" onClick={() => { void window.rd.removeHistoryEntry(entry.id).then(() => { setHistoryEntries((prev) => prev.filter((e) => e.id !== entry.id)); setSelectedHistoryIds((prev) => { const n = new Set(prev); n.delete(entry.id); return n; }); }).catch(() => {}); }}>Eintrag entfernen</button> + </div> + </div> + )} + </article> + ); + })} + </section> + )} + + {tab === "statistics" && ( + <section className="statistics-view"> + <article className="card stats-overview"> + <h3>Session-Übersicht</h3> + <div className="stats-actions"> + <button className="btn btn-sm" onClick={() => { + void window.rd.resetSessionStats().then(() => { + showToast("Session-Statistik zurückgesetzt", 1800); + }).catch((error) => { + showToast(`Session-Reset fehlgeschlagen: ${String(error)}`, 2400); + }); + }}>Session zurücksetzen</button> + <button className="btn btn-sm" onClick={() => { + void window.rd.resetDownloadStats().then(() => { + showToast("Gesamt-Downloadstatistik zurückgesetzt", 1800); + }).catch((error) => { + showToast(`Download-Reset fehlgeschlagen: ${String(error)}`, 2400); + }); + }}>Heruntergeladen zurücksetzen</button> + </div> + <div className="stats-sections"> + {statsSections.map((section) => ( + <section key={section.key} className="stats-section"> + <div className="stats-section-title">{section.title}</div> + <div className="stats-grid"> + {section.items.map((item) => item.onClick ? ( + <button + key={item.key} + type="button" + className={`stat-item stat-button${item.clickable ? " stat-item-clickable" : ""}`} + title={item.title} + onClick={item.onClick} + > + <span className="stat-top"> + <span className="stat-eyebrow">{item.eyebrow}</span> + <span className="stat-label">{item.label}</span> + </span> + <StatValueView value={item.value} compact={item.compactValue} danger={item.danger} /> + </button> + ) : ( + <div key={item.key} className="stat-item"> + <span className="stat-top"> + <span className="stat-eyebrow">{item.eyebrow}</span> + <span className="stat-label">{item.label}</span> + </span> + <StatValueView value={item.value} compact={item.compactValue} /> + </div> + ))} + </div> + </section> + ))} + {false && (<> + <div className="stat-item"> + <span className="stat-label">Laufzeit (Session)</span> + <span className="stat-value">{formatRuntimeDuration(liveSessionRuntimeMs)}</span> + </div> + <div className="stat-item"> + <span className="stat-label">Laufzeit (Gesamt)</span> + <span className="stat-value">{formatRuntimeDuration(liveTotalRuntimeMs)}</span> + </div> + <div className="stat-item"> + <span className="stat-label">Fertige Dateien (Gesamt)</span> + <span className="stat-value">{snapshot.stats.totalFilesAllTime}</span> + </div> + <div className="stat-item"> + <span className="stat-label">Fertige Dateien (Session)</span> + <span className="stat-value">{snapshot.stats.totalFilesSession}</span> + </div> + <div className="stat-item"> + <span className="stat-label">Pakete</span> + <span className="stat-value">{snapshot.stats.totalPackages}</span> + </div> + <div className="stat-item"> + <span className="stat-label">Aktive Downloads</span> + <span className="stat-value">{itemStatusCounts.downloading}</span> + </div> + <div className="stat-item"> + <span className="stat-label">In Warteschlange</span> + <span className="stat-value">{itemStatusCounts.queued}</span> + </div> + <div + className={`stat-item${itemStatusCounts.failed > 0 ? " stat-item-clickable" : ""}`} + title={itemStatusCounts.failed > 0 ? "Klicken zum Zurücksetzen aller fehlerhaften Downloads" : undefined} + onClick={() => { + if (itemStatusCounts.failed === 0) return; + const failedIds = Object.values(snapshot.session.items) + .filter((it) => it.status === "failed") + .map((it) => it.id); + void window.rd.resetItems(failedIds).catch(() => {}); + }} + > + <span className="stat-label">Fehlerhaft</span> + <span className="stat-value danger">{itemStatusCounts.failed}</span> + </div> + <div className="stat-item"> + <span className="stat-label">{snapshot.etaText.includes(": ") ? snapshot.etaText.slice(0, snapshot.etaText.indexOf(": ")) : snapshot.etaText}</span> + <span className="stat-value">{snapshot.etaText.includes(": ") ? snapshot.etaText.slice(snapshot.etaText.indexOf(": ") + 2) : "--"}</span> + </div> + </>)} + </div> + </article> + + <article className="card stats-chart-card"> + <h3>Bandbreitenverlauf</h3> + <BandwidthChart items={snapshot.session.items} running={snapshot.session.running} paused={snapshot.session.paused} speedHistoryRef={speedHistoryRef} /> + </article> + + <article className="card stats-provider-card"> + <h3>Hoster-Statistik</h3> + <div className="provider-stats"> + {providerStats.map(([provider, stats]) => ( + <div key={provider} className="provider-stat-item"> + <span className="provider-name">{provider === "unknown" ? "Unbekannt" : provider}</span> + <div className="provider-bars"> + <div className="provider-bar"> + <div className="bar-fill completed" style={{ width: `${stats.total > 0 ? (stats.completed / stats.total) * 100 : 0}%` }} /> + </div> + </div> + <span className="provider-detail"> + {stats.completed}/{stats.total} fertig | {humanSize(stats.bytes)} + {stats.failed > 0 && <span className="danger"> | {stats.failed} Fehler</span>} + </span> + </div> + ))} + {Object.keys(snapshot.session.items).length === 0 && ( + <div className="empty-provider">Noch keine Downloads vorhanden.</div> + )} + </div> + </article> + </section> + )} + + {tab === "settings" && ( + <section className="settings-shell"> + <article className="card settings-toolbar"> + <div className="settings-toolbar-copy"> + <h3>Einstellungen</h3> + <span>Kompakt, schnell auffindbar und direkt speicherbar.</span> + </div> + <div className="settings-toolbar-actions-wrap"> + <div className="settings-toolbar-actions"> + <button className="btn accent" disabled={actionBusy} onClick={onSaveSettings}>Einstellungen speichern</button> + </div> + </div> + </article> + + <div className="settings-body"> + <nav className="settings-sidebar"> + {settingsSubTabs.map((st) => ( + <button key={st.key} className={`settings-sidebar-tab${settingsSubTab === st.key ? " active" : ""}`} onClick={() => setSettingsSubTab(st.key)}>{st.label}</button> + ))} + </nav> + <div className="settings-content" key={settingsSubTab}> + {settingsSubTab === "allgemein" && ( + <div className="settings-section card"> + <h3>Allgemein</h3> + <div className="settings-section-intro">Grundeinstellungen für Speicherort, Download-Verhalten, Verlauf, Oberfläche und Benachrichtigungen.</div> + + <h4 className="settings-subhead first">Speicherort</h4> + <label>Download-Ordner</label> + <div className="input-row"> + <input value={settingsDraft.outputDir} onChange={(e) => setText("outputDir", e.target.value)} /> + <button className="btn" onClick={() => { void performQuickAction(async () => { const s = await window.rd.pickFolder(); if (s) { setText("outputDir", s); } }); }}>Wählen</button> + </div> + <div className="setting-hint">Zielordner für alle heruntergeladenen Dateien.</div> + <label>Paketname (optional)</label> + <input value={settingsDraft.packageName} onChange={(e) => setText("packageName", e.target.value)} /> + <div className="setting-hint">Voreingestellter Name für neue Pakete; leer = automatisch aus den Links.</div> + + <h4 className="settings-subhead">Download-Verhalten</h4> + <div className="field-grid two"> + <div><label>Max. gleichzeitige Downloads</label><input type="number" min={1} max={50} value={settingsDraft.maxParallel} onChange={(e) => setNum("maxParallel", Math.max(1, Math.min(50, Number(e.target.value) || 1)))} /><div className="setting-hint">Gleichzeitige Downloads (1-50); höher lastet Leitung und Hoster-Slots stärker aus.</div></div> + <div><label>Automatische Wiederholungen</label><input type="number" min={0} max={99} value={settingsDraft.retryLimit} onChange={(e) => setNum("retryLimit", Math.max(0, Math.min(99, Number(e.target.value) || 0)))} /><div className="setting-hint">Wiederholungen pro Datei bei Fehlern; 0 = unbegrenzt weiterversuchen.</div></div> + </div> + <label className="toggle-line"><input type="checkbox" checked={settingsDraft.autoResumeOnStart} onChange={(e) => setBool("autoResumeOnStart", e.target.checked)} /> Beim Start automatisch fortsetzen</label> + <div className="setting-hint">Unterbrochene Downloads beim Programmstart automatisch fortsetzen.</div> + <label className="toggle-line"><input type="checkbox" checked={settingsDraft.clipboardWatch} onChange={(e) => setBool("clipboardWatch", e.target.checked)} /> Zwischenablage überwachen</label> + <div className="setting-hint">Kopierte Links werden automatisch erkannt und zur Liste hinzugefügt.</div> + + <h4 className="settings-subhead">Verlauf</h4> + <label>Verlauf speichern</label> + <select value={settingsDraft.historyRetentionMode} onChange={(e) => setText("historyRetentionMode", e.target.value)}> + {Object.entries(historyRetentionLabels).map(([key, label]) => ( + <option key={key} value={key}>{label}</option> + ))} + </select> + <div className="setting-hint">Nie / nur Sitzung / dauerhaft; die Grenzen unten wirken nur bei „Dauerhaft".</div> + <div className="field-grid two"> + <div><label>Maximale Verlauf-Einträge</label><input type="number" min={50} max={100000} value={settingsDraft.historyMaxEntries} disabled={settingsDraft.historyRetentionMode !== "permanent"} onChange={(e) => setNum("historyMaxEntries", Math.max(50, Math.min(100000, Number(e.target.value) || 500)))} /><div className="setting-hint">Obergrenze (Standard 500); älteste Einträge fallen darüber hinaus weg.</div></div> + <div><label>Einträge löschen älter als (Tage)</label><input type="number" min={0} max={3650} value={settingsDraft.historyMaxAgeDays} disabled={settingsDraft.historyRetentionMode !== "permanent"} onChange={(e) => setNum("historyMaxAgeDays", Math.max(0, Math.min(3650, Number(e.target.value) || 0)))} /><div className="setting-hint">Löscht Einträge älter als X Tage; 0 = aus. Nur bei „Dauerhaft" aktiv.</div></div> + </div> + + <h4 className="settings-subhead">Oberfläche & Bedienung</h4> + <label className="toggle-line"><input type="checkbox" checked={settingsDraft.collapseNewPackages} onChange={(e) => setBool("collapseNewPackages", e.target.checked)} /> Neue Pakete eingeklappt zeigen</label> + <div className="setting-hint">Neue Pakete starten zugeklappt; hält lange Listen übersichtlich.</div> + <label className="toggle-line"><input type="checkbox" checked={settingsDraft.autoSortPackagesByProgress} onChange={(e) => setBool("autoSortPackagesByProgress", e.target.checked)} /> Nach Fortschritt sortieren</label> + <div className="setting-hint">Laufende Pakete automatisch nach Fortschritt ordnen statt nach Reihenfolge.</div> + <label className="toggle-line"><input type="checkbox" checked={settingsDraft.minimizeToTray} onChange={(e) => setBool("minimizeToTray", e.target.checked)} /> In den Infobereich minimieren</label> + <div className="setting-hint">Legt das Fenster in den Tray statt zu beenden; läuft im Hintergrund weiter.</div> + <label className="toggle-line"><input type="checkbox" checked={settingsDraft.confirmDeleteSelection} onChange={(e) => setBool("confirmDeleteSelection", e.target.checked)} /> Vor dem Löschen nachfragen</label> + <div className="setting-hint">Sicherheitsabfrage vor dem Entfernen ausgewählter Einträge.</div> + <label className="toggle-line"><input type="checkbox" checked={settingsDraft.backupIncludeDownloads} onChange={(e) => setBool("backupIncludeDownloads", e.target.checked)} /> Download-Liste mitsichern</label> + <div className="setting-hint">Sicherung enthält auch die Download-Liste; Standard: nur Einstellungen.</div> + <label className="toggle-line"><input type="checkbox" checked={settingsDraft.backupIncludeRemoteDiagnostics} onChange={(e) => setBool("backupIncludeRemoteDiagnostics", e.target.checked)} /> Ferndiagnose-Einstellungen mitsichern</label> + <div className="setting-hint">Allowlist, Port und Freigabemodus (lokal/Netzwerk) reisen mit. Verbindungs-Token und eigene Adresse bleiben pro Server – nach dem Import einmal „Aktivieren" drücken.</div> + <label className="toggle-line"><input type="checkbox" checked={settingsDraft.theme === "light"} onChange={(e) => { + const next = e.target.checked ? "light" : "dark"; + settingsDraftRevisionRef.current += 1; + panelDirtyRevisionRef.current += 1; + settingsDirtyRef.current = true; + setSettingsDirty(true); + setSettingsDraft((prev) => ({ ...prev, theme: next as AppTheme })); + applyTheme(next as AppTheme); + }} /> Heller Modus</label> + <div className="setting-hint">Schaltet die Oberfläche von Dunkel auf Hell um.</div> + + <h4 className="settings-subhead">Discord-Benachrichtigungen</h4> + <label>Webhook-Adresse (Discord)</label> + <div className="input-row"> + <input value={settingsDraft.notifyUrl} placeholder="https://discord.com/api/webhooks/..." onChange={(e) => setText("notifyUrl", e.target.value)} /> + <button className="btn" disabled={actionBusy || !settingsDraft.notifyUrl.trim()} onClick={() => { + void performQuickAction(async () => { + const ok = await window.rd.testNotification(settingsDraft.notifyUrl, settingsDraft.notifyMention); + if (ok) { + showToast(settingsDirty ? "Test-Nachricht gesendet — Einstellungen jetzt noch speichern!" : "Test-Nachricht gesendet — schau in Discord", 4800); + } else { + showToast("Test fehlgeschlagen — Webhook-URL prüfen (Details unter Hilfe → Letzte Fehler)", 4200); + } + }, (error) => { + showToast(`Test fehlgeschlagen: ${String(error)}`, 3600); + }); + }}>Testen</button> + </div> + <div className="setting-hint">Discord-Webhook für Meldungen; per „Testen"-Button prüfbar. Leer = aus. In Discord: Servereinstellungen → Integrationen → Webhooks → Neuer Webhook → URL kopieren.</div> + <label>Discord-Erwähnung (optional)</label> + <input value={settingsDraft.notifyMention} placeholder="Deine User-ID, @everyone oder @here" onChange={(e) => setText("notifyMention", e.target.value)} /> + <div className="setting-hint">Wird jeder Meldung vorangestellt (User-ID, @everyone, @here), damit Discord pingt.</div> + <label className="toggle-line"><input type="checkbox" checked={settingsDraft.notifyOnPackageCompleted} onChange={(e) => setBool("notifyOnPackageCompleted", e.target.checked)} /> Melden, wenn ein Paket fertig ist</label> + <div className="setting-hint">Sendet eine Meldung, sobald ein Paket vollständig geladen wurde.</div> + <label className="toggle-line"><input type="checkbox" checked={settingsDraft.notifyOnPackageFailed} onChange={(e) => setBool("notifyOnPackageFailed", e.target.checked)} /> Melden, wenn ein Paket fehlschlägt</label> + <div className="setting-hint">Sendet eine Meldung, wenn ein Paket endgültig fehlschlägt.</div> + <label className="toggle-line"><input type="checkbox" checked={settingsDraft.notifyOnRunFinished} onChange={(e) => setBool("notifyOnRunFinished", e.target.checked)} /> Melden, wenn alles fertig ist</label> + <div className="setting-hint">Sendet eine Meldung, wenn die ganze Warteschlange abgearbeitet ist.</div> + </div> + )} + {settingsSubTab === "accounts" && ( + <div className="account-settings-layout"> + <div className="settings-section card account-board"> + <div className="account-board-header"> + <div> + <h3>Accounts</h3> + <div className="hint">Accounts werden als Liste verwaltet. Neue Einträge kommen über den Dialog oben rechts dazu.</div> + </div> + <div className="account-board-header-actions"> + <button className="btn" disabled={actionBusy || accountCheckBusy} onClick={() => { void checkAllAccounts(); }} title="Prüft Login-Gültigkeit und Premium-Restlaufzeit aller Mega-Debrid-/Debrid-Link-Accounts"> + {accountCheckBusy ? "Prüfe Accounts…" : "Alle prüfen"} + </button> + <button className="btn accent" disabled={actionBusy || availableAccountOptions.length === 0} onClick={openCreateAccountDialog}> + Account hinzufügen + </button> + </div> + </div> + + <div className="account-board-summary"> + <span className="account-inline-stat">{accountRows.length} {accountRows.length === 1 ? "Account" : "Accounts"}</span> + <span className="account-inline-stat">{availableAccountOptions.length} weitere Typen verfügbar</span> + </div> + + {configuredAccountServices.has("megadebrid-api") && configuredAccountServices.has("megadebrid-web") && ( + <div className="account-mode-note"> + <strong>Mega-Debrid API + Web sind derselbe Account</strong>, nur zwei Zugriffsarten: API (schnell) wird zuerst versucht, Web dient als Fallback. Gleicher Login, kein zweites Konto — beide Zeilen teilen sich dasselbe Tageslimit und denselben Premium-Status. + </div> + )} + + {accountRows.length === 0 && ( + <div className="account-empty-state"> + <strong>Noch keine Accounts hinterlegt</strong> + <span>Füge über "Account hinzufügen" den ersten Dienst hinzu. Danach erscheinen hier Status, Zugang und Aktionen als Liste.</span> + </div> + )} + + {accountRows.length > 0 && ( + <div className="acct2-table"> + <div className="acct2-head"> + <span className="acct2-c-check" /> + <span>Hoster</span> + <span>Download-Traffic</span> + <span className="acct2-sortable" title="Nach Premium-Restlaufzeit sortieren (längste/kürzeste)" onClick={cycleAccountStatusSort}>Status{accountStatusSort === "desc" ? " ▼" : accountStatusSort === "asc" ? " ▲" : ""}</span> + <span>Benutzername</span> + <span>Verfallsdatum</span> + <span className="acct2-c-actions">Aktion</span> + </div> + {groupedAccountRows.flatMap((group) => { + if (group.rows.length < 2) { + return group.rows.map((row) => renderAccountRow(row, false)); + } + const collapsed = collapsedAccountGroups.has(group.service); + let okCount = 0; + let problemCount = 0; + let offCount = 0; + for (const r of group.rows) { + if (r.disabled) { offCount += 1; continue; } + const s = r.accountId ? (snapshot.settings?.debridAccountStatuses?.[r.accountId] ?? null) : null; + if (r.checkable && s && !s.valid) { problemCount += 1; } else { okCount += 1; } + } + const elements: ReactElement[] = [ + <div key={`grp-${group.service}`} className="acct2-row acct2-group-head" title={collapsed ? "Ausklappen" : "Einklappen"} onClick={() => toggleAccountGroup(group.service)}> + <span className="acct2-c-check"><span className={`acct2-chevron${collapsed ? "" : " open"}`}>{"▶"}</span></span> + <span className="acct2-hoster"> + <strong>{group.serviceLabel}</strong> + <span className="acct2-mode">{group.rows.length} Accounts</span> + </span> + <span className="acct2-traffic" /> + <span className="acct2-status acct2-grp-sum"> + {okCount > 0 && <span className="account-validity-badge ok">{okCount} OK</span>} + {problemCount > 0 && <span className="account-validity-badge invalid">{problemCount} Problem</span>} + {offCount > 0 && <span className="account-validity-badge disabled">{offCount} aus</span>} + </span> + <span /> + <span /> + <span /> + </div> + ]; + if (!collapsed) { + for (const row of group.rows) elements.push(renderAccountRow(row, true)); + } + return elements; + })} + </div> + )} + </div> + + <div className="settings-section card"> + <div className="account-board-header"> + <div> + <h3>Rotations-Verlauf</h3> + <div className="hint">Zeigt, welcher Account/Key zuletzt für die Link-Umwandlung versucht wurde und warum gewechselt wurde.</div> + </div> + </div> + <div className="rotation-panel"> + {(!snapshot?.rotationEvents || snapshot.rotationEvents.length === 0) ? ( + <div className="rotation-empty">Noch keine Rotations-Ereignisse. Sobald ein Account/Key bei der Link-Umwandlung fehlschlägt oder gewechselt wird, erscheint es hier.</div> + ) : ( + snapshot.rotationEvents.map((ev) => ( + <div key={ev.id} className={`rotation-event ${ev.level}`}> + <span className="rotation-time">{new Date(ev.at).toLocaleTimeString()}</span> + <span className="rotation-body"> + <strong>{ev.provider} · {ev.accountLabel}</strong>{" "} + {rotationEventText(ev)} + {ev.reason ? <span className="rotation-reason"> ({ev.reason})</span> : null} + </span> + </div> + )) + )} + </div> + </div> + + <div className="settings-section card"> + <h3>Hoster-Reihenfolge</h3> + <div className="hint"> + Lege fest, in welcher Reihenfolge die Debrid-Accounts für Links genutzt werden. + Der erste Eintrag ist der Hauptaccount. Direkt-Hoster (1Fichier, DDownload) laufen separat und erscheinen nicht hier. + </div> + {activeProviderOrder.length === 0 && ( + <div className="account-empty-state compact"> + <strong>Keine Debrid-Reihenfolge verfügbar</strong> + <span>Füge mindestens einen Debrid-Account hinzu, dann kannst Du die Reihenfolge festlegen.</span> + </div> + )} + {activeProviderOrder.length > 0 && ( + <div className="provider-order-list"> + {activeProviderOrder.map((provider, idx) => ( + <div + key={provider} + className={`provider-order-row${draggedProvider === provider ? " dragging" : ""}${providerDropTarget === provider && draggedProvider !== provider ? " drag-target" : ""}`} + draggable + onDragStart={(event) => onProviderDragStart(event, provider)} + onDragOver={(event) => onProviderDragOver(event, provider)} + onDrop={(event) => onProviderDrop(event, provider)} + onDragEnd={onProviderDragEnd} + > + <span className="provider-order-num">{idx + 1}.</span> + <span className="provider-order-label">{providerLabelWithMode(provider, settingsDraft)}</span> + <div className="provider-order-actions"> + <button + className="btn btn-sm" + disabled={idx === 0} + onClick={() => { + const next = [...activeProviderOrder]; + [next[idx - 1], next[idx]] = [next[idx], next[idx - 1]]; + setProviderOrder(next); + }} + title="Nach oben" + >{"\u25B2"}</button> + <button + className="btn btn-sm" + disabled={idx === activeProviderOrder.length - 1} + onClick={() => { + const next = [...activeProviderOrder]; + [next[idx + 1], next[idx]] = [next[idx], next[idx + 1]]; + setProviderOrder(next); + }} + title="Nach unten" + >{"\u25BC"}</button> + </div> + </div> + ))} + </div> + )} + <label className="toggle-line"><input type="checkbox" checked={settingsDraft.autoProviderFallback} onChange={(e) => setBool("autoProviderFallback", e.target.checked)} /> Bei Fehlern oder Fair-Use automatisch zum nächsten Provider wechseln</label> + <label className="toggle-line"><input type="checkbox" checked={settingsDraft.rememberToken} onChange={(e) => setBool("rememberToken", e.target.checked)} /> Zugangsdaten lokal speichern</label> + </div> + + {configuredProviders.length >= 1 && ( + <div className="settings-section card"> + <h3>Hoster-Zuordnung</h3> + <div className="hint">Lege fest, welcher Debrid-Provider sich um welchen Filehoster kümmert. Nicht zugeordnete Hoster nutzen die Standard-Reihenfolge oben.</div> + {(() => { + const routing: Record<string, DebridProvider> = settingsDraft.hosterRouting || {}; + const routingEntries = Object.entries(routing).sort(([a], [b]) => a.localeCompare(b)); + const usedHosters = new Set(routingEntries.map(([h]) => h)); + const availableHosters = KNOWN_HOSTERS.filter((h) => !usedHosters.has(h.id)); + + const setRouting = (newRouting: Record<string, DebridProvider>) => { + settingsDraftRevisionRef.current += 1; + panelDirtyRevisionRef.current += 1; + settingsDirtyRef.current = true; + setSettingsDirty(true); + setSettingsDraft((prev) => ({ ...prev, hosterRouting: newRouting })); + }; + + const addEntry = (hosterId: string) => { + if (!hosterId || routing[hosterId]) return; + setRouting({ ...routing, [hosterId]: configuredProviders[0] }); + }; + + const removeEntry = (hosterId: string) => { + const copy = { ...routing }; + delete copy[hosterId]; + setRouting(copy); + }; + + const changeProvider = (hosterId: string, provider: DebridProvider) => { + setRouting({ ...routing, [hosterId]: provider }); + }; + + return ( + <> + {routingEntries.length > 0 && ( + <div className="hoster-routing-table"> + <div className="hoster-routing-header"> + <span>Filehoster</span> + <span>Zuständiger Provider</span> + <span></span> + </div> + {routingEntries.map(([hosterId, provider]) => { + const hosterLabel = KNOWN_HOSTERS.find((h) => h.id === hosterId)?.label || hosterId; + return ( + <div key={hosterId} className="hoster-routing-row"> + <span className="hoster-routing-label">{hosterLabel}</span> + <select value={provider} onChange={(e) => changeProvider(hosterId, e.target.value as DebridProvider)}> + {configuredProviders.map((p) => ( + <option key={p} value={p}>{providerLabelWithMode(p, settingsDraft)}</option> + ))} + </select> + <button className="btn btn-sm btn-danger" onClick={() => removeEntry(hosterId)} title="Zuordnung entfernen">×</button> + </div> + ); + })} + </div> + )} + {routingEntries.length === 0 && ( + <div className="hint" style={{ fontStyle: "italic", opacity: 0.7 }}>Noch keine Zuordnungen. Alle Hoster nutzen die Standard-Reihenfolge.</div> + )} + <div className="hoster-routing-add"> + <select + value="" + onChange={(e) => { + const val = e.target.value; + if (val === "__custom") { + const name = window.prompt("Hoster-Domain eingeben (z.B. rapidgator, turbobit):"); + const clean = (name || "").trim().toLowerCase().replace(/^www\./, "").split(".")[0]; + if (clean) addEntry(clean); + } else { + addEntry(val); + } + e.target.value = ""; + }} + > + <option value="" disabled>Hoster hinzufügen...</option> + {availableHosters.map((h) => ( + <option key={h.id} value={h.id}>{h.label}</option> + ))} + <option value="" disabled>───────────</option> + <option value="__custom">Eigener Hoster...</option> + </select> + </div> + </> + ); + })()} + </div> + )} + + <div hidden> + <div className="settings-section card"> + <h3>Accounts</h3> + <label>Real-Debrid API Token</label> + <input type="password" value={settingsDraft.token} onChange={(e) => setText("token", e.target.value)} /> + <label className="toggle-line"><input type="checkbox" checked={settingsDraft.realDebridUseWebLogin} onChange={(e) => setBool("realDebridUseWebLogin", e.target.checked)} /> Real-Debrid per Web-Login statt API-Token verwenden</label> + {settingsDraft.realDebridUseWebLogin && ( + <> + <div className="hint">Beim ersten Link oder über den Button unten öffnet sich ein Real-Debrid-Browserfenster. Der Login läuft dort manuell über die Website.</div> + <button className="btn" disabled={actionBusy} onClick={() => { void onOpenRealDebridLogin(); }}>Real-Debrid Web-Login öffnen</button> + </> + )} + <label>Mega-Debrid Accounts (Login:Passwort pro Zeile)</label> + <textarea rows={3} value={settingsDraft.megaCredentials || ""} onChange={(e) => setText("megaCredentials", e.target.value)} style={{ fontFamily: "monospace", resize: "vertical" }} placeholder={"user@example.com:passwort"} /> + <label className="toggle-line"><input type="checkbox" checked={settingsDraft.megaDebridPreferApi} onChange={(e) => setBool("megaDebridPreferApi", e.target.checked)} /> Mega-Debrid bevorzugt über API (schneller, Fallback auf Web)</label> + <label>BestDebrid API Token</label> + <input type="password" value={settingsDraft.bestToken} onChange={(e) => setText("bestToken", e.target.value)} /> + <label className="toggle-line"><input type="checkbox" checked={settingsDraft.bestDebridUseWebLogin} onChange={(e) => setBool("bestDebridUseWebLogin", e.target.checked)} /> BestDebrid per Cookie-Import statt API-Token verwenden</label> + {settingsDraft.bestDebridUseWebLogin && ( + <> + <div className="hint">Exportiere deine BestDebrid-Cookies als Netscape-Textdatei (z.B. mit der Browser-Extension "Get cookies.txt LOCALLY") und importiere sie hier.</div> + <button className="btn" disabled={actionBusy} onClick={() => { void onImportBestDebridCookies(); }}>BestDebrid Cookies importieren</button> + </> + )} + <label>AllDebrid API Key</label> + <input type="password" value={settingsDraft.allDebridToken} onChange={(e) => setText("allDebridToken", e.target.value)} /> + <label className="toggle-line"><input type="checkbox" checked={settingsDraft.allDebridUseWebLogin} onChange={(e) => setBool("allDebridUseWebLogin", e.target.checked)} /> AllDebrid per Web-Login statt API-Key verwenden</label> + {settingsDraft.allDebridUseWebLogin && ( + <> + <div className="hint">Beim ersten Link oder über den Button unten öffnet sich ein echtes AllDebrid-Browserfenster. Der Login läuft dort manuell, damit reCAPTCHA sauber funktioniert.</div> + <button className="btn" disabled={actionBusy} onClick={() => { void onOpenAllDebridLogin(); }}>AllDebrid Web-Login öffnen</button> + </> + )} + <div style={{ marginTop: 12, padding: 14, borderRadius: 14, border: "1px solid rgba(83, 168, 255, 0.22)", background: "rgba(10, 20, 35, 0.32)" }}> + <div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", gap: 12, flexWrap: "wrap" }}> + <strong>AllDebrid Rapidgator Status</strong> + <button className="btn" disabled={allDebridHostLoading || !hasSavedAllDebridAccount} onClick={() => { void loadAllDebridHostInfo(false); }}> + {allDebridHostLoading ? "Lade..." : "Status aktualisieren"} + </button> + </div> + {!hasSavedAllDebridAccount && ( + <div className="hint" style={{ marginTop: 10 }}>Nach dem Speichern eines AllDebrid-Accounts wird hier der Rapidgator-Status angezeigt.</div> + )} + {hasSavedAllDebridAccount && !allDebridHostInfo && !allDebridHostLoading && ( + <div className="hint" style={{ marginTop: 10 }}>Noch keine Host-Information geladen.</div> + )} + {hasSavedAllDebridAccount && allDebridHostLoading && !allDebridHostInfo && ( + <div className="hint" style={{ marginTop: 10 }}>Rapidgator-Status wird geladen...</div> + )} + {allDebridHostInfo && ( + <> + <div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fit, minmax(180px, 1fr))", gap: 12, marginTop: 12 }}> + <div> + <div className="hint" style={{ margin: 0 }}>Status</div> + <div>{allDebridHostInfo.statusLabel}</div> + </div> + <div> + <div className="hint" style={{ margin: 0 }}>Quelle</div> + <div>{formatAllDebridSourceLabel(allDebridHostInfo.source)}</div> + </div> + <div> + <div className="hint" style={{ margin: 0 }}>Letztes Update</div> + <div>{formatAllDebridTimestamp(allDebridHostInfo)}</div> + </div> + <div> + <div className="hint" style={{ margin: 0 }}>Quota</div> + <div>{formatAllDebridQuota(allDebridHostInfo)}</div> + </div> + <div> + <div className="hint" style={{ margin: 0 }}>Simultan-Downloads</div> + <div>{formatAllDebridSimuLimit(allDebridHostInfo)}</div> + </div> + </div> + {allDebridHostInfo.note && ( + <div className="hint" style={{ marginTop: 10 }}>{allDebridHostInfo.note}</div> + )} + </> + )} + {allDebridSettingsDirty && hasSavedAllDebridAccount && ( + <div className="hint" style={{ marginTop: 10 }}>Status basiert auf den zuletzt gespeicherten AllDebrid-Einstellungen.</div> + )} + </div> + <label>DDownload Login</label> + <input value={settingsDraft.ddownloadLogin || ""} onChange={(e) => setText("ddownloadLogin", e.target.value)} /> + <label>DDownload Passwort</label> + <input type="password" value={settingsDraft.ddownloadPassword || ""} onChange={(e) => setText("ddownloadPassword", e.target.value)} /> + <label>1Fichier API Key</label> + <input type="password" value={settingsDraft.oneFichierApiKey || ""} onChange={(e) => setText("oneFichierApiKey", e.target.value)} /> + <label className="toggle-line"><input type="checkbox" checked={settingsDraft.rememberToken} onChange={(e) => setBool("rememberToken", e.target.checked)} /> Zugangsdaten lokal speichern</label> + </div> + </div> + </div> + )} + {settingsSubTab === "entpacken" && ( + <div className="settings-section card"> + <h3>Entpacken</h3> + <div className="settings-section-intro">Wann und wie Archive entpackt werden, dazu Tonspur, Ablageform und Leistung.</div> + + <h4 className="settings-subhead first">Ziel & Ablauf</h4> + <label>Entpacken nach</label> + <div className="input-row"> + <input value={settingsDraft.extractDir} onChange={(e) => setText("extractDir", e.target.value)} /> + <button className="btn" onClick={() => { void performQuickAction(async () => { const s = await window.rd.pickFolder(); if (s) { setText("extractDir", s); } }); }}>Wählen</button> + </div> + <div className="setting-hint">Zielordner für die entpackten Dateien.</div> + <label className="toggle-line"><input type="checkbox" checked={settingsDraft.autoExtract} onChange={(e) => setBool("autoExtract", e.target.checked)} /> Automatisch entpacken</label> + <div className="setting-hint">Entpackt Archive automatisch, sobald das Paket vollständig geladen ist.</div> + <label className="toggle-line"><input type="checkbox" checked={settingsDraft.autoSkipExtracted} onChange={(e) => setBool("autoSkipExtracted", e.target.checked)} /> Bereits Entpacktes überspringen</label> + <div className="setting-hint">Überspringt beim Start Archive, deren Inhalt schon vorhanden ist.</div> + <label className="toggle-line"><input type="checkbox" checked={settingsDraft.hideExtractedItems} onChange={(e) => setBool("hideExtractedItems", e.target.checked)} /> Entpackte Einträge ausblenden</label> + <div className="setting-hint">Blendet fertig entpackte Einträge aus der Paketliste aus.</div> + <label className="toggle-line"><input type="checkbox" checked={settingsDraft.autoExtractWhenStopped} onChange={(e) => setBool("autoExtractWhenStopped", e.target.checked)} /> Entpacken auch ohne laufende Sitzung</label> + <div className="setting-hint">Entpackt offene Archive auch bei Stopp oder Programmstart ohne laufende Sitzung.</div> + + <h4 className="settings-subhead">Deutsche Tonspur</h4> + <label className="toggle-line"><input type="checkbox" checked={settingsDraft.keepGermanAudioOnly} onChange={(e) => setBool("keepGermanAudioOnly", e.target.checked)} /> Nur deutsche Tonspur behalten</label> + <div className="setting-hint">Reduziert .DL.-Videos auf die deutsche Spur; benötigt ffmpeg.</div> + <label>Welche Tonspur behalten</label> + <select value={settingsDraft.germanAudioMode} disabled={!settingsDraft.keepGermanAudioOnly} onChange={(e) => setText("germanAudioMode", e.target.value)}> + <option value="tag">Deutsche Spur per Sprach-Tag (empfohlen)</option> + <option value="first">Immer erste Tonspur (wie Script)</option> + </select> + <div className="setting-hint">Deutsche Spur per Sprach-Kennung (empfohlen) oder erste Spur; nur aktiv wenn oben an.</div> + + <h4 className="settings-subhead">Ablageform</h4> + <label className="toggle-line"><input type="checkbox" checked={settingsDraft.autoRename4sf4sj} onChange={(e) => setBool("autoRename4sf4sj", e.target.checked)} /> Automatisch umbenennen (Beta)</label> + <div className="setting-hint">Benennt kryptische 4sf/4sj-Dateinamen automatisch um (experimentell).</div> + <label className="toggle-line"><input type="checkbox" checked={settingsDraft.createExtractSubfolder} onChange={(e) => setBool("createExtractSubfolder", e.target.checked)} /> In Paket-Unterordner ablegen</label> + <div className="setting-hint">Legt entpackte Dateien je Paket in einen eigenen Unterordner.</div> + <label className="toggle-line"><input type="checkbox" checked={settingsDraft.collectMkvToLibrary} onChange={(e) => setBool("collectMkvToLibrary", e.target.checked)} /> Videos in Sammelordner verschieben</label> + <div className="setting-hint">Verschiebt fertige Videos nach Paketabschluss in einen zentralen Ordner.</div> + <label>Video-Sammelordner</label> + <div className="input-row"> + <input value={settingsDraft.mkvLibraryDir} onChange={(e) => setText("mkvLibraryDir", e.target.value)} disabled={!settingsDraft.collectMkvToLibrary} /> + <button className="btn" disabled={!settingsDraft.collectMkvToLibrary} onClick={() => { void performQuickAction(async () => { const s = await window.rd.pickFolder(); if (s) { setText("mkvLibraryDir", s); } }); }}>Wählen</button> + </div> + <div className="setting-hint">Zielordner für gesammelte Videos; nur aktiv wenn Sammeln eingeschaltet ist.</div> + + <h4 className="settings-subhead">Leistung</h4> + <label className="toggle-line"><input type="checkbox" checked={settingsDraft.hybridExtract} onChange={(e) => setBool("hybridExtract", e.target.checked)} /> Hybrid-Entpacken</label> + <div className="setting-hint">Entpackt schon während des Downloads weiter; spart Zeit, kostet mehr I/O.</div> + <div className="field-grid two"> + <div><label>Gleichzeitige Entpackungen</label><input type="number" min={1} max={8} value={settingsDraft.maxParallelExtract} onChange={(e) => setNum("maxParallelExtract", Math.max(1, Math.min(8, Number(e.target.value) || 2)))} /><div className="setting-hint">Parallele Entpackvorgänge (1-8); höher braucht mehr CPU und Datenträger.</div></div> + <div><label>CPU-Priorität beim Entpacken</label><select value={settingsDraft.extractCpuPriority} onChange={(e) => setText("extractCpuPriority", e.target.value)}> + <option value="high">Hoch (80% CPU)</option> + <option value="middle">Mittel (50% CPU)</option> + <option value="low">Niedrig (25% CPU)</option> + </select><div className="setting-hint">CPU-Anteil beim Entpacken: Hoch 80%, Mittel 50%, Niedrig 25%.</div></div> + </div> + + <h4 className="settings-subhead">Passwörter</h4> + <label>Passwortliste für Archive</label> + <textarea className="password-list" value={settingsDraft.archivePasswordList} onChange={(e) => setText("archivePasswordList", e.target.value)} placeholder={"serienfans.org\nserienjunkies.org\nmein-passwort"} /> + <div className="setting-hint">Ein Passwort pro Zeile; wird der Reihe nach an geschützten Archiven probiert.</div> + </div> + )} + {settingsSubTab === "geschwindigkeit" && ( + <div className="settings-section card"> + <h3>Geschwindigkeit</h3> + <div className="settings-section-intro">Tempo begrenzen, Verhalten bei Abbrüchen und zeitgesteuerte Bandbreitenregeln.</div> + + <h4 className="settings-subhead first">Tempo-Begrenzung</h4> + <label className="toggle-line"><input type="checkbox" checked={settingsDraft.speedLimitEnabled} onChange={(e) => setBool("speedLimitEnabled", e.target.checked)} /> Geschwindigkeit begrenzen</label> + <div className="setting-hint">Schaltet die Drosselung ein; die Felder unten wirken nur dann.</div> + <div className="field-grid two"> + <div> + <label>Höchstgeschwindigkeit (MB/s)</label> + <input type="number" min={0} step={0.1} value={speedLimitInput} onChange={(event) => setSpeedLimitInput(event.target.value)} onBlur={(event) => { const parsed = parseMbpsInput(event.target.value); if (parsed === null) { setSpeedLimitInput(formatMbpsInputFromKbps(settingsDraft.speedLimitKbps)); return; } setSpeedLimitMbps(parsed); setSpeedLimitInput(formatMbpsInputFromKbps(Math.floor(parsed * 1024))); }} disabled={!settingsDraft.speedLimitEnabled} /> + <div className="setting-hint">Maximales Tempo in MB/s; 0 = unbegrenzt.</div> + </div> + <div> + <label>Limit gilt für</label> + <select value={settingsDraft.speedLimitMode} onChange={(e) => setText("speedLimitMode", e.target.value)} disabled={!settingsDraft.speedLimitEnabled}> + <option value="global">Global</option> + <option value="per_download">Pro Download</option> + </select> + <div className="setting-hint">Global = Summe aller Downloads, Pro Download = je Download getrennt.</div> + </div> + </div> + + <h4 className="settings-subhead">Verbindung</h4> + <label className="toggle-line"><input type="checkbox" checked={settingsDraft.autoReconnect} onChange={(e) => setBool("autoReconnect", e.target.checked)} /> Automatisch neu verbinden</label> + <div className="setting-hint">Verbindet bei Abbruch automatisch neu, statt den Download zu beenden.</div> + <div><label>Wartezeit vor neuem Versuch (Sek.)</label><input type="number" min={10} max={600} value={settingsDraft.reconnectWaitSeconds} onChange={(e) => setNum("reconnectWaitSeconds", Math.max(10, Math.min(600, Number(e.target.value) || 45)))} /><div className="setting-hint">Wartezeit vor dem nächsten Verbindungsversuch (10-600 Sek.).</div></div> + + <h4 className="settings-subhead">Bandbreitenplanung</h4> + <div className="setting-hint">Pro Zeitfenster (Start-/End-Stunde) ein eigenes Limit, je Regel aktivierbar.</div> + {schedules.map((s, i) => { + const scheduleKey = s.id || `schedule-${i}`; + const speedInput = scheduleSpeedInputs[scheduleKey] ?? formatMbpsInputFromKbps(s.speedLimitKbps); + return ( + <div key={scheduleKey} className="schedule-row"> + <input type="number" min={0} max={23} value={s.startHour} onChange={(e) => { const v = Number(e.target.value); if (!Number.isNaN(v)) updateSchedule(i, "startHour", Math.max(0, Math.min(23, v))); }} title="Von (Stunde)" /> + <span>-</span> + <input type="number" min={0} max={23} value={s.endHour} onChange={(e) => { const v = Number(e.target.value); if (!Number.isNaN(v)) updateSchedule(i, "endHour", Math.max(0, Math.min(23, v))); }} title="Bis (Stunde)" /> + <span>Uhr</span> + <input type="number" min={0} step={0.1} value={speedInput} onChange={(event) => { setScheduleSpeedInputs((prev) => ({ ...prev, [scheduleKey]: event.target.value })); }} onBlur={(event) => { const parsed = parseMbpsInput(event.target.value); if (parsed === null) { setScheduleSpeedInputs((prev) => ({ ...prev, [scheduleKey]: formatMbpsInputFromKbps(s.speedLimitKbps) })); return; } const nextKbps = Math.floor(parsed * 1024); setScheduleSpeedInputs((prev) => ({ ...prev, [scheduleKey]: formatMbpsInputFromKbps(nextKbps) })); updateSchedule(i, "speedLimitKbps", nextKbps); }} title="MB/s (0=unbegrenzt)" /> + <span>MB/s</span> + <input type="checkbox" checked={s.enabled} onChange={(e) => updateSchedule(i, "enabled", e.target.checked)} /> + <button className="btn danger" onClick={() => removeSchedule(i)}>X</button> + </div> + ); + })} + <button className="btn" onClick={addSchedule}>Zeitregel hinzufügen</button> + </div> + )} + {settingsSubTab === "bereinigung" && ( + <div className="settings-section card"> + <h3>Bereinigung</h3> + <div className="settings-section-intro">Integritätsprüfung sowie Aufräumen nach dem Entpacken und bei fertigen Downloads.</div> + + <h4 className="settings-subhead first">Prüfung</h4> + <label className="toggle-line"><input type="checkbox" checked={settingsDraft.enableIntegrityCheck} onChange={(e) => setBool("enableIntegrityCheck", e.target.checked)} /> Dateien auf Fehler prüfen</label> + <div className="setting-hint">Prüft Prüfsummen (SFV/CRC/MD5/SHA1), um defekte Dateien zu erkennen.</div> + + <h4 className="settings-subhead">Nach dem Entpacken</h4> + <label className="toggle-line"><input type="checkbox" checked={settingsDraft.removeLinkFilesAfterExtract} onChange={(e) => setBool("removeLinkFilesAfterExtract", e.target.checked)} /> Link-Dateien danach entfernen</label> + <div className="setting-hint">Löscht übrig gebliebene Link-Dateien nach erfolgreichem Entpacken.</div> + <label className="toggle-line"><input type="checkbox" checked={settingsDraft.removeSamplesAfterExtract} onChange={(e) => setBool("removeSamplesAfterExtract", e.target.checked)} /> Vorschau-Dateien danach entfernen</label> + <div className="setting-hint">Löscht kleine Sample-Videos nach erfolgreichem Entpacken.</div> + <label>Archive nach dem Entpacken</label> + <select value={settingsDraft.cleanupMode} onChange={(e) => setText("cleanupMode", e.target.value)}> + <option value="none">keine Archive löschen</option> + <option value="trash">Archive in Papierkorb</option> + <option value="delete">Archive löschen</option> + </select> + <div className="setting-hint">Mit Archiven nach Erfolg: behalten, in den Papierkorb oder löschen.</div> + + <h4 className="settings-subhead">Fertige Downloads & Konflikte</h4> + <label>Fertige Downloads aus der Liste</label> + <select value={settingsDraft.completedCleanupPolicy} onChange={(e) => setText("completedCleanupPolicy", e.target.value)}> + {Object.entries(cleanupLabels).map(([key, label]) => (<option key={key} value={key}>{label}</option>))} + </select> + <div className="setting-hint">Wann erledigte Einträge verschwinden: nie, sofort, beim Start oder nach dem Paket.</div> + <label>Bei gleichnamigen Dateien</label> + <select value={settingsDraft.extractConflictMode} onChange={(e) => setText("extractConflictMode", e.target.value)}> + <option value="overwrite">überschreiben</option> + <option value="skip">überspringen</option> + <option value="rename">umbenennen</option> + <option value="ask">nachfragen</option> + </select> + <div className="setting-hint">Bei vorhandener Zieldatei: überschreiben, überspringen, umbenennen oder nachfragen.</div> + </div> + )} + {settingsSubTab === "updates" && ( + <div className="settings-section card"> + <h3>Updates</h3> + <div className="settings-section-intro">Quelle und Zeitpunkt der Update-Prüfung.</div> + + <h4 className="settings-subhead first">Aktualisierung</h4> + <label className="toggle-line"><input type="checkbox" checked={settingsDraft.autoUpdateCheck} onChange={(e) => setBool("autoUpdateCheck", e.target.checked)} /> Beim Start nach Updates suchen</label> + <div className="setting-hint">Prüft beim Programmstart automatisch, ob eine neue Version verfügbar ist.</div> + <label>Update-Quelle</label> + <input value={settingsDraft.updateRepo} onChange={(e) => setText("updateRepo", e.target.value)} /> + <div className="setting-hint">Quelle für die Update-Prüfung (Benutzer/Repo). Im Zweifel unverändert lassen.</div> + </div> + )} + </div> + </div> + </section> + )} + </main> + + {confirmPrompt && ( + <div className="modal-backdrop" onClick={() => closeConfirmPrompt(false)}> + <div className="modal-card" onClick={(event) => event.stopPropagation()}> + <h3>{confirmPrompt.title}</h3> + <p style={{ whiteSpace: "pre-line" }}>{confirmPrompt.message}</p> + {confirmPrompt.details && ( + <details className="modal-details"> + <summary>{confirmPrompt.detailsLabel || "Details anzeigen"}</summary> + <pre>{confirmPrompt.details}</pre> + </details> + )} + <div className="modal-actions"> + <button className="btn" onClick={() => closeConfirmPrompt(false)}>{confirmPrompt.cancelLabel || "Abbrechen"}</button> + <button + className={confirmPrompt.danger ? "btn danger" : "btn"} + onClick={() => closeConfirmPrompt(true)} + > + {confirmPrompt.confirmLabel} + </button> + </div> + </div> + </div> + )} + + {remoteDiagOpen && ( + <div className="modal-backdrop" onClick={() => setRemoteDiagOpen(false)}> + <div className="modal-card" onClick={(event) => event.stopPropagation()}> + <h3>Ferndiagnose</h3> + <p>Ermöglicht einer vertrauenswürdigen Support-Stelle den geschützten Lesezugriff auf Status, Logs und Fehler. Der Verbindungscode enthält das Zugriffstoken und ist wie ein Passwort zu behandeln.</p> + <div className="rd-status-line"> + <span className={`rd-dot${remoteDiag?.status.running ? " on" : ""}`} /> + <span> + {remoteDiag?.status.running + ? `Aktiv auf ${remoteDiag.status.host}:${remoteDiag.status.port}${remoteDiag.status.localOnly ? " (nur lokal)" : ` (Allowlist: ${remoteDiag.status.allowlistCount})`}` + : "Inaktiv"} + </span> + </div> + + <div className="rd-field"> + <label>Sichtbarkeit</label> + <div className="rd-seg"> + <button className={rdHostMode === "local" ? "active" : ""} onClick={() => setRdHostMode("local")}>Nur lokal</button> + <button className={rdHostMode === "network" ? "active" : ""} onClick={() => setRdHostMode("network")}>Im Netzwerk</button> + </div> + <span className="rd-hint"> + {rdHostMode === "local" + ? "Bindet nur an 127.0.0.1. Fernzugriff nur ueber einen Tunnel (z.B. Tailscale/SSH) - die sicherste Variante." + : "Bindet an 0.0.0.0. Erreichbar im Netzwerk, erfordert eine Allowlist. Nur in vertrauenswuerdigen Netzen/VPN nutzen."} + </span> + </div> + + <div className="rd-field"> + <label>Oeffentliche Adresse (fuer den Verbindungscode)</label> + <input + value={rdPublicHost} + placeholder={rdHostMode === "local" ? "127.0.0.1 oder Tunnel-Adresse" : "Server-IP oder DNS-Name"} + onChange={(event) => setRdPublicHost(event.target.value)} + /> + {remoteDiag && remoteDiag.suggestedHosts.length > 0 && ( + <div className="rd-chips"> + {remoteDiag.suggestedHosts.map((host) => ( + <button key={host} className="rd-chip" onClick={() => setRdPublicHost(host)}>{host}</button> + ))} + </div> + )} + </div> + + <div className="rd-field rd-field-inline"> + <div className="rd-field"> + <label>Port</label> + <input value={rdPort} onChange={(event) => setRdPort(event.target.value.replace(/[^0-9]/g, ""))} /> + </div> + <div className="rd-field"> + <label>Name (optional)</label> + <input value={rdName} placeholder="z.B. Server-Berlin" onChange={(event) => setRdName(event.target.value)} /> + </div> + </div> + + {rdHostMode === "network" && ( + <div className="rd-field"> + <label>Allowlist - erlaubte IPs/CIDR (eine pro Zeile)</label> + <textarea + value={rdAllowlist} + placeholder={"203.0.113.5\n10.0.0.0/24"} + onChange={(event) => setRdAllowlist(event.target.value)} + /> + <span className="rd-hint">Pflicht im Netzwerkmodus. Nur diese Quell-IPs duerfen verbinden (zusaetzlich zum Token). Loopback ist immer erlaubt.</span> + </div> + )} + + {remoteDiag?.status.running && remoteDiag.code && ( + <div className="rd-field"> + <label>Verbindungscode</label> + <div className="rd-code">{remoteDiag.code}</div> + <div className="rd-chips"> + <button className="btn" onClick={() => { void onCopyRemoteDiagnosticsCode(); }}>Kopieren</button> + <button className="btn" onClick={() => { void onRotateRemoteDiagnosticsToken(); }} disabled={remoteDiagBusy}>Token neu (Code ungueltig machen)</button> + </div> + <span className="rd-hint">Enthaelt das Zugriffstoken - wie ein Passwort behandeln. Token neu = alter Code wird sofort ungueltig.</span> + </div> + )} + + <div className="modal-actions"> + <button className="btn" onClick={() => setRemoteDiagOpen(false)}>Schliessen</button> + {remoteDiag?.status.running && ( + <button className="btn danger" onClick={() => { void onDisableRemoteDiagnostics(); }} disabled={remoteDiagBusy}>Deaktivieren</button> + )} + <button className="btn accent" onClick={() => { void onSubmitRemoteDiagnostics(); }} disabled={remoteDiagBusy}> + {remoteDiag?.status.running ? "Aktualisieren" : "Aktivieren"} + </button> + </div> + </div> + </div> + )} + + {deleteConfirm && (() => { + const itemCount = [...deleteConfirm.ids].filter((id) => snapshot.session.items[id]).length; + const pkgCount = [...deleteConfirm.ids].filter((id) => snapshot.session.packages[id]).length; + const removedItemIds = new Set<string>(); + for (const id of deleteConfirm.ids) { + if (snapshot.session.items[id]) removedItemIds.add(id); + const pkg = snapshot.session.packages[id]; + if (pkg) { for (const iid of pkg.itemIds) removedItemIds.add(iid); } + } + const totalRemaining = Math.max(0, Object.keys(snapshot.session.items).length - removedItemIds.size); + const parts: string[] = []; + if (pkgCount > 0) parts.push(`${pkgCount} Paket(e)`); + if (itemCount > 0) parts.push(`${itemCount} Link(s)`); + return ( + <div className="modal-backdrop" onClick={() => setDeleteConfirm(null)}> + <div className="modal-card" onClick={(e) => e.stopPropagation()}> + <h3>Bist Du Dir sicher?</h3> + <p>Möchtest Du wirklich diese Aufräumaktion(en) durchführen?<br />Ausgewählte Links löschen</p> + <p><strong>Zu erledigende Aufgaben:</strong><br />{parts.join(" + ")} löschen ? {totalRemaining} Link(s) verbleiben!</p> + <label className="toggle-line"> + <input type="checkbox" checked={deleteConfirm.dontAsk} onChange={(e) => setDeleteConfirm((prev) => prev ? { ...prev, dontAsk: e.target.checked } : prev)} /> + Nicht mehr anzeigen + </label> + <div className="modal-actions"> + <button className="btn" onClick={() => setDeleteConfirm(null)}>Abbrechen</button> + <button className="btn danger" onClick={() => { + if (deleteConfirm.dontAsk) { + setSettingsDraft((prev) => ({ ...prev, confirmDeleteSelection: false })); + void window.rd.updateSettings({ confirmDeleteSelection: false }).catch(() => {}); + } + executeDeleteSelection(deleteConfirm.ids); + setDeleteConfirm(null); + }}>Fortfahren</button> + </div> + </div> + </div> + ); + })()} + + {startConflictPrompt && ( + <div className="modal-backdrop" onClick={() => closeStartConflictPrompt(null)}> + <div className="modal-card" onClick={(event) => event.stopPropagation()}> + <h3>Paket bereits entpackt</h3> + <p> + <strong>{startConflictPrompt.entry.packageName}</strong> ist im Ziel bereits vorhanden. + </p> + <p>Bei "überspringen" wird nur das erneute Entpacken übersprungen - offene Downloads bleiben in der Queue.</p> + <p className="modal-path" title={startConflictPrompt.entry.extractDir}>{startConflictPrompt.entry.extractDir}</p> + <label className="toggle-line"> + <input + type="checkbox" + checked={startConflictPrompt.applyToAll} + onChange={(event) => { + const checked = event.target.checked; + setStartConflictPrompt((prev) => prev ? { ...prev, applyToAll: checked } : prev); + }} + /> + Für alle weiteren Pakete dieselbe Auswahl verwenden + </label> + <div className="modal-actions"> + <button className="btn" onClick={() => closeStartConflictPrompt(null)}>Abbrechen</button> + <button + className="btn" + onClick={() => closeStartConflictPrompt({ policy: "skip", applyToAll: startConflictPrompt.applyToAll })} + > + Entpacktes überspringen + </button> + <button + className="btn danger" + onClick={() => closeStartConflictPrompt({ policy: "overwrite", applyToAll: startConflictPrompt.applyToAll })} + > + überschreiben + </button> + </div> + </div> + </div> + )} + + {accountDialog && ( + <div className="modal-backdrop" onClick={closeAccountDialog}> + <div className="modal-card account-modal" onClick={(event) => event.stopPropagation()}> + <div className="account-modal-header"> + <div> + <h3>{accountDialog.mode === "edit" ? "Account bearbeiten" : "Account hinzufügen"}</h3> + <p>Wie in JDownloader: oben Account-Typ auswaehlen, unten Zugangsdaten direkt eintragen.</p> + </div> + </div> + + <div className="account-modal-body"> + <div className="account-dialog-step"> + <div className="account-dialog-step-label">1. Account-Typ auswaehlen</div> + <input + className="account-picker-search" + placeholder="Dienst oder Typ suchen" + value={accountDialogSearch} + onChange={(event) => setAccountDialogSearch(event.target.value)} + /> + <div className="account-picker-table"> + <div className="account-picker-head"> + <span>Account</span> + <span>Typ / Funktion</span> + </div> + {filteredAccountDialogOptions.length === 0 && ( + <div className="account-empty-state compact"> + <strong>Kein passender Account-Typ gefunden</strong> + <span> + {accountDialogSelectableOptions.length === 0 + ? "Alle verfügbaren Typen sind bereits vorhanden." + : "Passe den Suchbegriff an oder waehle einen Eintrag aus der Liste."} + </span> + </div> + )} + {filteredAccountDialogOptions.map((option) => ( + <button + key={option.kind} + className={`account-picker-row${accountDialog.kind === option.kind ? " active" : ""}`} + onClick={() => updateAccountDialogKind(option.kind)} + > + <div> + <strong>{option.title}</strong> + <span>{option.serviceLabel}</span> + </div> + <div> + <strong>{getAccountPickerFunctionLabel(option)}</strong> + <span>{option.pickerDescription}</span> + </div> + </button> + ))} + </div> + </div> + + <div className="account-dialog-step"> + <div className="account-dialog-step-label"> + {accountDialogOption ? `2. ${accountDialogOption.serviceLabel} - Zugangsdaten eingeben` : "2. Zugangsdaten eingeben"} + </div> + + {!accountDialogOption && ( + <div className="account-empty-state compact"> + <strong>Oben zuerst einen Account-Typ wählen</strong> + <span>Danach erscheinen hier direkt die passenden Felder für Login, Passwort oder API-Token.</span> + </div> + )} + + {accountDialogOption && ( + <> + <div className="account-form-summary"> + <strong>{accountDialogOption.title}</strong> + <span>{accountDialogOption.pickerDescription}</span> + </div> + + <div className="account-modal-fields"> + {(accountDialogOption.service === "megadebrid-api" || accountDialogOption.service === "megadebrid-web") && ( + <div> + <label>Account hinzufügen</label> + <div className="field-grid two" style={{ marginBottom: 8 }}> + <div> + <input + placeholder="Login / E-Mail" + value={accountDialog.megaNewLogin} + onChange={(event) => setAccountDialog((prev) => prev ? { ...prev, megaNewLogin: event.target.value } : prev)} + /> + </div> + <div> + <input + type="password" + placeholder="Passwort" + value={accountDialog.megaNewPassword} + onChange={(event) => setAccountDialog((prev) => prev ? { ...prev, megaNewPassword: event.target.value } : prev)} + /> + </div> + </div> + <button + className="btn" + disabled={!accountDialog.megaNewLogin.trim() || !accountDialog.megaNewPassword.trim()} + onClick={() => { + const login = accountDialog.megaNewLogin.trim(); + const password = accountDialog.megaNewPassword.trim(); + if (!login || !password) return; + const exists = accountDialog.megaAccounts.some((a) => a.login.trim().toLowerCase() === login.toLowerCase()); + setAccountDialog((prev) => { + if (!prev || !prev.megaNewLogin.trim() || !prev.megaNewPassword.trim()) return prev; + if (prev.megaAccounts.some((a) => a.login.trim().toLowerCase() === login.toLowerCase())) return prev; + const nextAccounts = [...prev.megaAccounts, { login, password }]; + return { ...prev, megaAccounts: nextAccounts, megaNewLogin: "", megaNewPassword: "", token: serializeMegaDebridAccounts(nextAccounts) }; + }); + if (!exists) { + void runMegaAccountCheck(login, password); + } + }} + > + Hinzufügen + </button> + {accountDialog.megaAccounts.length > 0 && ( + <div style={{ marginTop: 12 }}> + <label>Konfigurierte Accounts ({accountDialog.megaAccounts.length})</label> + <div className="account-dl-key-limit-list"> + {accountDialog.megaAccounts.map((account, index) => { + const accId = getMegaDebridAccountId(account.login); + const accDisabled = (accountDialog.megaDisabledIds || []).includes(accId); + return ( + <div key={index} className={`account-dl-key-limit-row${accDisabled ? " disabled" : ""}`}> + <div className="account-dl-key-meta"> + <strong>Account {index + 1}</strong> + <span>{maskMegaDebridLogin(account.login)}</span> + {accDisabled && <span className="account-validity-badge invalid" title="Dieser Account wird beim Download übersprungen, bleibt aber gespeichert.">Deaktiviert</span>} + {megaCheckingIds.has(accId) + ? <span className="account-validity-badge unknown" title="Account wird gerade geprüft…">Prüfe…</span> + : (() => { + const st = snapshot?.settings?.debridAccountStatuses?.[accId]; + if (!st) return <span className="account-validity-badge unknown" title="Noch nicht geprüft – auf „Alle prüfen“ klicken">Noch nicht geprüft</span>; + const checkedAgo = formatCheckedAgo(st.checkedAt); + const tip = `${st.message}${st.email ? ` · ${st.email}` : ""} · geprüft ${checkedAgo}`; + if (!st.valid) return <span className="account-validity-badge invalid" title={tip}>Login ungültig</span>; + if (!st.isPremium) return <span className="account-validity-badge free" title={tip}>Login OK · kein Premium</span>; + return <span className="account-validity-badge ok" title={tip}>{st.message}</span>; + })()} + </div> + <div style={{ display: "flex", gap: 6, flexShrink: 0 }}> + <button + className={accDisabled ? "btn success" : "btn"} + title={accDisabled ? "Account wieder aktivieren" : "Account temporär deaktivieren — wird beim Download übersprungen, aber nicht gelöscht"} + onClick={() => setAccountDialog((prev) => { + if (!prev) return prev; + const cur = prev.megaDisabledIds || []; + const nextDisabled = cur.includes(accId) + ? cur.filter((id) => id !== accId) + : [...cur, accId]; + return { ...prev, megaDisabledIds: nextDisabled }; + })} + > + {accDisabled ? "Aktivieren" : "Deaktivieren"} + </button> + <button + className="btn danger" + onClick={() => setAccountDialog((prev) => { + if (!prev) return prev; + const nextAccounts = prev.megaAccounts.filter((_, i) => i !== index); + return { ...prev, megaAccounts: nextAccounts, megaDisabledIds: (prev.megaDisabledIds || []).filter((id) => id !== accId), token: serializeMegaDebridAccounts(nextAccounts) }; + })} + > + Entfernen + </button> + </div> + </div> + ); + })} + </div> + </div> + )} + </div> + )} + {accountDialogOption.needsToken && accountDialogOption.service !== "megadebrid-api" && accountDialogOption.service !== "megadebrid-web" && ( + <div> + <label>{accountDialogOption.service === "alldebrid" || accountDialogOption.service === "onefichier" || accountDialogOption.service === "debridlink" ? "API-Key(s)" : "Token"}</label> + {accountDialogOption.service === "debridlink" ? ( + <textarea + rows={4} + placeholder="Ein API-Key pro Zeile" + value={accountDialog.token} + onChange={(event) => setAccountDialog((prev) => prev ? { + ...prev, + token: event.target.value, + keyDailyLimitGbById: buildDebridLinkKeyLimitInputs(event.target.value, prev.keyDailyLimitGbById, settingsDraft) + } : prev)} + style={{ fontFamily: "monospace", resize: "vertical" }} + /> + ) : ( + <input type="password" value={accountDialog.token} onChange={(event) => setAccountDialog((prev) => prev ? { ...prev, token: event.target.value } : prev)} /> + )} + </div> + )} + + {accountDialogOption.needsCredentials && ( + <div className="field-grid two"> + <div> + <label>Login / E-Mail</label> + <input value={accountDialog.login} onChange={(event) => setAccountDialog((prev) => prev ? { ...prev, login: event.target.value } : prev)} /> + </div> + <div> + <label>Passwort</label> + <input type="password" value={accountDialog.password} onChange={(event) => setAccountDialog((prev) => prev ? { ...prev, password: event.target.value } : prev)} /> + </div> + </div> + )} + + <div> + <label>Tageslimit (GB, optional)</label> + <input + inputMode="decimal" + placeholder="z.B. 250" + value={accountDialog.dailyLimitGb} + onChange={(event) => setAccountDialog((prev) => prev ? { ...prev, dailyLimitGb: event.target.value } : prev)} + /> + <div className="account-modal-note">Ab 00:00 wird der Zähler automatisch zurückgesetzt. Wenn das Limit erreicht ist, nutzt die App den nächsten Hoster aus der Reihenfolge.</div> + </div> + + {accountDialog.kind === "debridlink-api" && parseDebridLinkApiKeys(accountDialog.token).length > 0 && ( + <div> + <label>API-Key Limits (GB, optional pro Key)</label> + <div className="account-dl-key-limit-list"> + {parseDebridLinkApiKeys(accountDialog.token).map((key) => ( + <div key={key.id} className="account-dl-key-limit-row"> + <div className="account-dl-key-meta"> + <strong>{key.label}</strong> + <span>{key.masked}</span> + {(() => { + const st = snapshot?.settings?.debridAccountStatuses?.[key.id]; + if (!st) return <span className="account-validity-badge unknown" title="Noch nicht geprüft – auf „Alle prüfen“ klicken">Noch nicht geprüft</span>; + const tip = `${st.message}${st.email ? ` · ${st.email}` : ""} · geprüft ${formatCheckedAgo(st.checkedAt)}`; + if (!st.valid) return <span className="account-validity-badge invalid" title={tip}>Key ungültig</span>; + if (!st.isPremium) return <span className="account-validity-badge free" title={tip}>Key OK · kein Premium</span>; + return <span className="account-validity-badge ok" title={tip}>{st.message}</span>; + })()} + </div> + <input + inputMode="decimal" + placeholder="Kein Limit" + value={accountDialog.keyDailyLimitGbById[key.id] || ""} + onChange={(event) => setAccountDialog((prev) => prev ? { + ...prev, + keyDailyLimitGbById: { + ...prev.keyDailyLimitGbById, + [key.id]: event.target.value + } + } : prev)} + /> + </div> + ))} + </div> + <div className="account-modal-note">Leer lassen = unbegrenzt. Die Limits gelten pro API-Key und werden täglich um 00:00 zurückgesetzt.</div> + </div> + )} + + {accountDialog.kind === "realdebrid-web" && ( + <div className="account-modal-note">Nach dem Speichern kannst Du direkt das Browserfenster für den Web-Login öffnen.</div> + )} + {accountDialog.kind === "bestdebrid-web" && ( + <div className="account-modal-note">Der Web-Account arbeitet über einen Cookies.txt-Import aus dem Browser.</div> + )} + {accountDialog.kind === "alldebrid-web" && ( + <div className="account-modal-note">Der Web-Login nutzt ein echtes Browserfenster, damit reCAPTCHA sauber läuft.</div> + )} + {accountDialog.kind === "megadebrid-api" && ( + <div className="account-modal-note">Mehrere Accounts werden rotierend genutzt. Dieser Account nutzt nur die Mega-Debrid API. Kein Web-Fallback.</div> + )} + {accountDialog.kind === "megadebrid-web" && ( + <div className="account-modal-note">Mehrere Accounts werden rotierend genutzt. Dieser Account nutzt nur Mega-Debrid Web. Kein API-Fallback.</div> + )} + + {accountDialogOption.service === "alldebrid" && allDebridHostInfo && ( + <div className="account-status-grid"> + <div> + <span className="hint">Rapidgator-Status</span> + <strong>{allDebridHostInfo.statusLabel}</strong> + </div> + <div> + <span className="hint">Quelle</span> + <strong>{formatAllDebridSourceLabel(allDebridHostInfo.source)}</strong> + </div> + <div> + <span className="hint">Quota</span> + <strong>{formatAllDebridQuota(allDebridHostInfo)}</strong> + </div> + <div> + <span className="hint">Simultan</span> + <strong>{formatAllDebridSimuLimit(allDebridHostInfo)}</strong> + </div> + </div> + )} + </div> + + <div className="modal-actions"> + <button className="btn" onClick={closeAccountDialog}>Abbrechen</button> + {accountDialog.kind === "realdebrid-web" && ( + <button className="btn" disabled={actionBusy} onClick={() => { void onSaveAccountDialog("realdebrid-login"); }}> + Speichern + Login + </button> + )} + {accountDialog.kind === "bestdebrid-web" && ( + <button className="btn" disabled={actionBusy} onClick={() => { void onSaveAccountDialog("bestdebrid-cookies"); }}> + Speichern + Cookies + </button> + )} + {accountDialog.kind === "alldebrid-web" && ( + <button className="btn" disabled={actionBusy} onClick={() => { void onSaveAccountDialog("alldebrid-login"); }}> + Speichern + Login + </button> + )} + <button className="btn accent" disabled={actionBusy || !accountDialog.kind} onClick={() => { void onSaveAccountDialog(); }}> + Speichern + </button> + </div> + </> + )} + </div> + </div> + </div> + </div> + )} + + <footer className="status-bar"> + {totalPackageCount > 0 && ( + <button className="btn footer-btn" title={allPackagesCollapsed ? "Alle Pakete in der Liste ausklappen und Details anzeigen" : "Alle Pakete in der Liste einklappen und nur die Kopfzeilen anzeigen"} onClick={() => { + setCollapsedPackages((prev) => { + const next: Record<string, boolean> = { ...prev }; + const targetState = !allPackagesCollapsed; + for (const pkg of packages) { + next[pkg.id] = targetState; + if (targetState) { + manualCollapsedPkgsRef.current.add(pkg.id); + } else { + manualCollapsedPkgsRef.current.delete(pkg.id); + autoExpandedPkgsRef.current.delete(pkg.id); + } + } + return next; + }); + }}>{allPackagesCollapsed ? "Ausklappen" : "Einklappen"}</button> + )} + {totalPackageCount > 0 && ( + <button className="btn footer-btn" title="Alle Pakete und Links aus der Download-Queue entfernen" disabled={actionBusy} onClick={() => { + void performQuickAction(async () => { + const confirmed = await askConfirmPrompt({ title: "Queue löschen", message: "Wirklich alle Einträge aus der Queue löschen?", confirmLabel: "Alles löschen", danger: true }); + if (!confirmed) return; + await window.rd.clearAll(); + }); + }}>Leeren</button> + )} + {snapshot.clipboardActive && ( + <button className="btn footer-btn btn-active" title="Zwischenablage-Überwachung ist aktiv ? kopierte Links werden automatisch erkannt und zur Queue hinzugefügt. Zum Deaktivieren: Einstellungen ? Zwischenablage überwachen" disabled={actionBusy} onClick={() => { void performQuickAction(() => window.rd.toggleClipboard()); }}> + Clipboard: An + </button> + )} + <span className="footer-spacer" /> + <span>Pakete: {snapshot.stats.totalPackages}</span> + <span>Links: {Object.keys(snapshot.session.items).length}</span> + <span>Session: {humanSize(snapshot.stats.totalDownloaded)}</span> + <span>Gesamt: {humanSize(snapshot.stats.totalDownloadedAllTime)}</span> + <span>Hoster: {providerStats.length}</span> + <span>{snapshot.speedText}</span> + <span>{snapshot.etaText}</span> + </footer> + + {updateInstallProgress && ( + <div className={`update-popup update-popup-${updateInstallProgress.stage}`}> + <div className="update-popup-header"> + <span className="update-popup-title">Update</span> + {(updateInstallProgress.stage === "done" || updateInstallProgress.stage === "error") && ( + <button className="update-popup-close" onClick={() => setUpdateInstallProgress(null)} title="Schließen">×</button> + )} + </div> + <div className="update-popup-message">{formatUpdateInstallProgress(updateInstallProgress)}</div> + {updateInstallProgress.stage === "downloading" && updateInstallProgress.percent !== null && ( + <div className="update-popup-bar-track"> + <div className="update-popup-bar-fill" style={{ width: `${updateInstallProgress.percent}%` }} /> + </div> + )} + </div> + )} + {statusToast && <div className="toast">{statusToast}</div>} + {dragOver && <div className="drop-overlay">Links, .dlc oder Export-Dateien hier ablegen</div>} + {contextMenu && (() => { + const multi = selectedIds.size > 1; + const selectedPackageIds = [...selectedIds].filter((id) => snapshot.session.packages[id]); + const selectedItemIds = [...selectedIds].filter((id) => snapshot.session.items[id]); + const hasPackages = selectedPackageIds.length > 0; + const startableStatuses = new Set(["queued", "cancelled", "reconnect_wait"]); + const hasStartableItems = [...selectedIds].some((id) => { const it = snapshot.session.items[id]; return it && startableStatuses.has(it.status); }); + const hasItems = selectedItemIds.length > 0; + return ( + <div ref={ctxMenuRef} className="ctx-menu" style={{ left: contextMenu.x, top: contextMenu.y }} onClick={(e) => e.stopPropagation()}> + {(hasPackages || hasStartableItems) && ( + <button className="ctx-menu-item" onClick={() => { + const pkgIds = selectedPackageIds; + const itemIds = selectedItemIds.filter((id) => { const it = snapshot.session.items[id]; return it && startableStatuses.has(it.status); }); + if (pkgIds.length > 0) void window.rd.startPackages(pkgIds).catch(() => {}); + if (itemIds.length > 0) void window.rd.startItems(itemIds).catch(() => {}); + setContextMenu(null); + }}>Ausgewählte Downloads starten{multi ? ` (${selectedIds.size})` : ""}</button> + )} + <button className="ctx-menu-item" onClick={() => { void window.rd.start().catch(() => {}); setContextMenu(null); }}>Alle Downloads starten</button> + <div className="ctx-menu-sep" /> + <button className="ctx-menu-item" onClick={() => showLinksPopup(contextMenu.packageId, contextMenu.itemId)}>Linkadressen anzeigen</button> + {hasPackages && !contextMenu.itemId && ( + <button className="ctx-menu-item" onClick={() => { + void onExportPackageSelection(selectedPackageIds); + setContextMenu(null); + }}>{multi ? `Ausgewählte Pakete exportieren (${selectedPackageIds.length})` : "Paket exportieren"}</button> + )} + {contextMenu.itemId && ( + <button className="ctx-menu-item" onClick={() => { + void onExportItemSelection(multi ? selectedItemIds : [contextMenu.itemId!]); + setContextMenu(null); + }}>{multi ? `Ausgewählte Dateien exportieren (${selectedItemIds.length})` : "Datei exportieren"}</button> + )} + {hasPackages && !contextMenu.itemId && ( + <button className="ctx-menu-item" onClick={() => { + for (const id of selectedPackageIds) { + void window.rd.openPackageLog(id).catch(() => {}); + } + setContextMenu(null); + }}>Log öffnen{multi ? ` (${selectedPackageIds.length})` : ""}</button> + )} + {contextMenu.itemId && ( + <button className="ctx-menu-item" onClick={() => { + const itemIds = multi ? selectedItemIds : [contextMenu.itemId!]; + for (const id of itemIds) { + void window.rd.openItemLog(id).catch(() => {}); + } + setContextMenu(null); + }}>Item-Log öffnen{multi ? ` (${selectedItemIds.length})` : ""}</button> + )} + <div className="ctx-menu-sep" /> + {hasPackages && !contextMenu.itemId && ( + <button className="ctx-menu-item" onClick={() => { + for (const id of selectedIds) { if (snapshot.session.packages[id]) onPackageToggle(id); } + setContextMenu(null); + }}> + {multi ? `Alle ${selectedIds.size} umschalten` : (snapshot.session.packages[contextMenu.packageId]?.enabled ? "Deaktivieren" : "Aktivieren")} + </button> + )} + {!multi && contextMenu.itemId && ( + <button className="ctx-menu-item ctx-danger" onClick={() => { + setContextMenu(null); + const ids = new Set([contextMenu.itemId!]); + if (settingsDraft.confirmDeleteSelection) { setDeleteConfirm({ ids, dontAsk: false }); } + else { executeDeleteSelection(ids); } + }}>Entfernen</button> + )} + {selectedItemIds.length > 1 && !hasPackages && ( + <button className="ctx-menu-item ctx-danger" onClick={() => { + setContextMenu(null); + const ids = new Set(selectedItemIds); + if (settingsDraft.confirmDeleteSelection) { setDeleteConfirm({ ids, dontAsk: false }); } + else { executeDeleteSelection(ids); } + }}>Ausgewählte Dateien entfernen ({selectedItemIds.length})</button> + )} + {hasPackages && !contextMenu.itemId && ( + <button className="ctx-menu-item" onClick={() => { + for (const id of selectedPackageIds) void window.rd.resetPackage(id).catch(() => {}); + setContextMenu(null); + }}>Zurücksetzen{multi ? ` (${selectedPackageIds.length})` : ""}</button> + )} + {contextMenu.itemId && ( + <button className="ctx-menu-item" onClick={() => { + const itemIds = multi ? selectedItemIds : [contextMenu.itemId!]; + void window.rd.resetItems(itemIds).catch(() => {}); + setContextMenu(null); + }}>Zurücksetzen{multi ? ` (${selectedItemIds.length})` : ""}</button> + )} + {hasPackages && !multi && (() => { + const pkg = snapshot.session.packages[contextMenu.packageId]; + const items = pkg?.itemIds.map((id) => snapshot.session.items[id]).filter(Boolean) || []; + const someCompleted = items.some((item) => item && item.status === "completed" && !/^Entpackt\b/i.test(item.fullStatus || "")); + return (<> + {someCompleted && ( + <button className="ctx-menu-item" onClick={() => { void window.rd.extractNow(contextMenu.packageId).catch(() => {}); setContextMenu(null); }}>Jetzt entpacken</button> + )} + </>); + })()} + {hasPackages && !contextMenu.itemId && (<> + <div className="ctx-menu-sep" /> + <div className="ctx-menu-sub"> + <button className="ctx-menu-item">Priorität ></button> + <div className="ctx-menu-sub-items"> + {(["high", "normal", "low"] as const).map((p) => { + const label = p === "high" ? "Hoch" : p === "low" ? "Niedrig" : "Standard"; + const pkgIds = selectedPackageIds; + const allMatch = pkgIds.every((id) => (snapshot.session.packages[id]?.priority || "normal") === p); + return <button key={p} className={`ctx-menu-item${allMatch ? " ctx-menu-active" : ""}`} onClick={() => { for (const id of pkgIds) void window.rd.setPackagePriority(id, p).catch(() => {}); setContextMenu(null); }}>{allMatch ? `[Aktiv] ${label}` : label}</button>; + })} + </div> + </div> + </>)} + {hasItems && (() => { + const itemIds = selectedItemIds; + const skippable = itemIds.filter((id) => { const it = snapshot.session.items[id]; return it && (it.status === "queued" || it.status === "reconnect_wait"); }); + if (skippable.length === 0) return null; + return <button className="ctx-menu-item" onClick={() => { void window.rd.skipItems(skippable).catch(() => {}); setContextMenu(null); }}>überspringen{skippable.length > 1 ? ` (${skippable.length})` : ""}</button>; + })()} + {hasPackages && ( + <button className="ctx-menu-item ctx-danger" onClick={() => { + setContextMenu(null); + const ids = new Set(selectedPackageIds); + if (settingsDraft.confirmDeleteSelection) { setDeleteConfirm({ ids, dontAsk: false }); } + else { executeDeleteSelection(ids); } + }}>{multi ? `Ausgewählte entfernen (${selectedPackageIds.length})` : "Paket entfernen"}</button> + )} + </div> + ); + })()} + {colHeaderCtx && ( + <div ref={colHeaderCtxRef} className="ctx-menu" style={{ left: colHeaderCtx.x, top: colHeaderCtx.y }} onClick={(e) => e.stopPropagation()}> + {ALL_COLUMN_KEYS.map((col) => { + const def = COLUMN_DEFS[col]; + if (!def) return null; + const isVisible = columnOrder.includes(col); + const isRequired = col === "name"; + return ( + <button + key={col} + className={`ctx-menu-item${isRequired ? " ctx-menu-disabled" : ""}${isVisible ? " ctx-menu-active" : ""}`} + disabled={isRequired} + onClick={() => { + if (isRequired) return; + let newOrder: string[]; + if (isVisible) { + newOrder = columnOrder.filter((c) => c !== col); + } else { + newOrder = [...columnOrder]; + const defaultIdx = ALL_COLUMN_KEYS.indexOf(col); + let insertAt = newOrder.length; + for (let i = 0; i < newOrder.length; i++) { + if (ALL_COLUMN_KEYS.indexOf(newOrder[i]) > defaultIdx) { + insertAt = i; + break; + } + } + newOrder.splice(insertAt, 0, col); + } + setColumnOrder(newOrder); + void window.rd.updateSettings({ columnOrder: newOrder }).catch(() => {}); + }} + > + {isVisible ? "\u2713 " : "\u2003 "}{def.label} + </button> + ); + })} + </div> + )} + {historyCtxMenu && (() => { + const multi = selectedHistoryIds.size > 1; + const contextEntry = historyEntries.find(e => e.id === historyCtxMenu.entryId); + const hasUrls = (contextEntry?.urls?.length ?? 0) > 0; + const removeSelected = (): void => { + const idSet = new Set(selectedHistoryIds); + void Promise.all([...idSet].map(id => window.rd.removeHistoryEntry(id))).then(() => { + setHistoryEntries((prev) => prev.filter((e) => !idSet.has(e.id))); + setSelectedHistoryIds(new Set()); + }).catch(() => { + void window.rd.getHistory().then((entries) => { setHistoryEntries(entries); setSelectedHistoryIds(new Set()); }).catch(() => {}); + }); + setHistoryCtxMenu(null); + }; + return ( + <div ref={historyCtxMenuRef} className="ctx-menu" style={{ left: historyCtxMenu.x, top: historyCtxMenu.y }} onClick={(e) => e.stopPropagation()}> + <button className="ctx-menu-item ctx-danger" onClick={removeSelected}> + {multi ? `Ausgewählte entfernen (${selectedHistoryIds.size})` : "Eintrag entfernen"} + </button> + {hasUrls && !multi && ( + <> + <div className="ctx-menu-sep" /> + <button className="ctx-menu-item" onClick={() => { + const rawText = contextEntry!.urls!.join("\n"); + void window.rd.addLinks({ rawText, packageName: contextEntry!.name }).then((result) => { + if (result.addedLinks > 0) showToast(`${result.addedLinks} Link(s) zur Queue hinzugefügt`); + else showToast("Keine Links hinzugefügt"); + }).catch(() => showToast("Fehler beim Hinzufügen")); + setHistoryCtxMenu(null); + }}>Erneut herunterladen</button> + <button className="ctx-menu-item" onClick={() => { + const urls = contextEntry!.urls!; + const links = urls.map((u) => ({ name: u, url: u })); + setLinkPopup({ title: contextEntry!.name, links, isPackage: links.length > 1 }); + setHistoryCtxMenu(null); + }}>Linkadressen anzeigen</button> + </> + )} + <div className="ctx-menu-sep" /> + <button className="ctx-menu-item ctx-danger" onClick={() => { + void window.rd.clearHistory().then(() => { setHistoryEntries([]); setSelectedHistoryIds(new Set()); }).catch(() => {}); + setHistoryCtxMenu(null); + }}>Verlauf leeren</button> + </div> + ); + })()} + {keyStatsPopup && (() => { + const entry = configuredAccounts.find((a) => a.service === keyStatsPopup); + if (!entry || entry.debridLinkKeys.length === 0) return null; + const totalUsed = entry.debridLinkKeys.reduce((s, k) => s + k.dailyUsedBytes, 0); + const limitedCount = entry.debridLinkKeys.filter((k) => k.dailyLimitReached).length; + const disabledCount = entry.debridLinkKeys.filter((k) => k.disabled).length; + const keyDiagnostics = entry.debridLinkKeys + .map((k) => debridLinkHostLimits[k.id]) + .filter((info): info is DebridLinkHostLimitInfo => Boolean(info)); + const loadedQuotaCount = entry.debridLinkKeys.filter((k) => Boolean(debridLinkHostLimits[k.id])).length; + const invalidCount = keyDiagnostics.filter((info) => info.state === "invalid").length; + const cooldownCount = keyDiagnostics.filter((info) => info.state === "cooldown" || info.state === "quota" || info.state === "rate_limit").length; + const hostStatusLabel = keyDiagnostics.find((info) => info.hostState !== "unknown")?.hostStateLabel || ""; + return ( + <div className="modal-backdrop" onClick={() => setKeyStatsPopup(null)}> + <div className="modal-card key-stats-popup" onClick={(e) => e.stopPropagation()}> + <div className="key-stats-popup-header"> + <div> + <h3>API-Key Statistik</h3> + <p className="key-stats-summary"> + {entry.debridLinkKeys.length} Keys · Heute: {humanSize(totalUsed)} + {limitedCount > 0 && <span className="key-stats-warn"> · {limitedCount} am Limit</span>} + {disabledCount > 0 && <span className="key-stats-warn"> · {disabledCount} deaktiviert</span>} + {invalidCount > 0 && <span className="key-stats-warn"> · {invalidCount} invalid</span>} + {cooldownCount > 0 && <span className="key-stats-warn"> · {cooldownCount} im Cooldown</span>} + {debridLinkHostLimitsLoading && <span> · Rapidgator-Quota wird geladen ({loadedQuotaCount}/{entry.debridLinkKeys.length})</span>} + {!debridLinkHostLimitsLoading && !debridLinkHostLimitsError && <span> · Rapidgator {hostStatusLabel || "Status unbekannt"}</span>} + {debridLinkHostLimitsError && <span className="key-stats-warn"> · API-Quota konnte nicht geladen werden</span>} + </p> + </div> + <button className="update-popup-close" onClick={() => setKeyStatsPopup(null)}>×</button> + </div> + <div className="account-subkey-table"> + <div className="account-subkey-table-head"> + <span className="col-key">#</span> + <span className="col-masked">Key</span> + <span className="col-usage">Heute</span> + <span className="col-limit">Lokal</span> + <span className="col-status">Status</span> + <span className="col-traffic">RG Traffic</span> + <span className="col-links">RG Links</span> + <span className="col-action"></span> + </div> + {entry.debridLinkKeys.map((key, ki) => ( + <div key={key.id} className={`account-subkey-table-row${key.dailyLimitReached || (debridLinkHostLimits[key.id] && debridLinkHostLimits[key.id].state !== "ready") ? " warning" : ""}${key.disabled ? " disabled" : ""}`}> + {(() => { + const hostInfo = debridLinkHostLimits[key.id]; + const statusDisplay = getDebridLinkKeyStatusDisplay(key, hostInfo); + return ( + <> + <span className="col-key">{ki + 1}</span> + <span + className="col-masked link-popup-click" + title={`${key.masked}\nKlicken zum Kopieren`} + onClick={() => { + void navigator.clipboard.writeText(key.token) + .then(() => showToast(`${key.label} kopiert`, 1800)) + .catch(() => showToast("Kopieren fehlgeschlagen", 2200)); + }} + > + {key.masked} + </span> + <span className="col-usage">{humanSize(key.dailyUsedBytes)}</span> + <span className="col-limit">{key.disabled ? "Deaktiviert" : key.dailyLimitBytes > 0 ? humanSize(key.dailyLimitBytes) : "Kein Limit"}</span> + <span className={`col-status status-pill status-pill-${statusDisplay.tone}`} title={statusDisplay.title}>{statusDisplay.label}</span> + <span className="col-traffic" title={hostInfo?.note || ""}>{formatDebridLinkTraffic(hostInfo)}</span> + <span className="col-links" title={hostInfo?.note || ""}>{formatDebridLinkCountQuota(hostInfo)}</span> + <span className="col-action"> + <button + className={`btn btn-sm ${key.disabled ? "success" : "danger"}`} + disabled={actionBusy} + onClick={() => { void onToggleDebridLinkApiKeyEnabled(entry, key); }} + > + {key.disabled ? "Aktivieren" : "Deaktivieren"} + </button> + <button + className="btn btn-sm" + disabled={actionBusy || key.dailyUsedBytes <= 0} + onClick={() => { void onResetDebridLinkApiKeyDailyUsage(entry, key.id, key.label); }} + > + Reset + </button> + </span> + </> + ); + })()} + </div> + ))} + </div> + <div className="modal-actions"> + <button className="btn" onClick={() => setKeyStatsPopup(null)}>Schließen</button> + </div> + </div> + </div> + ); + })()} + {linkPopup && ( + <div className="modal-backdrop" onClick={() => setLinkPopup(null)}> + <div className="modal-card link-popup" onClick={(e) => e.stopPropagation()}> + <h3>Linkadressen anzeigen</h3> + <p>{linkPopup.title}</p> + <div className="link-popup-list"> + {linkPopup.links.map((link, i) => ( + <div key={i} className="link-popup-row"> + <span className="link-popup-name link-popup-click" title={`${link.name}\nKlicken zum Kopieren`} onClick={() => { void navigator.clipboard.writeText(link.name).then(() => showToast("Name kopiert")).catch(() => showToast("Kopieren fehlgeschlagen")); }}>{link.name}</span> + <span className="link-popup-url link-popup-click" title={`${link.url}\nKlicken zum Kopieren`} onClick={() => { void navigator.clipboard.writeText(link.url).then(() => showToast("Link kopiert")).catch(() => showToast("Kopieren fehlgeschlagen")); }}>{link.url}</span> + </div> + ))} + </div> + <div className="modal-actions"> + {linkPopup.isPackage && ( + <button className="btn" onClick={() => { + const text = linkPopup.links.map((l) => l.name).join("\n"); + void navigator.clipboard.writeText(text).then(() => showToast("Alle Namen kopiert")).catch(() => showToast("Kopieren fehlgeschlagen")); + }}>Alle Namen kopieren</button> + )} + {linkPopup.isPackage && ( + <button className="btn" onClick={() => { + const text = linkPopup.links.map((l) => l.url).join("\n"); + void navigator.clipboard.writeText(text).then(() => showToast("Alle Links kopiert")).catch(() => showToast("Kopieren fehlgeschlagen")); + }}>Alle Links kopieren</button> + )} + <button className="btn" onClick={() => setLinkPopup(null)}>Schließen</button> + </div> + </div> + </div> + )} + </div> + ); +} + +function computeDisplayedItemStatus(item: DownloadItem, sessionRunning: boolean): string { + const statusText = String(item.fullStatus || "").trim(); + if (statusText === "Wartet") return ""; + if (sessionRunning) return statusText; + if (item.status !== "queued" && item.status !== "reconnect_wait") return statusText; + if (statusText === "Paket gestoppt") return statusText; + if (/^Entpacken\b/i.test(statusText) || /^Entpackt\b/i.test(statusText) || /^Entpack-Fehler\b/i.test(statusText) || /^Fertig\b/i.test(statusText)) { + return statusText; + } + return ""; +} + +interface ItemRowProps { + item: DownloadItem; + packageId: string; + isSelected: boolean; + sessionRunning: boolean; + columnOrder: string[]; + gridTemplate: string; + onSelect: (id: string, ctrlKey: boolean, shiftKey: boolean) => void; + onSelectMouseDown: (id: string, e: React.MouseEvent) => void; + onSelectMouseEnter: (id: string) => void; + onContextMenu: (packageId: string, itemId: string | undefined, x: number, y: number) => void; +} + +const ItemRow = memo(function ItemRow({ item, packageId, isSelected, sessionRunning, columnOrder, gridTemplate, onSelect, onSelectMouseDown, onSelectMouseEnter, onContextMenu }: ItemRowProps): ReactElement { + const handleClick = useCallback((e: React.MouseEvent) => { + e.stopPropagation(); + onSelect(item.id, e.ctrlKey, e.shiftKey); + }, [item.id, onSelect]); + const handleMouseDown = useCallback((e: React.MouseEvent) => { + e.stopPropagation(); + onSelectMouseDown(item.id, e); + }, [item.id, onSelectMouseDown]); + const handleMouseEnter = useCallback(() => { + onSelectMouseEnter(item.id); + }, [item.id, onSelectMouseEnter]); + const handleContextMenu = useCallback((e: React.MouseEvent) => { + e.preventDefault(); + e.stopPropagation(); + onContextMenu(packageId, item.id, e.clientX, e.clientY); + }, [packageId, item.id, onContextMenu]); + const formattedCreatedAt = useMemo(() => formatDateTime(item.createdAt), [item.createdAt]); + const displayStatus = useMemo(() => computeDisplayedItemStatus(item, sessionRunning), [item, sessionRunning]); + const retrySuffix = item.retries > 0 ? ` (R${item.retries})` : ""; + const lastErrorText = String(item.lastError || "").trim(); + const statusTitle = displayStatus + ? (lastErrorText && lastErrorText !== displayStatus && !displayStatus.includes(lastErrorText) + ? `${displayStatus}${retrySuffix}\n${lastErrorText}` + : `${displayStatus}${retrySuffix}`) + : lastErrorText; + + return ( + <div + className={`item-row${isSelected ? " item-selected" : ""}`} + style={{ gridTemplateColumns: gridTemplate }} + onClick={handleClick} + onMouseDown={handleMouseDown} + onMouseEnter={handleMouseEnter} + onContextMenu={handleContextMenu} + > + {columnOrder.map((col) => { + switch (col) { + case "name": return ( + <span key={col} className="pkg-col pkg-col-name item-indent" title={item.fileName}> + <span + className={item.onlineStatus ? `link-status-dot ${item.onlineStatus}` : "link-status-dot link-status-dot-empty"} + title={item.onlineStatus === "online" ? "Online" : item.onlineStatus === "offline" ? "Offline" : item.onlineStatus === "checking" ? "Wird geprüft..." : undefined} + /> + {item.fileName} + </span> + ); + case "size": { + const total = item.totalBytes || item.downloadedBytes || 0; + const dl = item.downloadedBytes || 0; + const pct = total > 0 ? Math.min(100, Math.round((dl / total) * 100)) : 0; + const label = `${humanSize(dl)} / ${humanSize(total)}`; + return ( + <span key={col} className="pkg-col pkg-col-size"> + {total > 0 ? ( + <span className="progress-size progress-size-small"> + <span className="progress-size-bar" style={{ width: `${pct}%` }} /> + <span className="progress-size-text">{label}</span> + <span className="progress-size-text-filled" style={{ clipPath: `inset(0 ${100 - pct}% 0 0)` }}>{label}</span> + </span> + ) : ""} + </span> + ); + } + case "progress": return ( + <span key={col} className="pkg-col pkg-col-progress"> + {(item.totalBytes || 0) > 0 ? ( + <span className="progress-inline progress-inline-small"> + <span className="progress-inline-bar" style={{ width: `${item.progressPercent}%` }} /> + <span className="progress-inline-text">{item.progressPercent}%</span> + <span className="progress-inline-text-filled" style={{ clipPath: `inset(0 ${100 - (item.progressPercent || 0)}% 0 0)` }}>{item.progressPercent}%</span> + </span> + ) : ""} + </span> + ); + case "hoster": { const h = extractHoster(item.url) || ""; return <span key={col} className="pkg-col pkg-col-hoster" title={h}>{h}</span>; } + case "account": return <span key={col} className="pkg-col pkg-col-account">{item.providerLabel || (item.provider ? providerLabels[item.provider] : "")}</span>; + case "prio": return <span key={col} className="pkg-col pkg-col-prio"></span>; + case "status": return ( + <span key={col} className="pkg-col pkg-col-status" title={statusTitle}> + {displayStatus} + </span> + ); + case "speed": return <span key={col} className="pkg-col pkg-col-speed">{item.speedBps > 0 ? formatSpeedMbps(item.speedBps) : ""}</span>; + case "added": return <span key={col} className="pkg-col pkg-col-added">{formattedCreatedAt}</span>; + default: return null; + } + })} + </div> + ); +}, (prev, next) => { + if (prev.item !== next.item) { + const a = prev.item; + const b = next.item; + if (a.id !== b.id + || a.updatedAt !== b.updatedAt + || a.status !== b.status + || a.fileName !== b.fileName + || a.url !== b.url + || a.provider !== b.provider + || a.providerLabel !== b.providerLabel + || a.fullStatus !== b.fullStatus + || a.onlineStatus !== b.onlineStatus + || a.progressPercent !== b.progressPercent + || a.speedBps !== b.speedBps + || a.downloadedBytes !== b.downloadedBytes + || a.totalBytes !== b.totalBytes + || a.retries !== b.retries + || a.createdAt !== b.createdAt) { + return false; + } + } + if (prev.packageId !== next.packageId) return false; + if (prev.isSelected !== next.isSelected) return false; + if (prev.sessionRunning !== next.sessionRunning) return false; + if (prev.columnOrder !== next.columnOrder) return false; + if (prev.gridTemplate !== next.gridTemplate) return false; + if (prev.onSelect !== next.onSelect) return false; + if (prev.onSelectMouseDown !== next.onSelectMouseDown) return false; + if (prev.onSelectMouseEnter !== next.onSelectMouseEnter) return false; + if (prev.onContextMenu !== next.onContextMenu) return false; + return true; +}); + +interface PackageCardProps { + pkg: PackageEntry; + items: DownloadItem[]; + packageSpeed: number; + stripeVariant: "a" | "b"; + isFirst: boolean; + isLast: boolean; + isEditing: boolean; + editingName: string; + collapsed: boolean; + hideExtractedItems: boolean; + sessionRunning: boolean; + selectedIds: Set<string>; + columnOrder: string[]; + gridTemplate: string; + onSelect: (id: string, ctrlKey: boolean, shiftKey: boolean) => void; + onSelectMouseDown: (id: string, e: React.MouseEvent) => void; + onSelectMouseEnter: (id: string) => void; + onStartEdit: (packageId: string, packageName: string) => void; + onFinishEdit: (packageId: string, currentName: string, nextName: string) => void; + onEditChange: (name: string) => void; + onToggleCollapse: (packageId: string) => void; + onCancel: (packageId: string) => void; + onMoveUp: (packageId: string) => void; + onMoveDown: (packageId: string) => void; + onToggle: (packageId: string) => void; + onRemoveItem: (itemId: string) => void; + onContextMenu: (packageId: string, itemId: string | undefined, x: number, y: number) => void; + onDragStart: (packageId: string) => void; + onDrop: (packageId: string) => void; + onDragEnd: () => void; +} + +const PackageCard = memo(function PackageCard({ pkg, items, packageSpeed, stripeVariant, isFirst, isLast, isEditing, editingName, collapsed, hideExtractedItems, sessionRunning, selectedIds, columnOrder, gridTemplate, onSelect, onSelectMouseDown, onSelectMouseEnter, onStartEdit, onFinishEdit, onEditChange, onToggleCollapse, onCancel, onMoveUp, onMoveDown, onToggle, onRemoveItem, onContextMenu, onDragStart, onDrop, onDragEnd }: PackageCardProps): ReactElement { + const stats = useMemo(() => { + let done = 0; + let failed = 0; + let cancelled = 0; + let extracted = 0; + let extracting = false; + let activeProgress = 0; + let extractingProgress = 0; + for (const item of items) { + if (item.status === "completed") done += 1; + else if (item.status === "failed") failed += 1; + else if (item.status === "cancelled") cancelled += 1; + const fs = item.fullStatus || ""; + if (fs.startsWith("Entpackt")) { + extracted += 1; + } else if (fs.startsWith("Entpacken")) { + extracting = true; + const m = fs.match(/^Entpacken\s+(\d+)%/); + if (m) extractingProgress += Number(m[1]) / 100; + } + if (item.status === "downloading" || (item.status === "queued" && (item.progressPercent || 0) > 0)) { + activeProgress += (item.progressPercent || 0) / 100; + } + } + const total = Math.max(1, items.length); + const allDownloaded = done + failed + cancelled >= total; + const allExtracted = extracted >= total; + const useExtractSplit = extracting || pkg.status === "extracting" || (allDownloaded && !allExtracted && done > 0 && extracted > 0 && failed === 0 && cancelled === 0); + const dlProgress = Math.min(useExtractSplit ? 50 : 100, Math.floor(((done + activeProgress) / total) * (useExtractSplit ? 50 : 100))); + const exProgress = Math.min(50, Math.floor(((extracted + extractingProgress) / total) * 50)); + const combinedProgress = Math.min(100, useExtractSplit ? dlProgress + exProgress : dlProgress); + return { done, failed, cancelled, extracted, extracting, total, useExtractSplit, dlProgress, exProgress, combinedProgress }; + }, [items, pkg.status]); + const { done, failed, cancelled, extracted, extracting, total, useExtractSplit, dlProgress, exProgress, combinedProgress } = stats; + + const onKeyDown = (e: ReactKeyboardEvent<HTMLInputElement>): void => { + if (e.key === "Enter") { onFinishEdit(pkg.id, pkg.name, editingName); } + if (e.key === "Escape") { onFinishEdit(pkg.id, pkg.name, pkg.name); } + }; + + return ( + <article + className={`package-card queue-package-card pkg-stripe-${stripeVariant}${pkg.enabled ? "" : " disabled-pkg"}${selectedIds.has(pkg.id) ? " pkg-selected" : ""}`} + draggable + onContextMenu={(e) => { e.preventDefault(); e.stopPropagation(); onContextMenu(pkg.id, undefined, e.clientX, e.clientY); }} + onClick={(e) => { if (e.ctrlKey || e.shiftKey) onSelect(pkg.id, e.ctrlKey, e.shiftKey); }} + onMouseDown={(e) => onSelectMouseDown(pkg.id, e)} + onMouseEnter={() => onSelectMouseEnter(pkg.id)} + onDragStart={(event) => { event.stopPropagation(); onDragStart(pkg.id); }} + onDragOver={(event) => { event.preventDefault(); event.stopPropagation(); }} + onDrop={(event) => { event.preventDefault(); event.stopPropagation(); onDrop(pkg.id); }} + onDragEnd={(event) => { event.stopPropagation(); onDragEnd(); }} + > + <header onClick={(e) => { + if (e.ctrlKey) return; + const tag = (e.target as HTMLElement).tagName; + if (tag === "BUTTON" || tag === "INPUT" || tag === "SELECT") return; + onToggleCollapse(pkg.id); + }} style={{ cursor: "pointer" }}> + <div className="pkg-columns" style={{ gridTemplateColumns: gridTemplate }}> + {columnOrder.map((col) => { + switch (col) { + case "name": return ( + <div key={col} className="pkg-col pkg-col-name"> + <button className="pkg-toggle" onClick={() => onToggleCollapse(pkg.id)} title={collapsed ? "Ausklappen" : "Einklappen"}>{collapsed ? "+" : "\u2212"}</button> + <input type="checkbox" checked={pkg.enabled} onChange={() => onToggle(pkg.id)} title={pkg.enabled ? "Paket aktiv" : "Paket deaktiviert"} /> + {isEditing ? ( + <input className="rename-input" value={editingName} onChange={(e) => onEditChange(e.target.value)} onBlur={() => onFinishEdit(pkg.id, pkg.name, editingName)} onKeyDown={onKeyDown} autoFocus /> + ) : ( + <h4 onClick={(e) => { e.stopPropagation(); onStartEdit(pkg.id, pkg.name); }} title="Klicken zum Umbenennen">{pkg.name}</h4> + )} + </div> + ); + case "size": return ( + <span key={col} className="pkg-col pkg-col-size">{(() => { + const totalBytes = items.reduce((sum, item) => sum + (item.totalBytes || item.downloadedBytes || 0), 0); + const dlBytes = items.reduce((sum, item) => sum + (item.downloadedBytes || 0), 0); + const pct = totalBytes > 0 ? Math.min(100, Math.round((dlBytes / totalBytes) * 100)) : 0; + const label = `${humanSize(dlBytes)} / ${humanSize(totalBytes)}`; + return totalBytes > 0 ? ( + <span className="progress-size"> + <span className="progress-size-bar" style={{ width: `${pct}%` }} /> + <span className="progress-size-text">{label}</span> + <span className="progress-size-text-filled" style={{ clipPath: `inset(0 ${100 - pct}% 0 0)` }}>{label}</span> + </span> + ) : ""; + })()}</span> + ); + case "progress": return ( + <span key={col} className="pkg-col pkg-col-progress"> + <span className="progress-inline"> + <span className="progress-inline-bar" style={{ width: `${combinedProgress}%` }} /> + <span className="progress-inline-text">{combinedProgress}%</span> + <span className="progress-inline-text-filled" style={{ clipPath: `inset(0 ${100 - combinedProgress}% 0 0)` }}>{combinedProgress}%</span> + </span> + </span> + ); + case "hoster": { + const hosterText = [...new Set(items.map((item) => extractHoster(item.url)).filter(Boolean))].join(", "); + return <span key={col} className="pkg-col pkg-col-hoster" title={hosterText}>{hosterText}</span>; + } + case "account": { + const accountText = compactProviderLabels(items.map((item) => item.providerLabel || (item.provider ? providerLabels[item.provider] : "")).filter(Boolean)); + return <span key={col} className="pkg-col pkg-col-account" title={accountText}>{accountText}</span>; + } + case "prio": return ( + <span key={col} className={`pkg-col pkg-col-prio${pkg.priority === "high" ? " prio-high" : pkg.priority === "low" ? " prio-low" : ""}`}>{pkg.priority === "high" ? "Hoch" : pkg.priority === "low" ? "Niedrig" : ""}</span> + ); + case "status": { + const audioStrip = pkg.audioStripSummary ? formatAudioStripSummary(pkg.audioStripSummary) : null; + return ( + <span key={col} className="pkg-col pkg-col-status">[{done}/{total}{done === total && total > 0 ? " - Done" : ""}{failed > 0 ? ` | ${failed} Fehler` : ""}{cancelled > 0 ? ` | ${cancelled} abgebr.` : ""}]{pkg.postProcessLabel ? ` - ${pkg.postProcessLabel}` : ""}{audioStrip ? <span className={`pkg-audio-strip${audioStrip.attention ? " pkg-audio-strip-warn" : ""}`} title={audioStrip.tooltip}>{` · ${audioStrip.text}`}</span> : null}</span> + ); + } + case "speed": return ( + <span key={col} className="pkg-col pkg-col-speed">{packageSpeed > 0 ? formatSpeedMbps(packageSpeed) : ""}</span> + ); + case "added": return ( + <span key={col} className="pkg-col pkg-col-added">{formatDateTime(pkg.createdAt)}</span> + ); + default: return null; + } + })} + </div> + </header> + <div className="progress"> + <div className="progress-dl" style={{ width: `${dlProgress}%` }} /> + {useExtractSplit && <div className="progress-ex" style={{ width: `${exProgress}%` }} />} + </div> + {!collapsed && items.filter((item) => !hideExtractedItems || !item.fullStatus?.startsWith("Entpackt")).map((item) => ( + <ItemRow + key={item.id} + item={item} + packageId={pkg.id} + isSelected={selectedIds.has(item.id)} + sessionRunning={sessionRunning} + columnOrder={columnOrder} + gridTemplate={gridTemplate} + onSelect={onSelect} + onSelectMouseDown={onSelectMouseDown} + onSelectMouseEnter={onSelectMouseEnter} + onContextMenu={onContextMenu} + /> + ))} + </article> + ); +}, (prev, next) => { + if (prev.pkg.id !== next.pkg.id) { + return false; + } + if (prev.pkg.updatedAt !== next.pkg.updatedAt + || prev.pkg.status !== next.pkg.status + || prev.pkg.enabled !== next.pkg.enabled + || prev.pkg.name !== next.pkg.name + || prev.pkg.priority !== next.pkg.priority + || prev.pkg.createdAt !== next.pkg.createdAt) { + return false; + } + if (prev.packageSpeed !== next.packageSpeed + || prev.isFirst !== next.isFirst + || prev.isLast !== next.isLast + || prev.isEditing !== next.isEditing + || prev.collapsed !== next.collapsed + || prev.hideExtractedItems !== next.hideExtractedItems + || prev.columnOrder !== next.columnOrder + || prev.gridTemplate !== next.gridTemplate) { + return false; + } + if (prev.selectedIds !== next.selectedIds) { + for (const itemId of next.pkg.itemIds) { + if (prev.selectedIds.has(itemId) !== next.selectedIds.has(itemId)) { + return false; + } + } + } + if ((prev.isEditing || next.isEditing) && prev.editingName !== next.editingName) { + return false; + } + if (prev.pkg.itemIds.length !== next.pkg.itemIds.length) { + return false; + } + for (let index = 0; index < prev.pkg.itemIds.length; index += 1) { + if (prev.pkg.itemIds[index] !== next.pkg.itemIds[index]) { + return false; + } + } + if (prev.items.length !== next.items.length) { + return false; + } + for (let index = 0; index < prev.items.length; index += 1) { + const a = prev.items[index]; + const b = next.items[index]; + if (!a || !b) { + return false; + } + if (a.id !== b.id + || a.updatedAt !== b.updatedAt + || a.url !== b.url + || a.status !== b.status + || a.fileName !== b.fileName + || a.progressPercent !== b.progressPercent + || a.speedBps !== b.speedBps + || a.retries !== b.retries + || a.provider !== b.provider + || a.fullStatus !== b.fullStatus + || a.onlineStatus !== b.onlineStatus + || a.downloadedBytes !== b.downloadedBytes + || a.totalBytes !== b.totalBytes) { + return false; + } + } + return true; +}); diff --git a/src/renderer/error-boundary.tsx b/src/renderer/error-boundary.tsx new file mode 100644 index 0000000..386972b --- /dev/null +++ b/src/renderer/error-boundary.tsx @@ -0,0 +1,94 @@ +import React from "react"; + +interface ErrorBoundaryProps { + children: React.ReactNode; +} + +interface ErrorBoundaryState { + hasError: boolean; + message: string; +} + +// Catches render-time errors in the component tree so a crash shows a minimal +// recovery surface instead of a silent white screen, and forwards the error to +// the main process log. Kept deliberately dead-simple and state-independent: an +// error inside the error path is how you get a second white screen or a loop. +export class ErrorBoundary extends React.Component<ErrorBoundaryProps, ErrorBoundaryState> { + constructor(props: ErrorBoundaryProps) { + super(props); + this.state = { hasError: false, message: "" }; + } + + static getDerivedStateFromError(error: unknown): ErrorBoundaryState { + return { hasError: true, message: error instanceof Error ? error.message : String(error) }; + } + + componentDidCatch(error: unknown, info: React.ErrorInfo): void { + try { + window.rd?.reportRendererError({ + kind: "react", + message: error instanceof Error ? error.message : String(error), + stack: error instanceof Error ? error.stack : undefined, + componentStack: info?.componentStack || undefined + }); + } catch { + } + } + + private handleReload = (): void => { + window.location.reload(); + }; + + render(): React.ReactNode { + if (!this.state.hasError) { + return this.props.children; + } + const overlay: React.CSSProperties = { + position: "fixed", + inset: 0, + display: "flex", + flexDirection: "column", + alignItems: "center", + justifyContent: "center", + gap: 16, + padding: 32, + background: "#070b14", + color: "#e6edf6", + fontFamily: "Segoe UI, system-ui, sans-serif", + textAlign: "center" + }; + const pre: React.CSSProperties = { + maxWidth: 640, + maxHeight: 200, + overflow: "auto", + padding: 12, + background: "#0d1422", + border: "1px solid #243049", + borderRadius: 6, + color: "#ff9a8c", + fontSize: 12, + whiteSpace: "pre-wrap", + textAlign: "left" + }; + const button: React.CSSProperties = { + padding: "8px 20px", + background: "#2d5cff", + color: "#fff", + border: "none", + borderRadius: 6, + cursor: "pointer", + fontSize: 14 + }; + return ( + <div style={overlay}> + <h1 style={{ margin: 0, fontSize: 20 }}>Die Oberfläche hat einen Fehler ausgelöst</h1> + <p style={{ margin: 0, maxWidth: 560, color: "#9aa7bd" }}> + Die Anzeige wurde gestoppt, um Datenverlust zu vermeiden. Die laufenden Downloads im + Hintergrund sind nicht betroffen. Der Fehler wurde ins Log geschrieben. + </p> + <pre style={pre}>{this.state.message}</pre> + <button type="button" style={button} onClick={this.handleReload}>Oberfläche neu laden</button> + </div> + ); + } +} diff --git a/src/renderer/index.html b/src/renderer/index.html new file mode 100644 index 0000000..5a2b1fd --- /dev/null +++ b/src/renderer/index.html @@ -0,0 +1,12 @@ +<!doctype html> +<html lang="de"> + <head> + <meta charset="UTF-8" /> + <meta name="viewport" content="width=device-width, initial-scale=1.0" /> + <title>Multi Debrid Downloader + + +
+ + + diff --git a/src/renderer/main.tsx b/src/renderer/main.tsx new file mode 100644 index 0000000..a4d0b12 --- /dev/null +++ b/src/renderer/main.tsx @@ -0,0 +1,48 @@ +import React from "react"; +import { createRoot } from "react-dom/client"; +import { App } from "./App"; +import { ErrorBoundary } from "./error-boundary"; +import "./styles.css"; + +// Forward otherwise-silent renderer failures (uncaught errors, unhandled promise +// rejections) to the main process log. Without this, a renderer crash leaves no +// trace anywhere on an unattended server. +function reportRendererError(report: Parameters[0]): void { + try { + window.rd?.reportRendererError(report); + } catch { + } +} + +window.addEventListener("error", (event) => { + reportRendererError({ + kind: "error", + message: event.message || String(event.error || "Unbekannter Fehler"), + stack: event.error instanceof Error ? event.error.stack : undefined, + source: event.filename || undefined, + line: typeof event.lineno === "number" ? event.lineno : undefined, + column: typeof event.colno === "number" ? event.colno : undefined + }); +}); + +window.addEventListener("unhandledrejection", (event) => { + const reason = event.reason; + reportRendererError({ + kind: "unhandledrejection", + message: reason instanceof Error ? reason.message : String(reason), + stack: reason instanceof Error ? reason.stack : undefined + }); +}); + +const rootElement = document.getElementById("root"); +if (!rootElement) { + throw new Error("Root element fehlt"); +} + +createRoot(rootElement).render( + + + + + +); diff --git a/src/renderer/package-order.ts b/src/renderer/package-order.ts new file mode 100644 index 0000000..a38bb52 --- /dev/null +++ b/src/renderer/package-order.ts @@ -0,0 +1,61 @@ +import type { DownloadItem, DownloadStatus, PackageEntry } from "../shared/types"; + +const ACTIVE_PACKAGE_STATUSES = new Set(["downloading", "validating", "integrity_check", "extracting"]); + +export function reorderPackageOrderByDrop(order: string[], draggedPackageId: string, targetPackageId: string): string[] { + const fromIndex = order.indexOf(draggedPackageId); + const toIndex = order.indexOf(targetPackageId); + if (fromIndex < 0 || toIndex < 0 || fromIndex === toIndex) { + return order; + } + const next = [...order]; + const [dragged] = next.splice(fromIndex, 1); + const insertIndex = Math.max(0, Math.min(next.length, toIndex)); + next.splice(insertIndex, 0, dragged); + return next; +} + +export function sortPackageOrderByName(order: string[], packages: Record, descending: boolean): string[] { + const sorted = [...order]; + sorted.sort((a, b) => { + const nameA = (packages[a]?.name ?? "").toLowerCase(); + const nameB = (packages[b]?.name ?? "").toLowerCase(); + const cmp = nameA.localeCompare(nameB, undefined, { numeric: true, sensitivity: "base" }); + return descending ? -cmp : cmp; + }); + return sorted; +} + +export function sortPackagesForDisplay( + packages: PackageEntry[], + itemsById: Record, + running: boolean, + autoSortPackagesByProgress: boolean +): PackageEntry[] { + if (!running || !autoSortPackagesByProgress || packages.length <= 1) { + return packages; + } + + const active: PackageEntry[] = []; + const rest: PackageEntry[] = []; + + // Float packages that have an active item to the top, but keep BOTH groups in + // their original (queue) order. Earlier this sorted the active group by live + // completedRatio/downloadedBytes — which change on every progress tick (every + // 150-700ms), so active packages visibly reshuffled the whole time. A package + // entering/leaving the active bucket is a real, discrete event (start/finish); + // ranking *within* the bucket by live bytes was pure jitter nobody needs. + for (const pkg of packages) { + const hasActive = pkg.itemIds.some((id) => { + const item = itemsById[id]; + return item != null && ACTIVE_PACKAGE_STATUSES.has(item.status); + }); + (hasActive ? active : rest).push(pkg); + } + + if (active.length === 0 || active.length === packages.length) { + return packages; + } + + return [...active, ...rest]; +} diff --git a/src/renderer/selection.ts b/src/renderer/selection.ts new file mode 100644 index 0000000..ce888bc --- /dev/null +++ b/src/renderer/selection.ts @@ -0,0 +1,27 @@ +import type { SessionState } from "../shared/types"; + +/** + * Drop selected ids whose package OR item no longer exists in the session. + * The selection set mixes package and item ids; when entries vanish (delta + * removal, backup-driven session swap, completed-cleanup) a stale id would + * otherwise inflate the selection count and the "(N)" action labels and keep + * "multi" styling alive for ghosts. + * + * Returns the SAME set instance when nothing changed, so callers can use it + * directly as a React state updater without forcing a re-render. + */ +export function pruneSelection( + selected: ReadonlySet, + session: Pick +): Set { + if (selected.size === 0) { + return selected as Set; + } + const next = new Set(); + for (const id of selected) { + if (session.packages[id] || session.items[id]) { + next.add(id); + } + } + return next.size === selected.size ? (selected as Set) : next; +} diff --git a/src/renderer/styles.css b/src/renderer/styles.css new file mode 100644 index 0000000..3dc4599 --- /dev/null +++ b/src/renderer/styles.css @@ -0,0 +1,3469 @@ +:root { + font-family: "Manrope", "Segoe UI Variable", "Segoe UI", sans-serif; + --bg: #050810; + --bg-glow: #1e1730; + --surface: #0c1322; + --card: #12192c; + --field: #090f1c; + --border: #283447; + --text: #ece6dd; + --muted: #a59c8e; + --accent: #f2942d; + --accent-2: #ff7a5c; + --danger: #f4564b; + --button-bg: #0d1a2c; + --button-bg-hover: #12243d; + --tab-bg: #0b1321; + --tab-active: #14253e; + --toast-bg: #0f1f33; + --toast-border: #2a4f78; + --progress-track: #0b1628; + --overlay: rgba(8, 13, 23, 0.85); +} + +:root[data-theme="dark"] { + color-scheme: dark; +} + +:root[data-theme="light"] { + color-scheme: light; + --bg: #eef3fb; + --bg-glow: #d7e5ff; + --surface: #f7faff; + --card: #ffffff; + --field: #ffffff; + --border: #c7d5ea; + --text: #0f223d; + --muted: #6b6052; + --accent: #c2701a; + --accent-2: #d9542b; + --danger: #c0392b; + --button-bg: #f3f7ff; + --button-bg-hover: #e6efff; + --tab-bg: #edf3ff; + --tab-active: #dce8ff; + --toast-bg: #ffffff; + --toast-border: #a9c1e8; + --progress-track: #dfe8f8; + --overlay: rgba(229, 238, 252, 0.88); +} + +* { + box-sizing: border-box; +} + +html, +body, +#root { + margin: 0; + width: 100%; + height: 100%; + background: radial-gradient(circle at 15% 10%, var(--bg-glow) 0, var(--surface) 45%, var(--bg) 100%); + color: var(--text); +} + +.app-shell { + display: grid; + grid-template-rows: auto auto auto 1fr auto; + height: 100%; + padding: 12px 14px 10px; + gap: 8px; +} + +.menu-bar { + display: flex; + align-items: center; + gap: 0; + user-select: none; + font-size: 13px; + font-weight: 600; + margin: -8px -6px 0; +} + +.menu-bar-item { + position: relative; +} + +.menu-bar-trigger { + background: none; + border: none; + color: var(--text); + padding: 5px 12px; + cursor: pointer; + font: inherit; + font-weight: 600; + border-radius: 6px; + transition: background 0.1s; +} + +.menu-bar-trigger:hover, +.menu-bar-trigger.open { + background: var(--button-bg-hover); +} + +.menu-dropdown { + position: absolute; + top: 100%; + left: 0; + z-index: 1000; + min-width: max-content; + background: var(--card); + border: 1px solid var(--border); + border-radius: 8px; + padding: 4px 0; + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4); + white-space: nowrap; +} + +.menu-dropdown-item { + display: flex; + align-items: center; + justify-content: space-between; + width: 100%; + background: none; + border: none; + color: var(--text); + padding: 7px 16px; + cursor: pointer; + font: inherit; + font-size: 13px; + font-weight: 500; + text-align: left; + transition: background 0.1s; +} + +.menu-dropdown-item:hover { + background: var(--button-bg-hover); +} + +.menu-dropdown-item .shortcut { + color: var(--muted); + font-size: 12px; + margin-left: 24px; + white-space: nowrap; +} + +.menu-separator { + height: 1px; + background: var(--border); + margin: 4px 8px; +} + +.menu-submenu { + position: relative; +} + +.menu-submenu-trigger { + display: flex; + align-items: center; + justify-content: space-between; + width: 100%; + background: none; + border: none; + color: var(--text); + padding: 7px 16px; + cursor: pointer; + font: inherit; + font-size: 13px; + font-weight: 500; + text-align: left; + transition: background 0.1s; +} + +.menu-submenu-trigger:hover { + background: var(--button-bg-hover); +} + +.menu-submenu-trigger::after { + content: "\25B6"; + font-size: 8px; + color: var(--muted); + margin-left: 12px; +} + +.menu-submenu-dropdown { + position: absolute; + top: -4px; + left: 100%; + z-index: 1001; + min-width: max-content; + background: var(--card); + border: 1px solid var(--border); + border-radius: 8px; + padding: 4px 0; + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4); +} + +.menu-settings-grid { + display: grid; + grid-template-columns: 1fr auto auto auto; + align-items: center; + gap: 6px 8px; + padding: 6px 16px; + font-size: 13px; + font-weight: 500; + color: var(--text); +} + +.menu-spinner { + display: flex; + align-items: stretch; + border: 1px solid var(--border); + border-radius: 6px; + overflow: hidden; + height: 28px; +} + +.menu-spinner.disabled { + opacity: 0.45; + pointer-events: none; +} + +.menu-spinner input[type="text"] { + width: 38px; + background: var(--field); + color: var(--text); + border: none; + padding: 0 6px; + font: inherit; + font-size: 13px; + text-align: center; + outline: none; +} + +.menu-spinner-arrows { + display: flex; + flex-direction: column; + border-left: 1px solid var(--border); +} + +.menu-spinner-arrows button { + display: flex; + align-items: center; + justify-content: center; + background: var(--button-bg); + border: none; + color: var(--muted); + cursor: pointer; + padding: 0; + width: 18px; + flex: 1; + font-size: 8px; + line-height: 1; + transition: background 0.1s; +} + +.menu-spinner-arrows button:hover { + background: var(--button-bg-hover); + color: var(--text); +} + +.menu-spinner-arrows button:first-child { + border-bottom: 1px solid var(--border); +} + +.menu-settings-grid input[type="checkbox"] { + width: 15px; + height: 15px; + accent-color: var(--accent); + cursor: pointer; + flex-shrink: 0; +} + +.menu-speed-unit { + color: var(--muted); + font-size: 12px; + white-space: nowrap; +} + +.control-strip { + display: flex; + justify-content: space-between; + gap: 12px; + background: linear-gradient(180deg, color-mix(in srgb, var(--surface) 92%, transparent), color-mix(in srgb, var(--bg) 92%, transparent)); + border: 1px solid var(--border); + border-radius: 12px; + padding: 10px 12px; +} + +.ctrl-icon-btn { + display: inline-flex; + align-items: center; + justify-content: center; + width: 34px; + height: 34px; + border-radius: 8px; + border: 1px solid var(--border); + background: var(--button-bg); + color: var(--muted); + cursor: pointer; + transition: background 0.12s ease, border-color 0.12s ease, color 0.12s ease, transform 0.12s ease; +} + +.ctrl-icon-btn:hover:not(:disabled) { + transform: translateY(-1px); + border-color: var(--accent); + background: var(--button-bg-hover); + color: var(--text); +} + +.ctrl-icon-btn:disabled { + opacity: 0.35; + cursor: not-allowed; +} + +.ctrl-icon-btn.ctrl-play:not(:disabled) { + color: #4ade80; +} + +.ctrl-icon-btn.ctrl-play:hover:not(:disabled) { + border-color: #4ade80; + background: rgba(74, 222, 128, 0.1); +} + +.ctrl-icon-btn.ctrl-pause:not(:disabled):hover { + border-color: #f59e0b; + color: #f59e0b; + background: rgba(245, 158, 11, 0.1); +} + +.ctrl-icon-btn.ctrl-pause.paused { + border-color: #f59e0b; + color: #f59e0b; + background: rgba(245, 158, 11, 0.15); +} + +.ctrl-icon-btn.ctrl-stop:not(:disabled) { + color: var(--danger); +} + +.ctrl-icon-btn.ctrl-stop:hover:not(:disabled) { + border-color: var(--danger); + background: rgba(244, 63, 94, 0.1); +} + +.ctrl-icon-btn.ctrl-move:not(:disabled) { + color: var(--accent); +} + +.ctrl-icon-btn.ctrl-move:hover:not(:disabled) { + border-color: var(--accent); + background: color-mix(in srgb, var(--accent) 10%, transparent); +} + +.ctrl-icon-btn.ctrl-speed.active { + color: #f59e0b; + border-color: rgba(245, 158, 11, 0.5); + background: rgba(245, 158, 11, 0.1); +} + +.ctrl-icon-btn.ctrl-speed:hover:not(:disabled) { + border-color: #f59e0b; + color: #f59e0b; + background: rgba(245, 158, 11, 0.1); +} + +.schedule-ctrl { + position: relative; + display: flex; + align-items: center; +} +.schedule-btn.active { + color: var(--accent); +} +.schedule-active { + display: flex; + align-items: center; + gap: 4px; +} +.schedule-badge { + font-size: 11px; + color: var(--accent); + white-space: nowrap; + padding: 2px 6px; + background: rgba(99, 179, 237, 0.12); + border-radius: 6px; + cursor: default; +} +.schedule-cancel { + background: none; + border: none; + cursor: pointer; + color: var(--muted); + font-size: 11px; + padding: 2px 4px; + border-radius: 4px; + line-height: 1; +} +.schedule-cancel:hover { color: var(--danger, #e05555); } +.schedule-picker { + position: absolute; + top: calc(100% + 6px); + left: 0; + z-index: 200; + display: flex; + align-items: center; + gap: 6px; + background: var(--panel); + border: 1px solid var(--border); + border-radius: 8px; + padding: 8px 10px; + white-space: nowrap; + box-shadow: 0 4px 16px rgba(0,0,0,0.3); +} +.schedule-picker-label { + font-size: 12px; + color: var(--muted); +} +.schedule-time-input { + background: var(--field); + border: 1px solid var(--border); + border-radius: 6px; + color: var(--text); + padding: 3px 6px; + font-size: 13px; + width: 90px; +} +.ctrl-separator { + width: 1px; + height: 24px; + background: var(--border); + margin: 0 2px; +} + +.buttons, +.speed-config, +.link-actions, +.input-row, +.settings-actions { + display: flex; + align-items: center; + gap: 8px; +} + +.buttons-right { + margin-left: auto; + justify-content: flex-end; +} + +.btn { + background: var(--button-bg); + color: var(--text); + border: 1px solid var(--border); + border-radius: 9px; + padding: 6px 11px; + cursor: pointer; + font-weight: 600; + font-size: 13px; + transition: transform 0.12s ease, border-color 0.12s ease, background 0.12s ease; +} + +.btn:disabled { + opacity: 0.55; + cursor: not-allowed; +} + +.btn:hover:not(:disabled) { + transform: translateY(-1px); + border-color: var(--accent); + background: var(--button-bg-hover); +} + +.btn.accent { + background: linear-gradient(180deg, #f9a948, #e9881f); + color: #1c1206; + border-color: #f0982f; +} + +.btn.danger { + border-color: rgba(244, 63, 94, 0.7); + color: #fda4af; +} + +.btn.success { + border-color: rgba(74, 222, 128, 0.7); + color: #86efac; +} + +:root[data-theme="light"] .btn.danger { + border-color: color-mix(in srgb, var(--danger) 60%, transparent); + color: var(--danger); +} + +:root[data-theme="light"] .btn.success { + border-color: color-mix(in srgb, #16a34a 60%, transparent); + color: #15803d; +} + +.btn.btn-active { + border-color: var(--accent); + box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--accent) 45%, transparent); +} + +.tabs { + display: flex; + align-items: center; + gap: 8px; +} + +.tab-actions { + display: flex; + align-items: center; + gap: 8px; + margin-left: auto; +} + +.tab-action-btn { + font-size: 12px; + padding: 5px 10px; +} + +.tab-action-btn:hover:not(:disabled) { + transform: none; +} + +.downloads-action-bar { + display: flex; + align-items: center; + gap: 8px; + justify-content: flex-end; +} + +.search-input.tab-search { + width: 330px; + min-width: 330px; + position: relative; + z-index: 1; +} + +.speed-sparkline { + display: flex; + align-items: center; + gap: 8px; + height: 30px; + padding: 0 10px; + background: var(--field); + border: 1px solid var(--border); + border-radius: 8px; +} + +.speed-sparkline-canvas { + width: 116px; + height: 22px; + display: block; +} + +.speed-sparkline-value { + font-size: 12px; + font-weight: 600; + font-variant-numeric: tabular-nums; + color: var(--text); + white-space: nowrap; + min-width: 66px; + text-align: right; +} + +.tab { + background: var(--tab-bg); + border: 1px solid var(--border); + color: var(--muted); + border-radius: 9px; + padding: 7px 12px; + cursor: pointer; + font-weight: 600; + font-size: 13px; +} + +.tab.active { + color: var(--text); + background: var(--tab-active); + border-color: color-mix(in srgb, var(--accent) 45%, var(--border)); +} + +.tab-content { + min-height: 0; +} + +.grid-two { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 10px; + height: 100%; +} + +.card { + border: 1px solid var(--border); + border-radius: 12px; + background: linear-gradient(180deg, color-mix(in srgb, var(--card) 96%, transparent), color-mix(in srgb, var(--surface) 96%, transparent)); + padding: 10px; + display: flex; + flex-direction: column; + gap: 7px; +} + +.card.wide { + grid-column: span 2; + min-height: 280px; +} + +.collector-view { + grid-template-rows: 1fr; +} + +.collector-view .card.wide { + min-height: 0; +} + +.collector-view .card textarea { + flex: 1; + min-height: 120px; + resize: none; +} + +.card h3 { + margin: 0; + font-size: 15px; + letter-spacing: 0.1px; +} + +.card label, +.hint { + color: var(--muted); + font-size: 13px; +} + +.card input, +.card select, +.speed-config input, +.speed-config select, +.speed-config button, +.card textarea { + width: 100%; + background: var(--field); + color: var(--text); + border: 1px solid var(--border); + border-radius: 9px; + padding: 7px 9px; +} + +.card textarea { + min-height: 220px; + resize: vertical; +} + +.password-list { + min-height: 120px; + resize: vertical; +} + +.input-row input { + flex: 1; +} + +.speed-config input { + width: 96px; +} + +.speed-config select { + width: 150px; +} + +.downloads-view { + height: 100%; + overflow: auto; + display: flex; + flex-direction: column; + gap: 0; +} + +.downloads-toolbar { + display: flex; + align-items: center; + gap: 10px; + flex-wrap: wrap; + margin-bottom: 8px; +} + +.downloads-toolbar-actions { + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; +} + +.downloads-toolbar .search-input { + margin-left: auto; +} + +.pkg-column-header { + display: grid; + + gap: 8px; + padding: 4px 10px; + background: color-mix(in srgb, var(--card) 58%, transparent); + border: 0; + border-bottom: 1px solid color-mix(in srgb, var(--border) 62%, transparent); + border-radius: 0; + font-size: 12px; + font-weight: 700; + color: var(--muted); + user-select: none; + margin-bottom: 1px; +} + +.pkg-column-header .pkg-col-progress, +.pkg-column-header .pkg-col-size, +.pkg-column-header .pkg-col-hoster, +.pkg-column-header .pkg-col-account, +.pkg-column-header .pkg-col-prio, +.pkg-column-header .pkg-col-status, +.pkg-column-header .pkg-col-speed, +.pkg-column-header .pkg-col-added { + text-align: center; +} + +.pkg-column-header .sortable { + cursor: pointer; +} + +.pkg-column-header .sortable:hover { + color: var(--text); +} + +.pkg-column-header .sort-active { + color: var(--accent); +} + +.pkg-columns { + display: grid; + + gap: 8px; + align-items: center; + min-width: 0; + flex: 1; +} + +.pkg-columns .pkg-col-name { + display: flex; + align-items: center; + gap: 6px; + min-width: 0; +} + +.pkg-columns .pkg-col-name h4 { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.pkg-columns .pkg-col-progress, +.pkg-columns .pkg-col-size, +.pkg-columns .pkg-col-hoster, +.pkg-columns .pkg-col-account, +.pkg-columns .pkg-col-prio, +.pkg-columns .pkg-col-status, +.pkg-columns .pkg-col-speed, +.pkg-columns .pkg-col-added { + font-size: 13px; + color: var(--muted); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + text-align: center; +} + +.pkg-col-progress { + font-variant-numeric: tabular-nums; + padding-right: 12px; +} + +.pkg-audio-strip { + color: var(--muted); +} + +.pkg-audio-strip-warn { + color: var(--danger); +} + +.progress-size { + display: block; + position: relative; + width: 100%; + height: 18px; + background: var(--progress-track); + border-radius: 4px; + overflow: hidden; +} + +.progress-size-bar { + position: absolute; + top: 0; + left: 0; + height: 100%; + background: linear-gradient(90deg, #f2942d, #ff7a5c); + border-radius: 4px; + transition: width 0.15s ease; +} + +.progress-size-text { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + display: flex; + align-items: center; + justify-content: center; + font-size: 10px; + font-weight: 700; + font-variant-numeric: tabular-nums; + color: var(--muted); + white-space: nowrap; + z-index: 1; +} + +.progress-size-text-filled { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + display: flex; + align-items: center; + justify-content: center; + font-size: 10px; + font-weight: 700; + font-variant-numeric: tabular-nums; + color: #0a0f1a; + white-space: nowrap; + z-index: 2; +} + +.progress-size-small { + height: 14px; +} + +.progress-size-small .progress-size-text, +.progress-size-small .progress-size-text-filled { + font-size: 9px; +} + +.progress-inline { + display: block; + position: relative; + width: 100%; + height: 18px; + background: var(--progress-track); + border-radius: 4px; + overflow: hidden; +} + +.progress-inline-bar { + position: absolute; + top: 0; + left: 0; + height: 100%; + background: linear-gradient(90deg, #22c55e, #4ade80); + border-radius: 4px; + transition: width 0.15s ease; +} + +.progress-inline-text { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + display: flex; + align-items: center; + justify-content: center; + font-size: 11px; + font-weight: 700; + color: var(--muted); + z-index: 1; +} + +.progress-inline-text-filled { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + display: flex; + align-items: center; + justify-content: center; + font-size: 11px; + font-weight: 700; + color: #0a0f1a; + white-space: nowrap; + z-index: 2; +} + +.progress-inline-small { + height: 14px; +} + +.progress-inline-small .progress-inline-text, +.progress-inline-small .progress-inline-text-filled { + font-size: 10px; +} + +.search-input { + width: min(360px, 100%); + background: var(--field); + color: var(--text); + border: 1px solid var(--border); + border-radius: 9px; + padding: 7px 9px; +} + +.collector-header { + display: flex; + justify-content: space-between; + align-items: center; + gap: 10px; + flex-wrap: wrap; +} + +.collector-metrics { + color: var(--muted); + font-size: 13px; + font-variant-numeric: tabular-nums; +} + +.collector-tabs { + display: flex; + align-items: center; + gap: 6px; + flex-wrap: wrap; +} + +.collector-tab { + display: flex; + align-items: center; + border: 1px solid var(--border); + border-radius: 9px; + overflow: hidden; + background: var(--tab-bg); +} + +.collector-tab > button { + border: 0; + background: transparent; + color: var(--muted); + padding: 6px 10px; + cursor: pointer; +} + +.collector-tab.active > button { + color: var(--text); +} + +.collector-tab .close-tab { + border: 0; + border-left: 1px solid var(--border); + background: transparent; + color: var(--muted); + width: 26px; + cursor: pointer; +} + +.collector-tab .close-tab:hover { + color: var(--danger); +} + +.add-tab { + min-width: 32px; + padding-inline: 0; +} + +.reconnect-badge { + color: color-mix(in srgb, var(--danger) 70%, var(--text)); + font-weight: 600; +} + +.reconnect-banner { + border: 1px solid color-mix(in srgb, var(--danger) 45%, var(--border)); + background: color-mix(in srgb, var(--danger) 12%, var(--surface)); + color: var(--text); + border-radius: 10px; + padding: 8px 10px; + font-size: 13px; +} + +.status-bar { + display: flex; + flex-wrap: wrap; + gap: 8px 16px; + align-items: center; + color: var(--muted); + font-size: 12px; + font-variant-numeric: tabular-nums; + background: var(--surface); + border-top: 1px solid var(--border); + padding: 5px 14px; + margin: 0 -14px -10px; +} + +.footer-spacer { + flex: 1; +} + +.footer-btn { + font-size: 11px; + padding: 2px 8px; + min-height: 0; +} + +.settings-section-intro { + color: var(--muted); + font-size: 12.5px; + line-height: 1.4; + margin: -2px 0 2px; +} + +.settings-subhead { + margin: 16px 0 4px; + padding-top: 12px; + border-top: 1px solid color-mix(in srgb, var(--border) 60%, transparent); + font-size: 11px; + font-weight: 700; + letter-spacing: 0.09em; + text-transform: uppercase; + color: var(--muted); +} + +.settings-subhead.first { + margin-top: 6px; + padding-top: 0; + border-top: 0; +} + +.setting-hint { + color: var(--muted); + font-size: 12px; + line-height: 1.35; + margin: -1px 0 7px; +} + +.toggle-line + .setting-hint { + margin-top: -4px; + margin-left: 26px; +} + +.settings-shell { + display: grid; + grid-template-rows: auto 1fr; + gap: 8px; + height: 100%; + min-height: 0; +} + +.settings-toolbar { + display: flex; + flex-direction: row; + align-items: center; + justify-content: space-between; + gap: 10px; +} + +.settings-toolbar-copy { + display: flex; + flex-direction: column; + gap: 2px; +} + +.settings-toolbar-copy span { + color: var(--muted); + font-size: 12px; +} + +.settings-toolbar-actions { + display: flex; + gap: 8px; + align-items: center; +} + +.settings-toolbar-actions-wrap { + display: flex; + flex-direction: column; + align-items: flex-end; + gap: 6px; +} + +.update-popup { + position: fixed; + right: 20px; + bottom: 56px; + background: var(--toast-bg); + border: 1px solid var(--toast-border); + border-radius: 12px; + padding: 10px 14px; + min-width: 260px; + max-width: 340px; + box-shadow: 0 16px 30px rgba(0, 0, 0, 0.35); + z-index: 30; + font-variant-numeric: tabular-nums; +} + +.update-popup-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 4px; +} + +.update-popup-title { + font-weight: 700; + font-size: 13px; + color: var(--text); +} + +.update-popup-close { + background: none; + border: none; + color: var(--muted); + font-size: 18px; + cursor: pointer; + padding: 0 2px; + line-height: 1; +} + +.update-popup-close:hover { + color: var(--text); +} + +.update-popup-message { + font-size: 12px; + color: var(--muted); +} + +.update-popup-downloading .update-popup-message, +.update-popup-verifying .update-popup-message, +.update-popup-launching .update-popup-message, +.update-popup-starting .update-popup-message { + color: color-mix(in srgb, var(--accent) 75%, var(--text)); +} + +.update-popup-done .update-popup-message { + color: color-mix(in srgb, var(--accent) 65%, var(--text)); +} + +.update-popup-error .update-popup-message { + color: color-mix(in srgb, var(--danger) 65%, var(--text)); +} + +.update-popup-bar-track { + margin-top: 6px; + height: 4px; + background: var(--progress-track); + border-radius: 2px; + overflow: hidden; +} + +.update-popup-bar-fill { + height: 100%; + background: var(--accent); + border-radius: 2px; + transition: width 0.3s ease; +} + +.settings-body { + display: grid; + grid-template-columns: 180px 1fr; + gap: 0; + min-height: 0; + overflow: hidden; +} + +.settings-sidebar { + display: flex; + flex-direction: column; + gap: 2px; + padding: 4px 8px 4px 0; + border-right: 1px solid var(--border); + overflow-y: auto; +} + +.settings-sidebar-tab { + background: none; + border: none; + border-left: 3px solid transparent; + color: var(--muted); + padding: 8px 12px; + cursor: pointer; + font: inherit; + font-size: 13px; + font-weight: 600; + text-align: left; + border-radius: 0 8px 8px 0; + transition: background 0.12s, color 0.12s, border-color 0.12s; +} + +.settings-sidebar-tab:hover { + background: var(--button-bg-hover); + color: var(--text); +} + +.settings-sidebar-tab.active { + border-left-color: var(--accent); + color: var(--text); + background: var(--tab-active); +} + +.settings-content { + overflow-y: auto; + padding: 4px 0 4px 14px; +} + +.settings-section { + display: flex; + flex-direction: column; + gap: 7px; +} + +.account-settings-layout { + display: grid; + grid-template-columns: 1fr; + gap: 14px; +} + +.account-board { + gap: 12px; +} + +.account-board-header { + display: flex; + justify-content: space-between; + align-items: flex-start; + gap: 12px; + flex-wrap: wrap; +} + +.account-board-summary { + display: flex; + gap: 8px; + flex-wrap: wrap; +} + +.account-display-toggle { + width: fit-content; +} + +.account-inline-stat { + display: inline-flex; + align-items: center; + min-height: 28px; + padding: 0 10px; + border-radius: 999px; + border: 1px solid color-mix(in srgb, var(--border) 85%, transparent); + background: color-mix(in srgb, var(--button-bg) 86%, transparent); + color: var(--text); + font-size: 12px; + font-weight: 700; +} + +.account-empty-state { + display: grid; + gap: 4px; + padding: 14px; + border: 1px dashed color-mix(in srgb, var(--border) 80%, transparent); + border-radius: 12px; + background: color-mix(in srgb, var(--surface) 90%, transparent); +} + +.account-empty-state.compact { + padding: 12px; +} + +.account-empty-state strong { + font-size: 14px; +} + +.account-empty-state span { + color: var(--muted); + font-size: 13px; +} + +.account-table { + display: grid; + gap: 8px; + --account-col-service: 220px; + --account-col-mode: 96px; + --account-col-status: 300px; + --account-col-info: 220px; + --account-col-secret: 180px; + --account-col-actions: 560px; + width: 100%; +} + +.account-table-head, +.account-row { + display: grid; + grid-template-columns: + minmax(130px, var(--account-col-service)) + minmax(72px, var(--account-col-mode)) + minmax(140px, var(--account-col-status)) + minmax(160px, var(--account-col-info)) + minmax(180px, var(--account-col-secret)) + minmax(420px, var(--account-col-actions)); + gap: 10px; + align-items: center; + width: 100%; +} + +.account-table-head { + padding: 6px 12px; + color: var(--muted); + font-size: 12px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.04em; + align-items: center; +} + +.account-table-head > *:nth-child(n+2) { + border-left: 2px solid color-mix(in srgb, var(--border) 60%, transparent); + padding-left: 10px; + padding-right: 10px; + box-sizing: border-box; +} + +.account-header-cell { + position: relative; + display: flex; + align-items: center; + justify-content: center; + min-width: 0; + width: 100%; +} + +.account-header-cell > span { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.account-resize-handle { + position: absolute; + top: -6px; + right: -7px; + width: 14px; + height: calc(100% + 12px); + border: none; + background: transparent; + cursor: col-resize; + padding: 0; + z-index: 2; +} + +.account-resize-handle:hover::after, +.account-resize-handle:focus-visible::after { + content: ""; + position: absolute; + top: 6px; + bottom: 6px; + left: 50%; + width: 2px; + transform: translateX(-50%); + border-radius: 999px; + background: var(--accent); +} + +.account-row { + padding: 10px 12px; + border: 1px solid color-mix(in srgb, var(--border) 88%, transparent); + border-radius: 14px; + background: linear-gradient(180deg, color-mix(in srgb, var(--card) 97%, transparent), color-mix(in srgb, var(--surface) 96%, transparent)); +} + +.account-cell { + min-width: 0; + overflow-wrap: anywhere; + text-align: center; + display: flex; + align-items: center; + justify-content: center; +} + +.account-row > .account-cell:nth-child(n+2) { + border-left: 2px solid color-mix(in srgb, var(--border) 60%, transparent); + padding-left: 10px; + padding-right: 10px; + box-sizing: border-box; +} + +.account-row > .account-cell.account-row-actions { + justify-content: center; +} + +.account-service-cell { + display: grid; + gap: 3px; + justify-items: center; + align-content: center; +} + +.account-status-cell { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: center; + gap: 4px 8px; +} + +.account-info-cell { + display: flex; + align-items: center; + justify-content: center; + min-width: 0; +} + +.account-usage-stack { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 4px; + min-width: 0; +} + +.account-service-cell strong { + font-size: 14px; +} + +.account-service-cell span { + color: var(--muted); + font-size: 12px; + line-height: 1.4; +} + +.account-note { + display: block; + width: 100%; + color: var(--muted); + font-size: 11px; + max-width: 100%; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + text-align: center; +} + +.account-usage-stats { + display: flex; + flex-wrap: nowrap; + align-items: center; + gap: 4px; + color: var(--muted); + font-size: 11px; + white-space: nowrap; +} + +.account-usage-stats span + span::before { + content: "·"; + margin-right: 4px; +} + +.account-usage-stats.warning { + color: color-mix(in srgb, #f59e0b 78%, white 8%); +} + +.account-usage-total { + color: var(--muted); + font-size: 11px; + white-space: nowrap; +} + +.account-mode-pill, +.account-status-pill { + display: inline-flex; + align-items: center; + justify-content: center; + min-height: 28px; + padding: 0 10px; + border-radius: 999px; + font-size: 12px; + font-weight: 700; + border: 1px solid color-mix(in srgb, var(--border) 85%, transparent); +} + +.account-mode-pill { + background: color-mix(in srgb, var(--accent) 10%, transparent); + color: var(--text); +} + +.account-status-pill { + width: fit-content; + background: color-mix(in srgb, #4ade80 14%, transparent); + border-color: rgba(74, 222, 128, 0.25); + color: var(--text); +} + +.account-status-pill.account-status-disabled { + background: color-mix(in srgb, var(--danger) 12%, transparent); + border-color: color-mix(in srgb, var(--danger) 40%, transparent); + color: color-mix(in srgb, var(--danger) 75%, var(--text)); +} + +.account-status-pill.account-status-down { + background: color-mix(in srgb, var(--danger) 12%, transparent); + border-color: color-mix(in srgb, var(--danger) 40%, transparent); +} + +.account-status-pill.account-status-not_tracked, +.account-status-pill.account-status-unknown { + background: color-mix(in srgb, #f59e0b 12%, transparent); + border-color: rgba(245, 158, 11, 0.3); +} + +.account-secret { + display: inline-flex; + align-items: center; + justify-content: center; + min-height: 34px; + padding: 0 12px; + border-radius: 12px; + background: linear-gradient(180deg, color-mix(in srgb, var(--field) 96%, transparent), color-mix(in srgb, var(--surface) 92%, transparent)); + border: 1px solid color-mix(in srgb, var(--border) 88%, transparent); + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.04); + box-sizing: border-box; + font-family: "JetBrains Mono", "Consolas", monospace; + font-size: 12px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.account-row-actions { + display: flex; + justify-content: center; + align-items: center; + gap: 6px; + flex-wrap: nowrap; + align-content: center; + text-align: center; + padding-left: 10px; + padding-right: 10px; + min-width: 0; + overflow: hidden; +} + +.account-row-actions .btn { + padding: 5px 8px; + font-size: 11px; + white-space: nowrap; + min-width: 82px; + justify-content: center; +} + +.account-row-disabled { + opacity: 0.65; +} + +.account-row-disabled .account-row-actions { + opacity: 1; +} + +.account-display-actions { + display: flex; + justify-content: flex-end; + margin: -2px 0 10px; +} + +.key-stats-popup { + width: min(1360px, calc(100vw - 20px)); + max-width: min(1360px, calc(100vw - 20px)); + max-height: calc(100vh - 24px); + overflow: hidden; + display: flex; + flex-direction: column; + gap: 12px; +} + +.modal-card.key-stats-popup { + width: min(1360px, calc(100vw - 20px)); + max-width: min(1360px, calc(100vw - 20px)); +} + +.key-stats-popup-header { + display: flex; + justify-content: space-between; + align-items: flex-start; + gap: 12px; +} + +.key-stats-popup-header h3 { + margin: 0; + font-size: 14px; +} + +.key-stats-summary { + margin: 2px 0 0; + font-size: 12px; + color: var(--muted); +} + +.key-stats-warn { + color: #f59e0b; +} + +.key-stats-popup .account-subkey-table { + overflow-y: auto; + max-height: calc(100vh - 140px); +} + +.account-subkey-table { + margin-top: 6px; + border: 1px solid color-mix(in srgb, var(--border) 72%, transparent); + border-radius: 8px; + overflow: hidden; + font-size: 11px; +} + +.account-subkey-table-head, +.account-subkey-table-row { + display: grid; + grid-template-columns: 24px minmax(340px, 1fr) 72px 96px 180px 110px 168px; + align-items: center; + padding: 3px 8px; + gap: 10px; +} + +.account-subkey-table-head { + background: color-mix(in srgb, var(--field) 90%, transparent); + color: var(--muted); + font-weight: 600; + font-size: 10px; + text-transform: uppercase; + letter-spacing: 0.03em; + border-bottom: 1px solid color-mix(in srgb, var(--border) 50%, transparent); + padding: 4px 8px; +} + +.account-subkey-table-head .col-usage, +.account-subkey-table-head .col-limit, +.account-subkey-table-head .col-status, +.account-subkey-table-head .col-traffic, +.account-subkey-table-head .col-links, +.account-subkey-table-row .col-usage, +.account-subkey-table-row .col-limit, +.account-subkey-table-row .col-status, +.account-subkey-table-row .col-traffic, +.account-subkey-table-row .col-links { + text-align: right; + justify-self: center; +} + +.account-subkey-table-row { + border-bottom: 1px solid color-mix(in srgb, var(--border) 30%, transparent); +} + +.account-subkey-table-row:last-child { + border-bottom: none; +} + +.account-subkey-table-row.warning { + background: color-mix(in srgb, #f59e0b 8%, transparent); +} + +.account-subkey-table-row.disabled { + background: color-mix(in srgb, var(--field) 45%, transparent); +} + +.account-subkey-table-row .col-key { + font-weight: 600; + color: var(--muted); +} + +.account-subkey-table-row .col-masked { + font-family: "JetBrains Mono", "Consolas", monospace; + color: var(--muted); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + min-width: 0; +} + +.account-subkey-table-row .col-masked.link-popup-click { + display: block; +} + +.account-subkey-table-row .col-usage { + text-align: center; +} + +.account-subkey-table-row .col-limit { + text-align: center; + color: var(--muted); +} + +.account-subkey-table-row .col-status { + text-align: center; +} + +.account-subkey-table-row .col-traffic, +.account-subkey-table-row .col-links { + text-align: center; + color: var(--muted); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.account-subkey-table-row .col-action { + display: flex; + justify-content: flex-end; + gap: 6px; +} + +.account-subkey-table-row .col-action .btn { + padding: 1px 6px; + font-size: 10px; +} + +.status-pill { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 88px; + padding: 2px 8px; + border-radius: 999px; + font-size: 10px; + font-weight: 600; + letter-spacing: 0.02em; +} + +.status-pill-ok { + color: #166534; + background: color-mix(in srgb, #22c55e 16%, transparent); +} + +.status-pill-warn { + color: #92400e; + background: color-mix(in srgb, #f59e0b 18%, transparent); +} + +.status-pill-bad { + color: #991b1b; + background: color-mix(in srgb, #ef4444 14%, transparent); +} + +.status-pill-muted { + color: var(--muted); + background: color-mix(in srgb, var(--field) 80%, transparent); +} + +.btn-sm { + padding: 3px 8px; + font-size: 11px; +} + +.hoster-routing-table { + display: flex; + flex-direction: column; + gap: 0; + border: 1px solid var(--border); + border-radius: 10px; + overflow: hidden; + margin-bottom: 10px; +} + +.hoster-routing-header { + display: grid; + grid-template-columns: 1fr 1fr 40px; + gap: 10px; + padding: 8px 12px; + background: color-mix(in srgb, var(--card) 80%, var(--surface)); + font-size: 0.78rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.04em; + color: var(--muted); + border-bottom: 1px solid var(--border); +} + +.hoster-routing-row { + display: grid; + grid-template-columns: 1fr 1fr 40px; + gap: 10px; + padding: 6px 12px; + align-items: center; + border-bottom: 1px solid color-mix(in srgb, var(--border) 40%, transparent); +} + +.hoster-routing-row:last-child { + border-bottom: none; +} + +.hoster-routing-label { + font-weight: 500; + font-size: 0.88rem; +} + +.hoster-routing-row select { + padding: 4px 8px; + font-size: 0.84rem; +} + +.hoster-routing-row .btn-sm { + padding: 2px 8px; + font-size: 0.9rem; + min-width: 28px; + line-height: 1.4; + border-radius: 6px; +} + +.hoster-routing-add { + margin-top: 8px; +} + +.hoster-routing-add select { + max-width: 260px; +} + +.provider-order-list { + display: flex; + flex-direction: column; + gap: 4px; + margin-bottom: 10px; +} + +.provider-order-row { + display: flex; + align-items: center; + gap: 10px; + padding: 7px 10px; + border-radius: 8px; + background: rgba(255, 255, 255, 0.04); + border: 1px solid var(--border); + cursor: grab; + transition: border-color 0.12s ease, background 0.12s ease, transform 0.12s ease; +} + +.provider-order-row.dragging { + opacity: 0.6; + cursor: grabbing; +} + +.provider-order-row.drag-target { + border-color: color-mix(in srgb, var(--accent) 65%, var(--border)); + background: color-mix(in srgb, var(--accent) 10%, rgba(255, 255, 255, 0.04)); + transform: translateY(-1px); +} + +.provider-order-num { + font-size: 0.8rem; + color: var(--muted); + min-width: 18px; + text-align: right; +} + +.provider-order-label { + flex: 1; + font-size: 0.9rem; + font-weight: 500; +} + +.provider-order-actions { + display: flex; + gap: 4px; +} + +.provider-order-actions .btn-sm { + padding: 2px 8px; + font-size: 0.9rem; + line-height: 1.4; +} + +.account-modal { + width: min(960px, calc(100vw - 36px)); + max-height: calc(100vh - 36px); + overflow: hidden; + gap: 14px; +} + +.account-modal-header { + display: flex; + justify-content: space-between; + align-items: flex-start; + gap: 12px; + flex-wrap: wrap; +} + +.account-modal-header > div:first-child { + flex: 1; + min-width: min(320px, 100%); +} + +.account-modal-header p { + margin: 4px 0 0; + color: var(--muted); +} + +.account-dialog-step { + display: grid; + gap: 12px; + align-content: start; +} + +.account-dialog-step-label { + font-size: 14px; + font-weight: 700; +} + +.account-modal-body { + display: grid; + gap: 16px; + min-height: 0; + overflow-y: auto; + padding-right: 4px; +} + +.account-picker-search { + width: 100%; +} + +.account-modal label { + display: block; + margin-bottom: 6px; + color: color-mix(in srgb, var(--text) 92%, var(--muted)); + font-size: 13px; + font-weight: 600; + letter-spacing: 0.01em; +} + +.account-picker-search, +.account-modal input:not([type="checkbox"]):not([type="radio"]), +.account-modal select, +.account-modal textarea { + width: 100%; + min-height: 42px; + padding: 10px 12px; + border-radius: 12px; + border: 1px solid color-mix(in srgb, var(--border) 90%, transparent); + background: linear-gradient(180deg, color-mix(in srgb, var(--field) 97%, transparent), color-mix(in srgb, var(--surface) 94%, transparent)); + color: var(--text); + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.04); + font: inherit; + box-sizing: border-box; + appearance: none; + transition: border-color 0.12s ease, box-shadow 0.12s ease, background 0.12s ease; +} + +.account-picker-search::placeholder, +.account-modal input::placeholder, +.account-modal textarea::placeholder { + color: color-mix(in srgb, var(--muted) 88%, transparent); +} + +.account-picker-search:hover, +.account-modal input:not([type="checkbox"]):not([type="radio"]):hover, +.account-modal select:hover, +.account-modal textarea:hover { + border-color: color-mix(in srgb, var(--accent) 35%, var(--border)); +} + +.account-picker-search:focus-visible, +.account-modal input:not([type="checkbox"]):not([type="radio"]):focus-visible, +.account-modal select:focus-visible, +.account-modal textarea:focus-visible { + outline: none; + border-color: color-mix(in srgb, var(--accent) 68%, transparent); + box-shadow: + 0 0 0 1px color-mix(in srgb, var(--accent) 28%, transparent), + 0 0 0 4px color-mix(in srgb, var(--accent) 12%, transparent), + inset 0 1px 0 rgba(255, 255, 255, 0.05); +} + +.account-modal input[type="password"] { + letter-spacing: 0.02em; +} + +.account-modal textarea { + white-space: nowrap; + overflow-x: auto; +} + +.account-picker-table { + display: grid; + border: 1px solid color-mix(in srgb, var(--border) 84%, transparent); + border-radius: 14px; + overflow: auto; + max-height: min(330px, 40vh); + background: linear-gradient(180deg, color-mix(in srgb, var(--card) 96%, transparent), color-mix(in srgb, var(--surface) 94%, transparent)); +} + +.account-picker-head, +.account-picker-row { + display: grid; + grid-template-columns: minmax(0, 1.05fr) minmax(220px, 0.95fr); + gap: 14px; + align-items: start; +} + +.account-picker-head { + padding: 10px 14px; + color: var(--muted); + font-size: 12px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.04em; + position: sticky; + top: 0; + z-index: 1; + background: color-mix(in srgb, var(--card) 98%, transparent); +} + +.account-picker-row { + display: grid; + width: 100%; + padding: 12px 14px; + text-align: left; + border: none; + border-top: 1px solid color-mix(in srgb, var(--border) 80%, transparent); + background: transparent; + color: var(--text); + cursor: pointer; + transition: border-color 0.12s ease, transform 0.12s ease, background 0.12s ease; +} + +.account-dialog-step + .account-dialog-step { + padding-top: 4px; + border-top: 1px solid color-mix(in srgb, var(--border) 75%, transparent); +} + +.account-picker-row:hover { + background: color-mix(in srgb, var(--button-bg-hover) 90%, transparent); +} + +.account-picker-row.active { + background: color-mix(in srgb, var(--accent) 12%, transparent); + box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--accent) 35%, transparent); +} + +.account-picker-row > div { + min-width: 0; + display: grid; + gap: 4px; +} + +.account-picker-row strong { + font-size: 13px; +} + +.account-picker-row span { + color: var(--muted); + font-size: 12px; + line-height: 1.45; +} + +.account-form-summary { + display: grid; + gap: 4px; + padding: 12px 14px; + border-radius: 12px; + border: 1px solid color-mix(in srgb, var(--border) 82%, transparent); + background: color-mix(in srgb, var(--field) 84%, transparent); +} + +.account-form-summary span { + color: var(--muted); + font-size: 13px; +} + +.account-modal-fields { + display: grid; + gap: 12px; +} + +.account-modal-note { + padding: 12px 14px; + border-radius: 12px; + border: 1px solid color-mix(in srgb, var(--accent) 26%, transparent); + background: color-mix(in srgb, var(--accent) 8%, transparent); + color: var(--text); + font-size: 13px; + line-height: 1.5; +} + +.account-mode-note { + padding: 10px 12px; + border-radius: 10px; + border: 1px solid color-mix(in srgb, var(--accent) 22%, transparent); + background: color-mix(in srgb, var(--accent) 7%, transparent); + color: var(--muted); + font-size: 12.5px; + line-height: 1.5; +} + +.account-mode-note strong { + color: var(--text); +} + +.account-dl-key-limit-list { + display: grid; + gap: 8px; +} + +.account-dl-key-limit-row { + display: grid; + grid-template-columns: minmax(160px, 220px) minmax(0, 1fr); + gap: 10px; + align-items: center; + padding: 10px 12px; + border-radius: 12px; + border: 1px solid color-mix(in srgb, var(--border) 82%, transparent); + background: color-mix(in srgb, var(--field) 84%, transparent); +} + +.account-dl-key-meta { + display: grid; + gap: 2px; + min-width: 0; +} + +.account-dl-key-meta strong { + font-size: 13px; +} + +.account-dl-key-meta span { + color: var(--muted); + font-size: 12px; + font-family: "JetBrains Mono", "Consolas", monospace; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.account-secret.account-secret-multiline { + display: grid; + align-items: start; + gap: 4px; + padding: 10px 12px; + white-space: normal; +} + +.account-secret.account-secret-multiline span { + display: block; + line-height: 1.35; +} + +.account-status-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); + gap: 10px; +} + +.account-status-grid > div { + display: grid; + gap: 4px; + padding: 10px 12px; + border-radius: 12px; + border: 1px solid color-mix(in srgb, var(--border) 82%, transparent); + background: color-mix(in srgb, var(--field) 84%, transparent); +} + +.account-status-grid strong { + font-size: 14px; +} + +.field-grid { + display: grid; + gap: 8px; +} + +.field-grid.two { + grid-template-columns: repeat(2, minmax(0, 1fr)); +} + +.field-grid.three { + grid-template-columns: repeat(3, minmax(0, 1fr)); +} + +.field-grid > div { + min-width: 0; +} + +.toggle-line { + display: flex; + align-items: center; + gap: 7px; +} + +.toggle-line input[type="checkbox"] { + width: 15px; + height: 15px; +} + +.package-card { + border: 1px solid var(--border); + border-radius: 11px; + background: linear-gradient(180deg, color-mix(in srgb, var(--card) 96%, transparent), color-mix(in srgb, var(--surface) 96%, transparent)); + padding: 6px 10px; +} + +.queue-package-card { + border: 0; + border-radius: 0; + background: transparent; + padding: 0; + box-shadow: none; + border-bottom: 1px solid color-mix(in srgb, var(--border) 54%, transparent); +} + +.queue-package-card.pkg-stripe-a { + background: color-mix(in srgb, var(--surface) 18%, transparent); +} + +.queue-package-card.pkg-stripe-b { + background: color-mix(in srgb, var(--card) 24%, transparent); +} + +.queue-package-card:hover { + background: color-mix(in srgb, var(--accent) 3%, transparent); +} + +.package-card[draggable="true"] { + cursor: grab; +} + +.package-card[draggable="true"]:active { + cursor: grabbing; +} + +.disabled-pkg { + opacity: 0.72; +} + +.pkg-selected { + border-color: var(--accent); + box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--accent) 30%, transparent); +} + +.queue-package-card.pkg-selected { + border-color: transparent; + box-shadow: inset 2px 0 0 0 var(--accent); + background: color-mix(in srgb, var(--accent) 8%, transparent); +} + +.item-selected { + background: color-mix(in srgb, var(--accent) 12%, transparent); +} + +.package-card header { + display: flex; + justify-content: space-between; + gap: 8px; + align-items: center; +} + +.queue-package-card header { + min-height: 28px; + padding: 3px 10px; +} + +.package-card h4 { + margin: 0; + font-size: 14px; +} + +.package-card header span { + color: var(--muted); + font-size: 12px; +} + +.package-card header .progress-inline-text-filled, +.package-card header .progress-size-text-filled { + color: #0a0f1a; +} + +.pkg-toggle { + display: inline-flex; + align-items: center; + justify-content: center; + width: 20px; + height: 20px; + flex-shrink: 0; + border: 1px solid var(--border); + border-radius: 4px; + background: var(--button-bg); + color: var(--muted); + font-size: 15px; + font-weight: 700; + line-height: 1; + cursor: pointer; + padding: 0; + transition: background 0.12s ease, border-color 0.12s ease, color 0.12s ease; +} + +.queue-package-card .pkg-toggle { + width: 16px; + height: 16px; + border-radius: 3px; + font-size: 12px; + background: transparent; + border-color: color-mix(in srgb, var(--border) 42%, transparent); +} + +.pkg-toggle:hover { + border-color: var(--accent); + color: var(--text); + background: var(--button-bg-hover); +} + +.pkg-name-row input[type="checkbox"] { + width: 16px; + height: 16px; +} + +.rename-input { + flex: 1; + min-width: 0; + background: transparent; + border: 1px solid var(--accent); + border-radius: 4px; + color: var(--text); + font: inherit; + font-size: 15px; + font-weight: 600; + padding: 1px 4px; + margin: -2px 0; + outline: none; +} + +.pkg-speed { + margin-left: 7px; + color: color-mix(in srgb, var(--accent) 70%, var(--text)); +} + +.pkg-actions { + display: flex; + gap: 6px; + flex-wrap: wrap; + justify-content: flex-end; +} + +.progress { + margin-top: 6px; + height: 6px; + border-radius: 999px; + background: var(--progress-track); + overflow: hidden; + display: flex; +} + +.queue-package-card .progress { + margin-top: 0; + height: 2px; + border-radius: 0; + background: color-mix(in srgb, var(--progress-track) 80%, transparent); +} + +.progress-dl { + height: 100%; + background: linear-gradient(90deg, #f2942d, #ff7a5c); +} + +.progress-ex { + height: 100%; + background: linear-gradient(90deg, #22c55e, #4ade80); +} + +.history-view { + display: flex; + flex-direction: column; + gap: 6px; + height: 100%; + min-height: 0; + overflow: auto; + padding-right: 2px; +} + +.history-toolbar { + display: flex; + align-items: center; + flex-wrap: wrap; + justify-content: space-between; + gap: 12px; + padding: 5px 12px; + background: var(--card); + border: 1px solid var(--border); + border-radius: 8px; + font-size: 12px; + font-weight: 700; + color: var(--muted); +} + +.history-toolbar-actions { + display: flex; + align-items: center; + justify-content: flex-end; + gap: 8px; + margin-left: auto; +} + +.history-card { + cursor: default; +} + +.history-details { + padding: 10px 12px; + border-top: 1px solid color-mix(in srgb, var(--border) 40%, transparent); +} + +.history-detail-grid { + display: grid; + grid-template-columns: 140px 1fr; + gap: 4px 16px; + font-size: 13px; +} + +.history-label { + color: var(--muted); + font-weight: 600; +} + +.history-path { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.history-actions { + margin-top: 10px; + display: flex; + justify-content: flex-end; + gap: 8px; +} + +table { + width: 100%; + table-layout: fixed; + border-collapse: collapse; + margin-top: 10px; + font-size: 13px; +} + +th, +td { + border-bottom: 1px solid rgba(35, 57, 84, 0.7); + padding: 7px 8px; + text-align: left; +} + +th { + color: var(--muted); + font-weight: 600; +} + +td { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.num { + font-variant-numeric: tabular-nums; + font-family: "Consolas", "SFMono-Regular", "Menlo", monospace; +} + +.item-row { + display: grid; + + gap: 8px; + align-items: center; + margin: 0 -10px; + padding: 3px 10px; + font-size: 12px; + border-top: 1px solid color-mix(in srgb, var(--border) 40%, transparent); +} + +.queue-package-card .item-row { + margin: 0; + padding: 2px 10px 2px 10px; + border-top: 0; +} + +.queue-package-card .item-row + .item-row { + border-top: 1px solid color-mix(in srgb, var(--border) 24%, transparent); +} + +.item-row:hover { + background: color-mix(in srgb, var(--accent) 5%, transparent); +} + +.item-row .pkg-col { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + color: var(--muted); + font-size: 12px; + text-align: center; +} + +.item-row .pkg-col-name { + text-align: left; +} + +.item-row .pkg-col-name { + color: var(--text); + padding-left: 28px; +} + +.link-status-dot { + display: inline-block; + width: 8px; + height: 8px; + border-radius: 50%; + margin-right: 6px; + flex-shrink: 0; + vertical-align: middle; +} +.link-status-dot.online { + background: #22c55e; + box-shadow: 0 0 4px #22c55e80; +} +.link-status-dot.offline { + background: #ef4444; + box-shadow: 0 0 4px #ef444480; +} +.link-status-dot.checking { + background: #f59e0b; + box-shadow: 0 0 4px #f59e0b80; +} +/* Reserve the dot's footprint even before a status exists, so the filename does + not shift ~14px right when the online/offline/checking dot first appears. */ +.link-status-dot-empty { + background: transparent; + box-shadow: none; +} + +.prio-high { + color: #f59e0b !important; + font-weight: 700; +} + +.prio-low { + color: #64748b !important; +} + +.pkg-col-dragging { + opacity: 0.4; +} + +.pkg-col-drop-target { + box-shadow: -2px 0 0 0 var(--accent); +} + +.pkg-column-header .pkg-col { + cursor: grab; +} + +.pkg-column-header .pkg-col.sortable { + cursor: pointer; +} + +.ctx-menu-sub { + position: relative; +} + +.ctx-menu-sub > .ctx-menu-item::after { + content: ""; +} + +.ctx-menu-sub-items { + display: none; + position: absolute; + left: 100%; + top: 0; + min-width: 120px; + background: var(--card); + border: 1px solid var(--border); + border-radius: 6px; + padding: 4px 0; + box-shadow: 0 4px 12px rgba(0,0,0,.3); + z-index: 1001; +} + +.ctx-menu-sub:hover .ctx-menu-sub-items { + display: block; +} + +.ctx-menu-active { + color: var(--accent) !important; +} + +.ctx-menu-disabled { + opacity: 0.4; + cursor: not-allowed !important; + pointer-events: none; +} + +.item-remove { + background: none; + border: none; + color: var(--danger); + cursor: pointer; + font-size: 12px; + font-weight: 700; + padding: 2px 4px; + border-radius: 4px; + opacity: 0.6; +} + +.item-remove:hover { + opacity: 1; + background: rgba(244, 63, 94, 0.1); +} + +.statistics-view { + height: 100%; + overflow: auto; + display: grid; + grid-template-columns: 1fr 1fr; + grid-template-rows: auto 1fr; + gap: 10px; + min-height: 0; +} + +.stats-overview { + grid-column: span 2; +} + +.stats-actions { + display: flex; + flex-wrap: wrap; + gap: 10px; + margin-top: 10px; +} + +.stats-sections { + display: flex; + flex-direction: column; + gap: 14px; + margin-top: 14px; +} + +.stats-section { + display: flex; + flex-direction: column; + gap: 10px; +} + +.stats-section-title { + color: var(--muted); + font-size: 11px; + font-weight: 700; + letter-spacing: 0.12em; + text-transform: uppercase; +} + +.stats-grid { + display: grid; + grid-template-columns: repeat(6, minmax(0, 1fr)); + gap: 12px; +} + +.stat-item { + display: flex; + flex-direction: column; + justify-content: space-between; + gap: 10px; + min-height: 82px; + min-width: 0; + padding: 11px 14px; + background: var(--field); + border-radius: 10px; + border: 1px solid var(--border); + text-align: left; +} + +.stat-button { + width: 100%; + font: inherit; + color: inherit; + appearance: none; + cursor: default; +} + +.stat-top { + display: flex; + flex-direction: column; + gap: 2px; + min-width: 0; +} + +.stat-eyebrow { + color: var(--muted); + font-size: 9.5px; + font-weight: 700; + letter-spacing: 0.14em; + text-transform: uppercase; + opacity: 0.7; +} + +.stat-item.stat-item-clickable { + cursor: pointer; + transition: border-color 0.15s, transform 0.15s ease; +} +.stat-item.stat-item-clickable:hover { + border-color: var(--danger, #e05555); + transform: translateY(-1px); +} + +.stat-label { + color: var(--text); + font-size: 13px; + font-weight: 600; + line-height: 1.25; +} + +.stat-value { + display: flex; + align-items: baseline; + gap: 4px; + font-variant-numeric: tabular-nums; + line-height: 1.1; + overflow-wrap: anywhere; +} + +.stat-num { + font-size: clamp(22px, 1.35vw, 30px); + font-weight: 700; +} + +.stat-unit { + font-size: 13px; + font-weight: 600; + color: var(--muted); +} + +.stat-value.stat-value-compact { + font-size: clamp(18px, 1.05vw, 24px); + font-weight: 700; +} + +.stat-value.stat-idle, +.stat-value.stat-idle .stat-num { + color: var(--muted); + opacity: 0.5; +} + +.stat-value.danger, +.stat-value.danger .stat-num { + color: var(--danger); +} + +.stats-chart-card { + min-height: 280px; + display: flex; + flex-direction: column; +} + +.bandwidth-chart-container { + flex: 1; + min-height: 200px; + position: relative; + margin-top: 8px; + background: var(--field); + border-radius: 10px; + border: 1px solid var(--border); + overflow: hidden; +} + +.bandwidth-chart-container canvas { + display: block; + width: 100%; + height: 100%; +} + +.stats-provider-card { + min-height: 280px; + display: flex; + flex-direction: column; +} + +.provider-stats { + flex: 1; + overflow: auto; + display: flex; + flex-direction: column; + gap: 10px; + margin-top: 8px; +} + +.provider-stat-item { + display: flex; + flex-direction: column; + gap: 4px; + padding: 10px 12px; + background: var(--field); + border-radius: 10px; + border: 1px solid var(--border); +} + +.provider-name { + font-weight: 600; + font-size: 14px; +} + +.provider-bars { + display: flex; + flex-direction: column; + gap: 3px; +} + +.provider-bar { + height: 8px; + background: var(--progress-track); + border-radius: 999px; + overflow: hidden; +} + +.bar-fill { + height: 100%; + border-radius: 999px; + transition: width 0.3s ease; +} + +.bar-fill.completed { + background: var(--accent, #f2942d); +} + +.provider-detail { + color: var(--muted); + font-size: 12px; + font-variant-numeric: tabular-nums; +} + +.empty-provider { + color: var(--muted); + text-align: center; + padding: 20px; + font-size: 13px; +} + +@media (max-width: 1680px) { + .stats-grid { + grid-template-columns: repeat(4, minmax(0, 1fr)); + } +} + +@media (max-width: 1100px) { + .statistics-view { + grid-template-columns: 1fr; + grid-template-rows: auto auto auto; + } + + .stats-overview { + grid-column: span 1; + } + + .stats-grid { + grid-template-columns: repeat(3, minmax(0, 1fr)); + } +} + +@media (max-width: 760px) { + .stats-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .stat-item { + min-height: 88px; + } +} + +@media (max-width: 520px) { + .stats-grid { + grid-template-columns: 1fr; + } +} + +.schedule-row { + display: grid; + grid-template-columns: 56px auto 56px auto 92px auto auto auto; + align-items: center; + gap: 6px; +} + +.schedule-row input[type="number"] { + text-align: center; +} + +.schedule-row .btn { + padding: 5px 8px; +} + +.btn-icon { + border: 1px solid var(--border); + background: var(--button-bg); + color: var(--text); + border-radius: 7px; + width: 28px; + height: 28px; + cursor: pointer; +} + +.btn-icon.danger { + color: var(--danger); +} + +.col-actions { + width: 8%; +} + +.empty { + border: 1px dashed var(--border); + border-radius: 12px; + padding: 24px; + color: var(--muted); + text-align: center; +} + +.toast { + position: fixed; + right: 20px; + bottom: 18px; + background: var(--toast-bg); + color: var(--text); + border: 1px solid var(--toast-border); + border-radius: 12px; + padding: 10px 14px; + box-shadow: 0 16px 30px rgba(0, 0, 0, 0.35); + z-index: 50; +} + +.ctx-menu { + position: fixed; + z-index: 100; + min-width: 200px; + background: var(--card); + border: 1px solid var(--border); + border-radius: 8px; + padding: 4px 0; + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4); +} + +.ctx-menu-item { + display: block; + width: 100%; + background: none; + border: none; + color: var(--text); + padding: 7px 14px; + cursor: pointer; + font: inherit; + font-size: 13px; + text-align: left; + transition: background 0.1s; +} + +.ctx-menu-item:hover { + background: var(--button-bg-hover); +} + +.ctx-menu-item.ctx-danger { + color: var(--danger); +} + +.ctx-menu-sep { + height: 1px; + background: var(--border); + margin: 4px 8px; +} + +.link-popup { + width: min(720px, 90vw); + max-height: 70vh; + display: flex; + flex-direction: column; +} + +.link-popup-list { + flex: 1; + overflow: auto; + border: 1px solid var(--border); + border-radius: 8px; + background: var(--field); + max-height: 50vh; +} + +.link-popup-row { + display: grid; + grid-template-columns: 1fr 1.5fr; + gap: 12px; + padding: 6px 10px; + font-size: 12px; + border-bottom: 1px solid color-mix(in srgb, var(--border) 40%, transparent); +} + +.link-popup-row:last-child { + border-bottom: none; +} + +.link-popup-name, +.link-popup-url { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.link-popup-name { + color: var(--text); +} + +.link-popup-url { + color: var(--muted); +} + +.link-popup-click { + cursor: pointer; + border-radius: 4px; + padding: 1px 3px; + margin: -1px -3px; + transition: background 0.1s; +} + +.link-popup-click:hover { + background: var(--button-bg-hover); +} + +.drop-overlay { + position: fixed; + inset: 0; + display: grid; + place-items: center; + background: var(--overlay); + border: 2px dashed color-mix(in srgb, var(--accent) 72%, transparent); + color: var(--text); + font-size: 20px; + font-weight: 600; + pointer-events: none; + backdrop-filter: blur(2px); + z-index: 200; +} + +.modal-backdrop { + position: fixed; + inset: 0; + background: rgba(4, 9, 18, 0.68); + display: grid; + place-items: center; + z-index: 20; + padding: 20px; +} + +.modal-card { + width: min(560px, 100%); + max-height: calc(100vh - 40px); + overflow-y: auto; + border: 1px solid var(--border); + border-radius: 14px; + background: linear-gradient(180deg, color-mix(in srgb, var(--card) 98%, transparent), color-mix(in srgb, var(--surface) 98%, transparent)); + box-shadow: 0 20px 38px rgba(0, 0, 0, 0.35); + padding: 16px; + display: grid; + gap: 10px; +} + +.modal-card h3 { + margin: 0; +} + +.modal-card p { + margin: 0; + color: var(--muted); +} + +.modal-details { + border: 1px solid var(--border); + border-radius: 6px; + padding: 0; +} +.modal-details summary { + padding: 6px 10px; + cursor: pointer; + font-size: 13px; + color: var(--muted); + user-select: none; +} +.modal-details summary:hover { + color: var(--text); +} +.modal-details pre { + margin: 0; + padding: 8px 10px; + border-top: 1px solid var(--border); + font-size: 12px; + line-height: 1.5; + white-space: pre-wrap; + word-break: break-word; + max-height: 260px; + overflow-y: auto; + color: var(--muted); +} + +.modal-path { + font-size: 12px; + word-break: break-all; +} + +.modal-actions { + display: flex; + justify-content: flex-end; + gap: 8px; + flex-wrap: wrap; +} + +@media (max-width: 1100px) { + .control-strip { + flex-direction: column; + align-items: flex-start; + } + + .buttons-right { + width: 100%; + margin-left: 0; + justify-content: flex-end; + } + + .settings-toolbar { + flex-direction: column; + align-items: flex-start; + } + + .downloads-toolbar .search-input { + width: 100%; + margin-left: 0; + } + + .settings-toolbar-actions { + width: 100%; + } + + .settings-toolbar-actions-wrap { + width: 100%; + align-items: flex-start; + } + + .grid-two { + grid-template-columns: 1fr; + } + + .settings-body { + grid-template-columns: 140px 1fr; + } + + .account-settings-layout { + grid-template-columns: 1fr; + } + + .account-table-head { + display: none; + } + + .account-row { + grid-template-columns: 1fr; + min-width: 0; + } + + .account-row-actions { + justify-content: flex-start; + } + + .account-subkey-table-head, + .account-subkey-table-row { + grid-template-columns: 24px minmax(0, 1fr) 64px 56px 44px; + font-size: 10px; + padding: 2px 6px; + } + + .account-dl-key-limit-row { + grid-template-columns: 1fr; + } + + .account-subkey-head { + flex-direction: column; + align-items: flex-start; + gap: 2px; + } + + .account-picker-list { + grid-template-columns: 1fr; + } + + .account-picker-head, + .account-picker-row { + grid-template-columns: 1fr; + } + + .field-grid.two, + .field-grid.three { + grid-template-columns: 1fr; + } + + .schedule-row { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .schedule-row span { + display: none; + } + + .package-card header { + flex-direction: column; + align-items: flex-start; + } + + .pkg-columns, + .pkg-column-header, + .item-row { + grid-template-columns: 1fr !important; + } + + .pkg-column-header .pkg-col-progress, + .pkg-column-header .pkg-col-size, + .pkg-column-header .pkg-col-hoster, + .pkg-column-header .pkg-col-account, + .pkg-column-header .pkg-col-prio, + .pkg-column-header .pkg-col-status, + .pkg-column-header .pkg-col-speed, + .pkg-column-header .pkg-col-added { + display: none; + } + + .pkg-columns .pkg-col-progress, + .pkg-columns .pkg-col-size, + .pkg-columns .pkg-col-hoster, + .pkg-columns .pkg-col-account, + .pkg-columns .pkg-col-prio, + .pkg-columns .pkg-col-status, + .pkg-columns .pkg-col-speed, + .pkg-columns .pkg-col-added { + display: none; + } + + .pkg-actions { + width: 100%; + justify-content: flex-start; + } + + .card.wide, + .settings-actions { + grid-column: span 1; + } +} + +.account-board-header-actions { display: flex; gap: 8px; align-items: center; } +.account-validity-badge { + display: inline-block; + margin-top: 4px; + padding: 2px 8px; + border-radius: 999px; + font-size: 11px; + font-weight: 600; + letter-spacing: 0.2px; + border: 1px solid transparent; + white-space: nowrap; +} +.account-validity-badge.ok { color: #10240f; background: #4fb96a; border-color: #3f9d57; } +.account-validity-badge.free { color: #2a2113; background: #f2c14e; border-color: #d9a72f; } +.account-validity-badge.invalid { color: #fff; background: #d9534f; border-color: #c0392b; } +.account-validity-badge.unknown { color: var(--muted, #a59c8e); background: transparent; border-color: var(--line, #4a4032); } +.account-validity-badge.disabled { color: var(--muted, #a59c8e); background: transparent; border-color: var(--border); opacity: 0.8; } + +.acct2-table { + display: flex; + flex-direction: column; + border: 1px solid var(--border); + border-radius: 10px; + overflow: hidden; + margin-top: 4px; +} +.acct2-head, +.acct2-row { + display: grid; + grid-template-columns: 32px minmax(0, 1.4fr) minmax(0, 1.4fr) minmax(0, 1.3fr) minmax(0, 1.7fr) minmax(0, 1fr) 196px; + align-items: center; + gap: 10px; + padding: 7px 12px; +} +.acct2-head > span, +.acct2-row > span { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; +} +.acct2-head > .acct2-c-actions, +.acct2-row > .acct2-c-actions { overflow: visible; } +.acct2-head { + background: color-mix(in srgb, var(--card) 60%, transparent); + border-bottom: 1px solid var(--border); + font-size: 11px; + font-weight: 700; + letter-spacing: 0.04em; + text-transform: uppercase; + color: var(--muted); +} +.acct2-row { + border-bottom: 1px solid color-mix(in srgb, var(--border) 55%, transparent); + font-size: 13px; +} +.acct2-row:last-child { border-bottom: 0; } +.acct2-row.acct2-problem { background: color-mix(in srgb, var(--danger) 15%, transparent); } +.acct2-row.acct2-disabled { opacity: 0.55; } +.acct2-group-head { cursor: pointer; background: color-mix(in srgb, var(--card) 50%, transparent); } +.acct2-group-head:hover { background: color-mix(in srgb, var(--card) 68%, transparent); } +.acct2-group-head .acct2-hoster strong { font-size: 13px; } +.acct2-chevron { display: inline-block; color: var(--muted); font-size: 9px; transition: transform 0.12s ease; } +.acct2-chevron.open { transform: rotate(90deg); } +.acct2-grp-sum { display: flex; gap: 6px; flex-wrap: wrap; } +.acct2-row.acct2-member .acct2-hoster { + padding-left: 12px; + border-left: 2px solid color-mix(in srgb, var(--accent) 45%, transparent); +} +.acct2-c-check { display: flex; justify-content: center; } +.acct2-c-check input { cursor: pointer; } +.acct2-c-actions { display: flex; gap: 6px; justify-content: flex-end; } +.acct2-hoster { display: flex; flex-direction: column; gap: 1px; min-width: 0; } +.acct2-hoster strong { font-size: 13px; } +.acct2-mode { font-size: 11px; color: var(--muted); } +.acct2-traffic, +.acct2-expiry { font-variant-numeric: tabular-nums; color: var(--muted); } +.acct2-head > span:nth-child(3), +.acct2-traffic { text-align: center; } +.acct2-sortable { cursor: pointer; user-select: none; } +.acct2-sortable:hover { color: var(--text); } +.acct2-user { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.acct2-status .account-validity-badge { margin-top: 0; } +.acct2-nostatus { color: var(--muted); opacity: 0.6; font-variant-numeric: tabular-nums; } + +.rotation-panel { display: flex; flex-direction: column; gap: 6px; max-height: 320px; overflow-y: auto; } +.rotation-empty { color: var(--muted, #a59c8e); font-size: 12px; } +.rotation-event { + display: grid; + grid-template-columns: 72px 1fr; + gap: 8px; + align-items: baseline; + padding: 4px 8px; + border-radius: 6px; + font-size: 12px; + border-left: 3px solid var(--line, #4a4032); + background: rgba(255,255,255,0.02); +} +.rotation-event.WARN { border-left-color: #f2c14e; } +.rotation-event.ERROR { border-left-color: #d9534f; } +.rotation-event.INFO { border-left-color: #4fb96a; } +.rotation-event .rotation-time { color: var(--muted, #a59c8e); font-variant-numeric: tabular-nums; } +.rotation-event .rotation-body strong { font-weight: 600; } +.rotation-event .rotation-reason { color: var(--muted, #a59c8e); } + +.rd-status-line { + display: flex; + align-items: center; + gap: 8px; + font-size: 13px; + color: var(--text); +} +.rd-dot { + width: 9px; + height: 9px; + border-radius: 50%; + background: var(--muted); + flex: 0 0 auto; +} +.rd-dot.on { + background: #3fb950; +} +.rd-field { + display: grid; + gap: 5px; +} +.rd-field > label { + font-size: 13px; + font-weight: 600; + color: var(--text); +} +.rd-field input, +.rd-field textarea { + width: 100%; + box-sizing: border-box; + background: var(--bg); + color: var(--text); + border: 1px solid var(--border); + border-radius: 8px; + padding: 8px 10px; + font-size: 13px; + font-family: inherit; +} +.rd-field input:focus, +.rd-field textarea:focus { + outline: none; + border-color: var(--accent); +} +.rd-field textarea { + resize: vertical; + min-height: 62px; +} +.rd-field-inline { + grid-template-columns: 1fr 1fr; + gap: 10px; +} +.rd-hint { + font-size: 12px; + color: var(--muted); + line-height: 1.45; +} +.rd-seg { + display: flex; + width: fit-content; + border: 1px solid var(--border); + border-radius: 8px; + overflow: hidden; +} +.rd-seg button { + background: transparent; + color: var(--muted); + border: none; + padding: 7px 14px; + cursor: pointer; + font-size: 13px; + font-weight: 600; +} +.rd-seg button.active { + background: var(--accent); + color: #1a1206; +} +.rd-chips { + display: flex; + flex-wrap: wrap; + gap: 6px; +} +.rd-chip { + background: var(--surface); + border: 1px solid var(--border); + border-radius: 6px; + padding: 3px 9px; + font-size: 12px; + cursor: pointer; + color: var(--muted); +} +.rd-chip:hover { + color: var(--text); + border-color: var(--accent); +} +.rd-code { + font-family: ui-monospace, "Cascadia Code", monospace; + font-size: 12px; + word-break: break-all; + background: var(--bg); + border: 1px solid var(--border); + border-radius: 8px; + padding: 9px 10px; + max-height: 120px; + overflow-y: auto; + color: var(--text); +} diff --git a/src/renderer/vite-env.d.ts b/src/renderer/vite-env.d.ts new file mode 100644 index 0000000..7a60c7b --- /dev/null +++ b/src/renderer/vite-env.d.ts @@ -0,0 +1,9 @@ +import type { ElectronApi } from "../shared/preload-api"; + +declare global { + interface Window { + rd: ElectronApi; + } +} + +export {}; diff --git a/src/shared/debrid-link-keys.ts b/src/shared/debrid-link-keys.ts new file mode 100644 index 0000000..1753f3a --- /dev/null +++ b/src/shared/debrid-link-keys.ts @@ -0,0 +1,66 @@ +export interface DebridLinkApiKeyEntry { + id: string; + token: string; + index: number; + label: string; + masked: string; +} + +const FNV64_OFFSET_BASIS = 0xcbf29ce484222325n; +const FNV64_PRIME = 0x100000001b3n; +const FNV64_MASK = 0xffffffffffffffffn; + +function fnv1a64(text: string): string { + let hash = FNV64_OFFSET_BASIS; + for (const char of text) { + hash ^= BigInt(char.codePointAt(0) || 0); + hash = (hash * FNV64_PRIME) & FNV64_MASK; + } + return hash.toString(36); +} + +export function maskDebridLinkApiKey(token: string): string { + const trimmed = token.trim(); + if (!trimmed) { + return "Nicht hinterlegt"; + } + if (trimmed.length <= 6) { + return "*".repeat(trimmed.length); + } + return `${trimmed.slice(0, 3)}${"*".repeat(Math.max(4, trimmed.length - 6))}${trimmed.slice(-3)}`; +} + +export function getDebridLinkApiKeyId(token: string): string { + return `dlk_${fnv1a64(token.trim())}`; +} + +export function getDebridLinkApiKeyLabel(index: number): string { + return `Key ${index + 1}`; +} + +export function parseDebridLinkApiKeys(raw: string): DebridLinkApiKeyEntry[] { + const seen = new Set(); + const tokens = String(raw || "") + .split(/[\n,]+/) + .map((entry) => entry.trim()) + .filter(Boolean) + .filter((token) => { + if (seen.has(token)) { + return false; + } + seen.add(token); + return true; + }); + + return tokens.map((token, index) => ({ + id: getDebridLinkApiKeyId(token), + token, + index, + label: getDebridLinkApiKeyLabel(index), + masked: maskDebridLinkApiKey(token) + })); +} + +export function getDebridLinkApiKeyIds(raw: string): string[] { + return parseDebridLinkApiKeys(raw).map((entry) => entry.id); +} diff --git a/src/shared/ipc.ts b/src/shared/ipc.ts new file mode 100644 index 0000000..6c26574 --- /dev/null +++ b/src/shared/ipc.ts @@ -0,0 +1,77 @@ +export const IPC_CHANNELS = { + GET_SNAPSHOT: "app:get-snapshot", + GET_VERSION: "app:get-version", + CHECK_UPDATES: "app:check-updates", + INSTALL_UPDATE: "app:install-update", + UPDATE_INSTALL_PROGRESS: "app:update-install-progress", + OPEN_EXTERNAL: "app:open-external", + UPDATE_SETTINGS: "app:update-settings", + RESET_PROVIDER_DAILY_USAGE: "app:reset-provider-daily-usage", + RESET_DEBRID_LINK_API_KEY_DAILY_USAGE: "app:reset-debrid-link-api-key-daily-usage", + ADD_LINKS: "queue:add-links", + ADD_CONTAINERS: "queue:add-containers", + GET_START_CONFLICTS: "queue:get-start-conflicts", + RESOLVE_START_CONFLICT: "queue:resolve-start-conflict", + CLEAR_ALL: "queue:clear-all", + START: "queue:start", + START_PACKAGES: "queue:start-packages", + STOP: "queue:stop", + TOGGLE_PAUSE: "queue:toggle-pause", + CANCEL_PACKAGE: "queue:cancel-package", + RENAME_PACKAGE: "queue:rename-package", + REORDER_PACKAGES: "queue:reorder-packages", + REMOVE_ITEM: "queue:remove-item", + TOGGLE_PACKAGE: "queue:toggle-package", + EXPORT_PACKAGE_SELECTION: "queue:export-package-selection", + EXPORT_ITEM_SELECTION: "queue:export-item-selection", + EXPORT_QUEUE: "queue:export", + IMPORT_QUEUE: "queue:import", + PICK_FOLDER: "dialog:pick-folder", + PICK_CONTAINERS: "dialog:pick-containers", + STATE_UPDATE: "state:update", + CLIPBOARD_DETECTED: "clipboard:detected", + TOGGLE_CLIPBOARD: "clipboard:toggle", + GET_SESSION_STATS: "stats:get-session-stats", + RESET_SESSION_STATS: "stats:reset-session", + RESET_DOWNLOAD_STATS: "stats:reset-download", + RESTART: "app:restart", + QUIT: "app:quit", + EXPORT_BACKUP: "app:export-backup", + IMPORT_BACKUP: "app:import-backup", + EXPORT_SUPPORT_BUNDLE: "app:export-support-bundle", + OPEN_LOG: "app:open-log", + OPEN_AUDIT_LOG: "app:open-audit-log", + OPEN_RENAME_LOG: "app:open-rename-log", + OPEN_SESSION_LOG: "app:open-session-log", + OPEN_TRACE_LOG: "app:open-trace-log", + OPEN_PACKAGE_LOG: "app:open-package-log", + OPEN_ITEM_LOG: "app:open-item-log", + GET_DEBUG_SETUP_CHECK: "app:get-debug-setup-check", + GET_RECENT_ERRORS: "app:get-recent-errors", + TEST_NOTIFY: "app:test-notify", + GET_TRACE_CONFIG: "app:get-trace-config", + SET_TRACE_ENABLED: "app:set-trace-enabled", + ROTATE_DEBUG_TOKEN: "app:rotate-debug-token", + GET_REMOTE_DIAGNOSTICS: "app:get-remote-diagnostics", + ENABLE_REMOTE_DIAGNOSTICS: "app:enable-remote-diagnostics", + DISABLE_REMOTE_DIAGNOSTICS: "app:disable-remote-diagnostics", + ROTATE_REMOTE_DIAGNOSTICS_TOKEN: "app:rotate-remote-diagnostics-token", + OPEN_REALDEBRID_LOGIN: "app:open-realdebrid-login", + OPEN_ALLDEBRID_LOGIN: "app:open-alldebrid-login", + IMPORT_BESTDEBRID_COOKIES: "app:import-bestdebrid-cookies", + GET_ALLDEBRID_HOST_INFO: "app:get-alldebrid-host-info", + GET_DEBRIDLINK_HOST_LIMITS: "app:get-debridlink-host-limits", + CHECK_DEBRID_ACCOUNTS: "app:check-debrid-accounts", + CHECK_MEGA_DEBRID_ACCOUNT: "app:check-mega-debrid-account", + RETRY_EXTRACTION: "queue:retry-extraction", + EXTRACT_NOW: "queue:extract-now", + RESET_PACKAGE: "queue:reset-package", + GET_HISTORY: "history:get", + CLEAR_HISTORY: "history:clear", + REMOVE_HISTORY_ENTRY: "history:remove-entry", + SET_PACKAGE_PRIORITY: "queue:set-package-priority", + SKIP_ITEMS: "queue:skip-items", + RESET_ITEMS: "queue:reset-items", + START_ITEMS: "queue:start-items", + LOG_RENDERER_ERROR: "log:renderer-error" +} as const; diff --git a/src/shared/mega-debrid-accounts.ts b/src/shared/mega-debrid-accounts.ts new file mode 100644 index 0000000..f682da0 --- /dev/null +++ b/src/shared/mega-debrid-accounts.ts @@ -0,0 +1,90 @@ +export interface MegaDebridAccountEntry { + id: string; + login: string; + password: string; + index: number; + label: string; + maskedLogin: string; +} + +const FNV64_OFFSET_BASIS = 0xcbf29ce484222325n; +const FNV64_PRIME = 0x100000001b3n; +const FNV64_MASK = 0xffffffffffffffffn; + +function fnv1a64(text: string): string { + let hash = FNV64_OFFSET_BASIS; + for (const char of text) { + hash ^= BigInt(char.codePointAt(0) || 0); + hash = (hash * FNV64_PRIME) & FNV64_MASK; + } + return hash.toString(36); +} + +export function getMegaDebridAccountId(login: string): string { + return `mda_${fnv1a64(login.trim().toLowerCase())}`; +} + +export function maskMegaDebridLogin(login: string): string { + const trimmed = login.trim(); + if (!trimmed) { + return "Nicht hinterlegt"; + } + if (trimmed.length <= 4) { + return `${trimmed[0]}${"*".repeat(trimmed.length - 1)}`; + } + return `${trimmed.slice(0, 2)}${"*".repeat(Math.max(3, trimmed.length - 4))}${trimmed.slice(-2)}`; +} + +export function getMegaDebridAccountLabel(index: number): string { + return `Account ${index + 1}`; +} + +export function parseMegaDebridAccounts(raw: string, legacyPassword = ""): MegaDebridAccountEntry[] { + const seen = new Set(); + const lines = String(raw || "") + .split(/\n+/) + .map((line) => line.trim()) + .filter(Boolean); + + const entries: MegaDebridAccountEntry[] = []; + for (const line of lines) { + const colonIdx = line.indexOf(":"); + let login: string; + let password: string; + if (colonIdx >= 0) { + login = line.slice(0, colonIdx).trim(); + password = line.slice(colonIdx + 1).trim(); + } else { + login = line; + password = legacyPassword; + } + if (!login || !password) { + continue; + } + const key = login.toLowerCase(); + if (seen.has(key)) { + continue; + } + seen.add(key); + entries.push({ + id: getMegaDebridAccountId(login), + login, + password, + index: entries.length, + label: getMegaDebridAccountLabel(entries.length), + maskedLogin: maskMegaDebridLogin(login) + }); + } + return entries; +} + +export function serializeMegaDebridAccounts(accounts: { login: string; password: string }[]): string { + return accounts + .filter((a) => a.login.trim() && a.password.trim()) + .map((a) => `${a.login.trim()}:${a.password.trim()}`) + .join("\n"); +} + +export function getMegaDebridAccountIds(raw: string, legacyPassword = ""): string[] { + return parseMegaDebridAccounts(raw, legacyPassword).map((entry) => entry.id); +} diff --git a/src/shared/mega-debrid-errors.ts b/src/shared/mega-debrid-errors.ts new file mode 100644 index 0000000..311a686 --- /dev/null +++ b/src/shared/mega-debrid-errors.ts @@ -0,0 +1,24 @@ +export function isMegaDebridResolveFailure(errorText: string): boolean { + const text = String(errorText || "").toLowerCase(); + return /supprim/.test(text) + || text.includes("introuvable") + || text.includes("n'existe plus") + || text.includes("n existe plus") + || text.includes("fichier inexistant"); +} + +export function isMegaDebridTransientResolveFailure(errorText: string): boolean { + const text = String(errorText || "").toLowerCase(); + return isMegaDebridResolveFailure(text) + || text.includes("datei beim hoster gerade nicht abrufbar") + || text.includes("datei beim hoster nicht gefunden"); +} + +export function germanMegaDebridResolveReason(errorText: string): string { + const text = String(errorText || "").toLowerCase(); + if (text.includes("datei beim hoster nicht gefunden") + || text.includes("introuvable") || text.includes("fichier inexistant") || text.includes("n'existe plus") || text.includes("n existe plus")) { + return "Datei beim Hoster nicht gefunden"; + } + return "Datei beim Hoster gerade nicht abrufbar"; +} diff --git a/src/shared/preload-api.ts b/src/shared/preload-api.ts new file mode 100644 index 0000000..40612e5 --- /dev/null +++ b/src/shared/preload-api.ts @@ -0,0 +1,101 @@ +import type { + AddLinksPayload, + AllDebridHostInfo, + AppSettings, + DebridAccountStatus, + DebugSetupCheckResult, + DebridLinkHostLimitInfo, + DebridProvider, + DuplicatePolicy, + EnableRemoteDiagnosticsInput, + HistoryEntry, + PackagePriority, + RemoteDiagnosticsInfo, + RendererErrorReport, + SessionStats, + StartConflictEntry, + StartConflictResolutionResult, + SupportTraceConfig, + UiSnapshot, + UpdateCheckResult, + UpdateInstallProgress, + UpdateInstallResult +} from "./types"; + +export interface ElectronApi { + getSnapshot: () => Promise; + getVersion: () => Promise; + checkUpdates: () => Promise; + installUpdate: () => Promise; + openExternal: (url: string) => Promise; + updateSettings: (settings: Partial) => Promise; + resetProviderDailyUsage: (provider: DebridProvider) => Promise; + resetDebridLinkApiKeyDailyUsage: (keyId: string) => Promise; + addLinks: (payload: AddLinksPayload) => Promise<{ addedPackages: number; addedLinks: number; invalidCount: number }>; + addContainers: (filePaths: string[]) => Promise<{ addedPackages: number; addedLinks: number }>; + getStartConflicts: () => Promise; + resolveStartConflict: (packageId: string, policy: DuplicatePolicy) => Promise; + clearAll: () => Promise; + start: () => Promise; + startPackages: (packageIds: string[]) => Promise; + stop: () => Promise; + togglePause: () => Promise; + cancelPackage: (packageId: string) => Promise; + renamePackage: (packageId: string, newName: string) => Promise; + reorderPackages: (packageIds: string[]) => Promise; + removeItem: (itemId: string) => Promise; + togglePackage: (packageId: string) => Promise; + exportPackageSelection: (packageIds: string[]) => Promise<{ saved: boolean; packageCount: number; linkCount: number; filePath?: string }>; + exportItemSelection: (itemIds: string[]) => Promise<{ saved: boolean; packageCount: number; linkCount: number; filePath?: string }>; + exportQueue: () => Promise<{ saved: boolean }>; + importQueue: (json: string) => Promise<{ addedPackages: number; addedLinks: number }>; + toggleClipboard: () => Promise; + pickFolder: () => Promise; + pickContainers: () => Promise; + getSessionStats: () => Promise; + resetSessionStats: () => Promise; + resetDownloadStats: () => Promise; + restart: () => Promise; + quit: () => Promise; + exportBackup: () => Promise<{ saved: boolean }>; + importBackup: () => Promise<{ restored: boolean; relaunch: boolean; message: string }>; + exportSupportBundle: () => Promise<{ saved: boolean; filePath?: string }>; + openLog: () => Promise; + openAuditLog: () => Promise; + openRenameLog: () => Promise; + openSessionLog: () => Promise; + openTraceLog: () => Promise; + openPackageLog: (packageId: string) => Promise; + openItemLog: (itemId: string) => Promise; + getDebugSetupCheck: () => Promise; + getRecentErrors: () => Promise>; + testNotification: (url: string, mention: string) => Promise; + getTraceConfig: () => Promise; + setTraceEnabled: (enabled: boolean, note?: string, durationMinutes?: number) => Promise; + rotateDebugToken: () => Promise<{ path: string }>; + getRemoteDiagnostics: () => Promise; + enableRemoteDiagnostics: (input: EnableRemoteDiagnosticsInput) => Promise; + disableRemoteDiagnostics: () => Promise; + rotateRemoteDiagnosticsToken: () => Promise; + openRealDebridLogin: () => Promise; + openAllDebridLogin: () => Promise; + importBestDebridCookies: () => Promise; + getAllDebridHostInfo: () => Promise; + getDebridLinkHostLimits: () => Promise; + checkDebridAccounts: () => Promise; + checkMegaDebridAccount: (login: string, password: string) => Promise; + retryExtraction: (packageId: string) => Promise; + extractNow: (packageId: string) => Promise; + resetPackage: (packageId: string) => Promise; + getHistory: () => Promise; + clearHistory: () => Promise; + removeHistoryEntry: (entryId: string) => Promise; + setPackagePriority: (packageId: string, priority: PackagePriority) => Promise; + skipItems: (itemIds: string[]) => Promise; + resetItems: (itemIds: string[]) => Promise; + startItems: (itemIds: string[]) => Promise; + reportRendererError: (report: RendererErrorReport) => void; + onStateUpdate: (callback: (snapshot: UiSnapshot) => void) => () => void; + onClipboardDetected: (callback: (links: string[]) => void) => () => void; + onUpdateInstallProgress: (callback: (progress: UpdateInstallProgress) => void) => () => void; +} diff --git a/src/shared/provider-daily-limits.ts b/src/shared/provider-daily-limits.ts new file mode 100644 index 0000000..ab168f8 --- /dev/null +++ b/src/shared/provider-daily-limits.ts @@ -0,0 +1,329 @@ +import type { AppSettings, DebridProvider } from "./types"; + +export type ProviderByteMap = Partial>; +export type DebridLinkKeyByteMap = Record; + +type ProviderDailySettings = + Pick + & Partial> + & Partial>; + +type ProviderUsageSettings = + ProviderDailySettings + & Partial> + & Partial>; + +function normalizePositiveBytes(value: unknown): number { + const numeric = Number(value); + if (!Number.isFinite(numeric) || numeric <= 0) { + return 0; + } + return Math.floor(numeric); +} + +export function getProviderUsageDayKey(epochMs = Date.now()): string { + const current = new Date(epochMs); + const year = current.getFullYear(); + const month = String(current.getMonth() + 1).padStart(2, "0"); + const day = String(current.getDate()).padStart(2, "0"); + return `${year}-${month}-${day}`; +} + +export function getProviderDailyLimitBytes(settings: ProviderDailySettings, provider: DebridProvider): number { + return normalizePositiveBytes(settings.providerDailyLimitBytes?.[provider]); +} + +export function getProviderDailyUsageBytes( + settings: ProviderDailySettings, + provider: DebridProvider, + epochMs = Date.now() +): number { + if (settings.providerDailyUsageDay !== getProviderUsageDayKey(epochMs)) { + return 0; + } + return normalizePositiveBytes(settings.providerDailyUsageBytes?.[provider]); +} + +export function getProviderDailyRemainingBytes( + settings: ProviderDailySettings, + provider: DebridProvider, + epochMs = Date.now() +): number | null { + const limit = getProviderDailyLimitBytes(settings, provider); + if (limit <= 0) { + return null; + } + return Math.max(0, limit - getProviderDailyUsageBytes(settings, provider, epochMs)); +} + +export function isProviderDailyLimitReached( + settings: ProviderDailySettings, + provider: DebridProvider, + epochMs = Date.now() +): boolean { + const limit = getProviderDailyLimitBytes(settings, provider); + return limit > 0 && getProviderDailyUsageBytes(settings, provider, epochMs) >= limit; +} + +export function getProviderTotalUsageBytes(settings: ProviderUsageSettings, provider: DebridProvider): number { + return normalizePositiveBytes(settings.providerTotalUsageBytes?.[provider]); +} + +export function resetProviderDailyUsage( + settings: ProviderDailySettings, + provider?: DebridProvider, + epochMs = Date.now() +): Pick { + const dayKey = getProviderUsageDayKey(epochMs); + if (!provider) { + return { + providerDailyUsageDay: dayKey, + providerDailyUsageBytes: {} + }; + } + + const nextUsageBytes = settings.providerDailyUsageDay === dayKey + ? { ...(settings.providerDailyUsageBytes || {}) } + : {}; + delete nextUsageBytes[provider]; + + return { + providerDailyUsageDay: dayKey, + providerDailyUsageBytes: nextUsageBytes + }; +} + +export function addProviderDailyUsageBytes( + settings: ProviderDailySettings, + provider: DebridProvider, + byteDelta: number, + epochMs = Date.now() +): Pick { + const increment = normalizePositiveBytes(byteDelta); + const dayKey = getProviderUsageDayKey(epochMs); + const currentUsageBytes = settings.providerDailyUsageDay === dayKey + ? { ...(settings.providerDailyUsageBytes || {}) } + : {}; + if (increment <= 0) { + return { + providerDailyUsageDay: dayKey, + providerDailyUsageBytes: currentUsageBytes + }; + } + + const nextUsageBytes = currentUsageBytes; + nextUsageBytes[provider] = normalizePositiveBytes(nextUsageBytes[provider]) + increment; + + return { + providerDailyUsageDay: dayKey, + providerDailyUsageBytes: nextUsageBytes + }; +} + +export function addProviderTotalUsageBytes( + settings: ProviderUsageSettings, + provider: DebridProvider, + byteDelta: number +): Pick { + const increment = normalizePositiveBytes(byteDelta); + const currentUsageBytes = { ...(settings.providerTotalUsageBytes || {}) }; + if (increment <= 0) { + return { + providerTotalUsageBytes: currentUsageBytes + }; + } + + currentUsageBytes[provider] = normalizePositiveBytes(currentUsageBytes[provider]) + increment; + + return { + providerTotalUsageBytes: currentUsageBytes + }; +} + +export function getDebridLinkApiKeyDailyLimitBytes(settings: ProviderDailySettings, keyId: string): number { + return normalizePositiveBytes(settings.debridLinkApiKeyDailyLimitBytes?.[keyId]); +} + +export function getDebridLinkApiKeyDailyUsageBytes( + settings: ProviderDailySettings, + keyId: string, + epochMs = Date.now() +): number { + if (settings.providerDailyUsageDay !== getProviderUsageDayKey(epochMs)) { + return 0; + } + return normalizePositiveBytes(settings.debridLinkApiKeyDailyUsageBytes?.[keyId]); +} + +export function getDebridLinkApiKeyDailyRemainingBytes( + settings: ProviderDailySettings, + keyId: string, + epochMs = Date.now() +): number | null { + const limit = getDebridLinkApiKeyDailyLimitBytes(settings, keyId); + if (limit <= 0) { + return null; + } + return Math.max(0, limit - getDebridLinkApiKeyDailyUsageBytes(settings, keyId, epochMs)); +} + +export function isDebridLinkApiKeyDailyLimitReached( + settings: ProviderDailySettings, + keyId: string, + epochMs = Date.now() +): boolean { + const limit = getDebridLinkApiKeyDailyLimitBytes(settings, keyId); + return limit > 0 && getDebridLinkApiKeyDailyUsageBytes(settings, keyId, epochMs) >= limit; +} + +export function getDebridLinkApiKeyTotalUsageBytes(settings: ProviderUsageSettings, keyId: string): number { + return normalizePositiveBytes(settings.debridLinkApiKeyTotalUsageBytes?.[keyId]); +} + +export function resetDebridLinkApiKeyDailyUsage( + settings: ProviderDailySettings, + keyId?: string, + epochMs = Date.now() +): Pick { + const dayKey = getProviderUsageDayKey(epochMs); + if (!keyId) { + return { + providerDailyUsageDay: dayKey, + debridLinkApiKeyDailyUsageBytes: {} + }; + } + + const nextUsageBytes = settings.providerDailyUsageDay === dayKey + ? { ...(settings.debridLinkApiKeyDailyUsageBytes || {}) } + : {}; + delete nextUsageBytes[keyId]; + + return { + providerDailyUsageDay: dayKey, + debridLinkApiKeyDailyUsageBytes: nextUsageBytes + }; +} + +export function addDebridLinkApiKeyDailyUsageBytes( + settings: ProviderDailySettings, + keyId: string, + byteDelta: number, + epochMs = Date.now() +): Pick { + const increment = normalizePositiveBytes(byteDelta); + const dayKey = getProviderUsageDayKey(epochMs); + const currentUsageBytes = settings.providerDailyUsageDay === dayKey + ? { ...(settings.debridLinkApiKeyDailyUsageBytes || {}) } + : {}; + if (increment <= 0) { + return { + providerDailyUsageDay: dayKey, + debridLinkApiKeyDailyUsageBytes: currentUsageBytes + }; + } + + currentUsageBytes[keyId] = normalizePositiveBytes(currentUsageBytes[keyId]) + increment; + + return { + providerDailyUsageDay: dayKey, + debridLinkApiKeyDailyUsageBytes: currentUsageBytes + }; +} + +export function addDebridLinkApiKeyTotalUsageBytes( + settings: ProviderUsageSettings, + keyId: string, + byteDelta: number +): Pick { + const increment = normalizePositiveBytes(byteDelta); + const currentUsageBytes = { ...(settings.debridLinkApiKeyTotalUsageBytes || {}) }; + if (increment <= 0) { + return { + debridLinkApiKeyTotalUsageBytes: currentUsageBytes + }; + } + + currentUsageBytes[keyId] = normalizePositiveBytes(currentUsageBytes[keyId]) + increment; + + return { + debridLinkApiKeyTotalUsageBytes: currentUsageBytes + }; +} + +export function isMegaDebridAccountDisabled(settings: ProviderDailySettings, accountId: string): boolean { + return Array.isArray(settings.megaDebridDisabledAccountIds) && settings.megaDebridDisabledAccountIds.includes(accountId); +} + +export function getMegaDebridAccountDailyLimitBytes(settings: ProviderDailySettings, accountId: string): number { + return normalizePositiveBytes(settings.megaDebridAccountDailyLimitBytes?.[accountId]); +} + +export function getMegaDebridAccountDailyUsageBytes( + settings: ProviderDailySettings, + accountId: string, + epochMs = Date.now() +): number { + if (settings.providerDailyUsageDay !== getProviderUsageDayKey(epochMs)) { + return 0; + } + return normalizePositiveBytes(settings.megaDebridAccountDailyUsageBytes?.[accountId]); +} + +export function isMegaDebridAccountDailyLimitReached( + settings: ProviderDailySettings, + accountId: string, + epochMs = Date.now() +): boolean { + const limit = getMegaDebridAccountDailyLimitBytes(settings, accountId); + return limit > 0 && getMegaDebridAccountDailyUsageBytes(settings, accountId, epochMs) >= limit; +} + +export function getMegaDebridAccountTotalUsageBytes(settings: ProviderUsageSettings, accountId: string): number { + return normalizePositiveBytes(settings.megaDebridAccountTotalUsageBytes?.[accountId]); +} + +export function addMegaDebridAccountDailyUsageBytes( + settings: ProviderDailySettings, + accountId: string, + byteDelta: number, + epochMs = Date.now() +): Pick { + const increment = normalizePositiveBytes(byteDelta); + const dayKey = getProviderUsageDayKey(epochMs); + const currentUsageBytes = settings.providerDailyUsageDay === dayKey + ? { ...(settings.megaDebridAccountDailyUsageBytes || {}) } + : {}; + if (increment <= 0) { + return { + providerDailyUsageDay: dayKey, + megaDebridAccountDailyUsageBytes: currentUsageBytes + }; + } + + currentUsageBytes[accountId] = normalizePositiveBytes(currentUsageBytes[accountId]) + increment; + + return { + providerDailyUsageDay: dayKey, + megaDebridAccountDailyUsageBytes: currentUsageBytes + }; +} + +export function addMegaDebridAccountTotalUsageBytes( + settings: ProviderUsageSettings, + accountId: string, + byteDelta: number +): Pick { + const increment = normalizePositiveBytes(byteDelta); + const currentUsageBytes = { ...(settings.megaDebridAccountTotalUsageBytes || {}) }; + if (increment <= 0) { + return { + megaDebridAccountTotalUsageBytes: currentUsageBytes + }; + } + + currentUsageBytes[accountId] = normalizePositiveBytes(currentUsageBytes[accountId]) + increment; + + return { + megaDebridAccountTotalUsageBytes: currentUsageBytes + }; +} diff --git a/src/shared/types.ts b/src/shared/types.ts new file mode 100644 index 0000000..d56a950 --- /dev/null +++ b/src/shared/types.ts @@ -0,0 +1,567 @@ +export type DownloadStatus = + | "queued" + | "validating" + | "downloading" + | "paused" + | "reconnect_wait" + | "extracting" + | "integrity_check" + | "completed" + | "failed" + | "cancelled"; + +export type CleanupMode = "none" | "trash" | "delete"; +export type ConflictMode = "overwrite" | "skip" | "rename" | "ask"; +export type SpeedMode = "global" | "per_download"; +export type FinishedCleanupPolicy = "never" | "immediate" | "on_start" | "package_done"; +export type DebridProvider = + | "realdebrid" + | "megadebrid" + | "megadebrid-api" + | "megadebrid-web" + | "bestdebrid" + | "alldebrid" + | "ddownload" + | "onefichier" + | "debridlink" + | "linksnappy"; +export type DebridFallbackProvider = DebridProvider | "none"; +export type AppTheme = "dark" | "light"; +export type PackagePriority = "high" | "normal" | "low"; +export type ExtractCpuPriority = "high" | "middle" | "low"; +export type HistoryRetentionMode = "never" | "session" | "permanent"; + +export interface BandwidthScheduleEntry { + id: string; + startHour: number; + endHour: number; + speedLimitKbps: number; + enabled: boolean; +} + +export interface DownloadStats { + totalDownloaded: number; + totalDownloadedAllTime: number; + totalFiles?: number; + totalFilesSession: number; + totalFilesAllTime: number; + totalPackages: number; + sessionStartedAt: number; + appSessionStartedAt: number; + sessionRuntimeMs: number; + totalRuntimeMs: number; + runtimeMeasuredAt: number; +} + +export interface DebridAccountStatus { + accountId: string; + provider: "megadebrid" | "debridlink"; + label: string; + maskedLogin: string; + valid: boolean; + isPremium: boolean; + premiumUntilMs: number | null; + email?: string; + message: string; + checkedAt: number; +} + +export interface AppSettings { + token: string; + realDebridUseWebLogin: boolean; + megaLogin: string; + megaPassword: string; + megaCredentials: string; + megaDebridApiEnabled: boolean; + megaDebridWebEnabled: boolean; + megaDebridPreferApi: boolean; + bestToken: string; + bestDebridUseWebLogin: boolean; + allDebridToken: string; + allDebridUseWebLogin: boolean; + ddownloadLogin: string; + ddownloadPassword: string; + oneFichierApiKey: string; + debridLinkApiKeys: string; + debridLinkDisabledKeyIds: string[]; + linkSnappyLogin: string; + linkSnappyPassword: string; + archivePasswordList: string; + rememberToken: boolean; + providerOrder: readonly DebridProvider[]; + providerPrimary: DebridProvider; + providerSecondary: DebridFallbackProvider; + providerTertiary: DebridFallbackProvider; + autoProviderFallback: boolean; + outputDir: string; + packageName: string; + autoExtract: boolean; + autoRename4sf4sj: boolean; + keepGermanAudioOnly: boolean; + germanAudioMode: "tag" | "first"; + extractDir: string; + collectMkvToLibrary: boolean; + mkvLibraryDir: string; + createExtractSubfolder: boolean; + hybridExtract: boolean; + cleanupMode: CleanupMode; + extractConflictMode: ConflictMode; + removeLinkFilesAfterExtract: boolean; + removeSamplesAfterExtract: boolean; + enableIntegrityCheck: boolean; + autoResumeOnStart: boolean; + autoReconnect: boolean; + reconnectWaitSeconds: number; + completedCleanupPolicy: FinishedCleanupPolicy; + maxParallel: number; + maxParallelExtract: number; + retryLimit: number; + speedLimitEnabled: boolean; + speedLimitKbps: number; + speedLimitMode: SpeedMode; + updateRepo: string; + autoUpdateCheck: boolean; + clipboardWatch: boolean; + minimizeToTray: boolean; + theme: AppTheme; + collapseNewPackages: boolean; + historyRetentionMode: HistoryRetentionMode; + historyMaxEntries: number; + historyMaxAgeDays: number; + accountListShowDetailedDebridLinkKeys: boolean; + autoSortPackagesByProgress: boolean; + autoSkipExtracted: boolean; + hideExtractedItems: boolean; + confirmDeleteSelection: boolean; + backupIncludeDownloads: boolean; + backupIncludeRemoteDiagnostics: boolean; + notifyUrl: string; + notifyMention: string; + notifyOnPackageCompleted: boolean; + notifyOnPackageFailed: boolean; + notifyOnRunFinished: boolean; + totalDownloadedAllTime: number; + totalCompletedFilesAllTime: number; + totalRuntimeAllTimeMs: number; + bandwidthSchedules: BandwidthScheduleEntry[]; + columnOrder: string[]; + extractCpuPriority: ExtractCpuPriority; + autoExtractWhenStopped: boolean; + disabledProviders: DebridProvider[]; + hosterRouting: Record; + providerDailyLimitBytes: Partial>; + providerDailyUsageBytes: Partial>; + providerTotalUsageBytes: Partial>; + debridLinkApiKeyDailyLimitBytes: Record; + debridLinkApiKeyDailyUsageBytes: Record; + debridLinkApiKeyTotalUsageBytes: Record; + megaDebridDisabledAccountIds: string[]; + megaDebridAccountDailyLimitBytes: Record; + megaDebridAccountDailyUsageBytes: Record; + megaDebridAccountTotalUsageBytes: Record; + debridAccountStatuses: Record; + providerDailyUsageDay: string; + scheduledStartEpochMs: number; +} + +export interface DownloadItem { + id: string; + packageId: string; + url: string; + provider: DebridProvider | null; + providerLabel?: string; + providerAccountId?: string; + providerAccountLabel?: string; + status: DownloadStatus; + retries: number; + speedBps: number; + downloadedBytes: number; + totalBytes: number | null; + progressPercent: number; + fileName: string; + targetPath: string; + resumable: boolean; + attempts: number; + lastError: string; + fullStatus: string; + createdAt: number; + updatedAt: number; + onlineStatus?: "online" | "offline" | "checking"; +} + +export interface AudioStripFileResult { + name: string; + action: string; + reason: string; + languages?: string; +} + +export interface AudioStripSummary { + at: number; + candidates: number; + remuxed: number; + keptSingle: number; + skippedNoGerman: number; + skippedNoTool: number; + failed: number; + files: AudioStripFileResult[]; +} + +export interface PackageEntry { + id: string; + name: string; + outputDir: string; + extractDir: string; + status: DownloadStatus; + itemIds: string[]; + cancelled: boolean; + enabled: boolean; + priority?: PackagePriority; + postProcessLabel?: string; + audioStripSummary?: AudioStripSummary; + downloadStartedAt?: number; + downloadCompletedAt?: number; + createdAt: number; + updatedAt: number; +} + +export interface SessionState { + version: number; + packageOrder: string[]; + packages: Record; + items: Record; + runStartedAt: number; + totalDownloadedBytes: number; + summaryText: string; + reconnectUntil: number; + reconnectReason: string; + paused: boolean; + running: boolean; + updatedAt: number; +} + +export interface DownloadSummary { + total: number; + success: number; + failed: number; + cancelled: number; + extracted: number; + durationSeconds: number; + averageSpeedBps: number; +} + +export interface ParsedPackageInput { + name: string; + links: string[]; + fileNames?: string[]; +} + +export interface ContainerImportResult { + packages: ParsedPackageInput[]; + source: "dlc"; +} + +export interface RotationEvent { + id: string; + at: number; + level: "INFO" | "WARN" | "ERROR"; + provider: string; + accountLabel: string; + event: string; + reason?: string; + category?: string; + cooldownSec?: number; + next?: string; +} + +export interface UiSnapshot { + settings: AppSettings; + session: SessionState; + summary: DownloadSummary | null; + stats: DownloadStats; + speedText: string; + etaText: string; + canStart: boolean; + canStop: boolean; + canPause: boolean; + clipboardActive: boolean; + reconnectSeconds: number; + packageSpeedBps: Record; + payloadKind?: "full" | "delta"; + removedItemIds?: string[]; + removedPackageIds?: string[]; + rotationEvents?: RotationEvent[]; +} + +export interface AddLinksPayload { + rawText: string; + packageName?: string; + duplicatePolicy?: DuplicatePolicy; +} + +export interface AddContainerPayload { + filePaths: string[]; +} + +export type DuplicatePolicy = "keep" | "skip" | "overwrite"; + +export interface QueueAddResult { + addedPackages: number; + addedLinks: number; + skippedExistingPackages: string[]; + overwrittenPackages: string[]; +} + +export interface ContainerConflictResult { + conflicts: string[]; + packageCount: number; + linkCount: number; +} + +export interface StartConflictEntry { + packageId: string; + packageName: string; + extractDir: string; +} + +export interface StartConflictResolutionResult { + skipped: boolean; + overwritten: boolean; +} + +export interface UpdateCheckResult { + updateAvailable: boolean; + currentVersion: string; + latestVersion: string; + latestTag: string; + releaseUrl: string; + setupAssetUrl?: string; + setupAssetName?: string; + setupAssetDigest?: string; + releaseNotes?: string; + error?: string; +} + +export interface UpdateInstallResult { + started: boolean; + message: string; +} + +export interface UpdateInstallProgress { + stage: "starting" | "downloading" | "verifying" | "launching" | "done" | "error"; + percent: number | null; + downloadedBytes: number; + totalBytes: number | null; + message: string; +} + +export type AllDebridHostState = "up" | "down" | "not_tracked" | "unknown"; +export type AllDebridHostInfoSource = "api" | "web"; +export type DebridLinkHostState = "up" | "down" | "unknown"; +export type DebridLinkKeyState = "ready" | "cooldown" | "invalid" | "quota" | "rate_limit" | "error" | "unknown"; + +export interface AllDebridHostInfo { + host: string; + source: AllDebridHostInfoSource; + state: AllDebridHostState; + statusLabel: string; + fetchedAt: number; + lastCheckedAt: number | null; + quota: number | null; + quotaMax: number | null; + quotaType: string; + limitSimuDl: number | null; + note: string; +} + +export interface DebridLinkHostLimitInfo { + keyId: string; + keyLabel: string; + host: string; + fetchedAt: number; + trafficCurrentBytes: number | null; + trafficMaxBytes: number | null; + linksCurrent: number | null; + linksMax: number | null; + note: string; + state: DebridLinkKeyState; + stateLabel: string; + stateDetail: string; + cooldownUntil: number | null; + cooldownRemainingMs: number; + lastCheckedAt: number | null; + hostState: DebridLinkHostState; + hostStateLabel: string; + hostNote: string; +} + +export interface ParsedHashEntry { + fileName: string; + algorithm: "crc32" | "md5" | "sha1"; + digest: string; +} + +export interface BandwidthSample { + timestamp: number; + speedBps: number; +} + +export interface BandwidthStats { + samples: BandwidthSample[]; + currentSpeedBps: number; + averageSpeedBps: number; + maxSpeedBps: number; + totalBytesSession: number; + sessionDurationSeconds: number; +} + +export interface SessionStats { + bandwidth: BandwidthStats; + totalDownloads: number; + completedDownloads: number; + failedDownloads: number; + activeDownloads: number; + queuedDownloads: number; +} + +export interface SupportTraceConfig { + enabled: boolean; + includeMainLog: boolean; + includeAudit: boolean; + logDebugRequests: boolean; + autoDisableAt: string | null; + updatedAt: string; +} + +export interface SupportFileSizeInfo { + path: string | null; + exists: boolean; + bytes: number; +} + +export interface SupportDirectorySizeInfo { + path: string; + exists: boolean; + fileCount: number; + bytes: number; +} + +export interface SupportDiskSpaceInfo { + path: string; + totalBytes: number | null; + freeBytes: number | null; + freePercent: number | null; +} + +export interface SupportBundleEstimate { + estimatedBytes: number; + estimatedEntries: number; + duplicatedLiveLogBytes: number; + note: string; +} + +export interface DebugSetupCheckResult { + status: "ok" | "warn"; + enabled: boolean; + runtimeBaseDir: string; + host: string; + port: number; + localOnly: boolean; + tokenConfigured: boolean; + tokenPath: string; + supportManifestPath: string; + supportManifestPresent: boolean; + traceConfigPath: string | null; + traceLogPath: string | null; + traceEnabled: boolean; + traceAutoDisableAt: string | null; + diskSpace: { + runtime: SupportDiskSpaceInfo; + output: SupportDiskSpaceInfo; + extract: SupportDiskSpaceInfo; + }; + logSummary: { + totalBytes: number; + main: SupportFileSizeInfo; + mainBackup: SupportFileSizeInfo; + audit: SupportFileSizeInfo; + auditBackup: SupportFileSizeInfo; + rename: SupportFileSizeInfo; + renameBackup: SupportFileSizeInfo; + session: SupportFileSizeInfo; + trace: SupportFileSizeInfo; + traceBackup: SupportFileSizeInfo; + sessionLogs: SupportDirectorySizeInfo; + packageLogs: SupportDirectorySizeInfo; + itemLogs: SupportDirectorySizeInfo; + }; + supportBundle: SupportBundleEstimate; + warnings: string[]; + notes: string[]; + localUrls: { + health: string; + meta: string; + diagnostics: string; + }; + remoteUrlTemplates: { + health: string; + meta: string; + diagnostics: string; + }; +} + +export interface HistoryEntry { + id: string; + name: string; + totalBytes: number; + downloadedBytes: number; + fileCount: number; + provider: DebridProvider | null; + completedAt: number; + durationSeconds: number; + status: "completed" | "deleted"; + outputDir: string; + urls?: string[]; +} + +export interface HistoryState { + entries: HistoryEntry[]; + maxEntries: number; +} + +export interface RendererErrorReport { + kind: "error" | "unhandledrejection" | "react"; + message: string; + stack?: string; + source?: string; + line?: number; + column?: number; + componentStack?: string; +} + +export interface RemoteDiagnosticsStatus { + running: boolean; + host: string; + port: number; + hasToken: boolean; + localOnly: boolean; + allowlistCount: number; +} + +export interface RemoteDiagnosticsInfo { + status: RemoteDiagnosticsStatus; + code: string | null; + publicHost: string; + name: string; + allowlist: string[]; + suggestedHosts: string[]; +} + +export interface EnableRemoteDiagnosticsInput { + hostMode: "local" | "network"; + publicHost: string; + port?: number; + allowlist: string[]; + name?: string; + rotateToken?: boolean; +} diff --git a/tests/account-check.test.ts b/tests/account-check.test.ts new file mode 100644 index 0000000..441a51c --- /dev/null +++ b/tests/account-check.test.ts @@ -0,0 +1,161 @@ +import { describe, it, expect, vi, afterEach } from "vitest"; +import { checkMegaDebridAccount, checkDebridLinkKey, checkAllDebridAccounts } from "../src/main/account-check"; +import type { MegaDebridAccountEntry } from "../src/shared/mega-debrid-accounts"; +import type { DebridLinkApiKeyEntry } from "../src/shared/debrid-link-keys"; +import type { AppSettings } from "../src/shared/types"; + +function megaAccount(login = "user@example.com"): MegaDebridAccountEntry { + return { id: "mda_test", login, password: "pw", index: 0, label: "Account 1", maskedLogin: "us**le" }; +} + +function debridLinkKey(token = "tok_abcdef"): DebridLinkApiKeyEntry { + return { id: "dlk_test", token, index: 0, label: "Key 1", masked: "tok***def" }; +} + +function mockFetchOnce(status: number, body: unknown): void { + const text = typeof body === "string" ? body : JSON.stringify(body); + vi.stubGlobal("fetch", vi.fn(async () => ({ + ok: status >= 200 && status < 300, + status, + text: async () => text + })) as unknown as typeof fetch); +} + +const NOW = 1_700_000_000_000; + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +describe("checkMegaDebridAccount", () => { + it("reports valid + premium from vip_end (future Unix ts)", async () => { + const futureSec = Math.floor(NOW / 1000) + 30 * 24 * 60 * 60; + mockFetchOnce(200, { response_code: "ok", response_text: "User logged", token: "t", vip_end: String(futureSec), email: "a@b.de" }); + const st = await checkMegaDebridAccount(megaAccount(), undefined, NOW); + expect(st.valid).toBe(true); + expect(st.isPremium).toBe(true); + expect(st.premiumUntilMs).toBe(futureSec * 1000); + expect(st.email).toBe("a@b.de"); + expect(st.message).toMatch(/Premium noch/); + }); + + it("reports valid but NOT premium when vip_end is in the past", async () => { + const pastSec = Math.floor(NOW / 1000) - 1000; + mockFetchOnce(200, { response_code: "ok", token: "t", vip_end: String(pastSec) }); + const st = await checkMegaDebridAccount(megaAccount(), undefined, NOW); + expect(st.valid).toBe(true); + expect(st.isPremium).toBe(false); + }); + + it("reports valid but no premium when vip_end is 0/missing", async () => { + mockFetchOnce(200, { response_code: "ok", token: "t", vip_end: "0" }); + const st = await checkMegaDebridAccount(megaAccount(), undefined, NOW); + expect(st.valid).toBe(true); + expect(st.isPremium).toBe(false); + expect(st.premiumUntilMs).toBe(0); + expect(st.message).toMatch(/Kein Premium/); + }); + + it("reports invalid login when response_code != ok", async () => { + mockFetchOnce(200, { response_code: "error", response_text: "bad login" }); + const st = await checkMegaDebridAccount(megaAccount(), undefined, NOW); + expect(st.valid).toBe(false); + expect(st.isPremium).toBe(false); + expect(st.message).toMatch(/Ungueltiger Login/); + }); + + it("reports invalid on HTTP error", async () => { + mockFetchOnce(500, "server error"); + const st = await checkMegaDebridAccount(megaAccount(), undefined, NOW); + expect(st.valid).toBe(false); + }); + + it("never throws on network error — returns a failed status", async () => { + vi.stubGlobal("fetch", vi.fn(async () => { throw new Error("ECONNRESET"); }) as unknown as typeof fetch); + const st = await checkMegaDebridAccount(megaAccount(), undefined, NOW); + expect(st.valid).toBe(false); + expect(st.message).toMatch(/Pruefung fehlgeschlagen/); + }); +}); + +describe("checkDebridLinkKey", () => { + it("reports valid + premium from premiumLeft seconds", async () => { + const premiumLeft = 60 * 24 * 60 * 60; + mockFetchOnce(200, { success: true, value: { username: "u", accountType: 1, premiumLeft } }); + const st = await checkDebridLinkKey(debridLinkKey(), undefined, NOW); + expect(st.valid).toBe(true); + expect(st.isPremium).toBe(true); + expect(st.premiumUntilMs).toBe(NOW + premiumLeft * 1000); + }); + + it("reports valid but free (premiumLeft 0, accountType 0)", async () => { + mockFetchOnce(200, { success: true, value: { username: "u", accountType: 0, premiumLeft: 0 } }); + const st = await checkDebridLinkKey(debridLinkKey(), undefined, NOW); + expect(st.valid).toBe(true); + expect(st.isPremium).toBe(false); + expect(st.message).toMatch(/Free/); + }); + + it("reports invalid key on HTTP 401", async () => { + mockFetchOnce(401, { success: false, error: "badToken" }); + const st = await checkDebridLinkKey(debridLinkKey(), undefined, NOW); + expect(st.valid).toBe(false); + expect(st.message).toMatch(/Ungueltiger API-Key/); + }); + + it("reports invalid key when success=false", async () => { + mockFetchOnce(200, { success: false, error: "badToken" }); + const st = await checkDebridLinkKey(debridLinkKey(), undefined, NOW); + expect(st.valid).toBe(false); + }); +}); + +describe("checkAllDebridAccounts", () => { + it("returns empty array when nothing configured", async () => { + const settings = { megaCredentials: "", megaPassword: "", debridLinkApiKeys: "" } as unknown as AppSettings; + const result = await checkAllDebridAccounts(settings); + expect(result).toEqual([]); + }); + + it("checks every configured mega account + debrid-link key", async () => { + const futureSec = Math.floor(Date.now() / 1000) + 1000; + vi.stubGlobal("fetch", vi.fn(async (url: string) => { + if (String(url).includes("mega-debrid")) { + return { ok: true, status: 200, text: async () => JSON.stringify({ response_code: "ok", token: "t", vip_end: String(futureSec) }) }; + } + return { ok: true, status: 200, text: async () => JSON.stringify({ success: true, value: { accountType: 1, premiumLeft: 1000 } }) }; + }) as unknown as typeof fetch); + + const settings = { + megaCredentials: "a@b.de:pw1\nc@d.de:pw2", + megaPassword: "", + debridLinkApiKeys: "key1\nkey2\nkey3" + } as unknown as AppSettings; + + const result = await checkAllDebridAccounts(settings); + expect(result).toHaveLength(5); + expect(result.filter((r) => r.provider === "megadebrid")).toHaveLength(2); + expect(result.filter((r) => r.provider === "debridlink")).toHaveLength(3); + expect(result.every((r) => r.valid)).toBe(true); + }); + + it("caps concurrency (never more than 4 in flight) and preserves result order", async () => { + let inFlight = 0; + let maxInFlight = 0; + vi.stubGlobal("fetch", vi.fn(async () => { + inFlight += 1; + maxInFlight = Math.max(maxInFlight, inFlight); + await new Promise((resolve) => setTimeout(resolve, 5)); + inFlight -= 1; + return { ok: true, status: 200, text: async () => JSON.stringify({ success: true, value: { accountType: 1, premiumLeft: 1000 } }) }; + }) as unknown as typeof fetch); + + const keys = Array.from({ length: 9 }, (_, i) => `key_${i}`).join("\n"); + const settings = { megaCredentials: "", megaPassword: "", debridLinkApiKeys: keys } as unknown as AppSettings; + + const result = await checkAllDebridAccounts(settings); + expect(result).toHaveLength(9); + expect(maxInFlight).toBeLessThanOrEqual(4); + result.forEach((r, i) => expect(r.label).toBe(`Key ${i + 1}`)); + }); +}); diff --git a/tests/account-dialog-prefer-api.test.ts b/tests/account-dialog-prefer-api.test.ts new file mode 100644 index 0000000..f4a6302 --- /dev/null +++ b/tests/account-dialog-prefer-api.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from "vitest"; +import { applyAccountDialogToSettings, AccountDialogState } from "../src/renderer/App"; +import { defaultSettings } from "../src/main/constants"; + +function megaDialog(kind: "megadebrid-api" | "megadebrid-web"): AccountDialogState { + return { + mode: "edit", + kind, + token: "", + login: "", + password: "", + dailyLimitGb: "", + keyDailyLimitGbById: {}, + megaAccounts: [{ login: "user@x", password: "pw" }], + megaNewLogin: "", + megaNewPassword: "", + megaDisabledIds: [] + }; +} + +describe("applyAccountDialogToSettings — keeps the user's Mega preferApi choice", () => { + it("does not flip megaDebridPreferApi to true when editing the API account", () => { + const settings = { ...defaultSettings(), megaDebridApiEnabled: true, megaDebridWebEnabled: true, megaDebridPreferApi: false }; + const next = applyAccountDialogToSettings(settings, megaDialog("megadebrid-api")); + expect(next.megaDebridApiEnabled).toBe(true); + expect(next.megaDebridPreferApi).toBe(false); + }); + + it("does not flip megaDebridPreferApi to false when editing the Web account", () => { + const settings = { ...defaultSettings(), megaDebridApiEnabled: true, megaDebridWebEnabled: true, megaDebridPreferApi: true }; + const next = applyAccountDialogToSettings(settings, megaDialog("megadebrid-web")); + expect(next.megaDebridWebEnabled).toBe(true); + expect(next.megaDebridPreferApi).toBe(true); + }); +}); diff --git a/tests/account-rotation-log.test.ts b/tests/account-rotation-log.test.ts new file mode 100644 index 0000000..1f8d80a --- /dev/null +++ b/tests/account-rotation-log.test.ts @@ -0,0 +1,57 @@ +import { describe, it, expect } from "vitest"; +import { logAccountRotation, runWithRotationItemSink, getRecentRotationEvents } from "../src/main/account-rotation-log"; +import type { RotationEvent } from "../src/shared/types"; + +describe("rotation item-sink (AsyncLocalStorage)", () => { + it("routes the FULL rotation trail (incl. TEST) to the active item sink", async () => { + const captured: RotationEvent[] = []; + await runWithRotationItemSink((ev) => captured.push(ev), async () => { + logAccountRotation("INFO", "Mega-Debrid Web", "Account 1/3 (ab**xy)", "TEST", { link: "x" }); + logAccountRotation("WARN", "Mega-Debrid Web", "Account 1/3 (ab**xy)", "FAILED", { reason: "Timeout", cooldownSec: 30, next: "Account 2/3 (cd**zw)" }); + logAccountRotation("INFO", "Mega-Debrid Web", "Account 2/3 (cd**zw)", "TEST", { link: "x" }); + logAccountRotation("INFO", "Mega-Debrid Web", "Account 2/3 (cd**zw)", "OK", { fileName: "f.mkv" }); + await Promise.resolve(); + }); + + const events = captured.map((e) => e.event); + expect(events).toEqual(["TEST", "FAILED", "TEST", "OK"]); + const failed = captured.find((e) => e.event === "FAILED"); + expect(failed?.reason).toBe("Timeout"); + expect(failed?.next).toBe("Account 2/3 (cd**zw)"); + }); + + it("does not leak events to the sink outside the run() scope", () => { + const captured: RotationEvent[] = []; + logAccountRotation("INFO", "Debrid-Link", "Key 1/2 (k1)", "OK"); + expect(captured).toHaveLength(0); + }); + + it("isolates two parallel item sinks (no cross-attribution)", async () => { + const a: RotationEvent[] = []; + const b: RotationEvent[] = []; + await Promise.all([ + runWithRotationItemSink((ev) => a.push(ev), async () => { + logAccountRotation("INFO", "Mega-Debrid Web", "Account 1 (a)", "TEST"); + await new Promise((r) => setTimeout(r, 10)); + logAccountRotation("INFO", "Mega-Debrid Web", "Account 1 (a)", "OK"); + }), + runWithRotationItemSink((ev) => b.push(ev), async () => { + logAccountRotation("INFO", "Debrid-Link", "Key 1 (b)", "TEST"); + await new Promise((r) => setTimeout(r, 5)); + logAccountRotation("WARN", "Debrid-Link", "Key 1 (b)", "FAILED", { reason: "badToken" }); + }) + ]); + expect(a.every((e) => e.provider === "Mega-Debrid Web")).toBe(true); + expect(b.every((e) => e.provider === "Debrid-Link")).toBe(true); + expect(a.map((e) => e.event)).toEqual(["TEST", "OK"]); + expect(b.map((e) => e.event)).toEqual(["TEST", "FAILED"]); + }); + + it("still feeds the global UI ring (outcomes only, TEST filtered)", () => { + logAccountRotation("INFO", "Mega-Debrid API", "Account 9 (zz)", "TEST"); + logAccountRotation("INFO", "Mega-Debrid API", "Account 9 (zz)", "OK", { fileName: "ring.mkv" }); + const ring = getRecentRotationEvents(10); + expect(ring.some((e) => e.event === "OK" && e.accountLabel === "Account 9 (zz)")).toBe(true); + expect(ring.some((e) => e.event === "TEST" && e.accountLabel === "Account 9 (zz)")).toBe(false); + }); +}); diff --git a/tests/app-order.test.ts b/tests/app-order.test.ts new file mode 100644 index 0000000..c015027 --- /dev/null +++ b/tests/app-order.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it } from "vitest"; +import { reorderPackageOrderByDrop, sortPackageOrderByName } from "../src/renderer/package-order"; + +describe("reorderPackageOrderByDrop", () => { + it("moves adjacent package down by one on drop", () => { + const next = reorderPackageOrderByDrop(["a", "b", "c"], "b", "c"); + expect(next).toEqual(["a", "c", "b"]); + }); + + it("moves package after lower drop target", () => { + const next = reorderPackageOrderByDrop(["a", "b", "c", "d"], "a", "c"); + expect(next).toEqual(["b", "c", "a", "d"]); + }); + + it("returns original order when ids are invalid", () => { + const order = ["a", "b", "c"]; + expect(reorderPackageOrderByDrop(order, "x", "b")).toEqual(order); + expect(reorderPackageOrderByDrop(order, "a", "x")).toEqual(order); + expect(reorderPackageOrderByDrop(order, "a", "a")).toEqual(order); + }); +}); + +describe("sortPackageOrderByName", () => { + it("sorts package IDs alphabetically ascending", () => { + const sorted = sortPackageOrderByName( + ["pkg3", "pkg1", "pkg2"], + { + pkg1: { id: "pkg1", name: "Alpha", outputDir: "", extractDir: "", status: "queued", itemIds: [], cancelled: false, enabled: true, priority: "normal", createdAt: 0, updatedAt: 0 }, + pkg2: { id: "pkg2", name: "beta", outputDir: "", extractDir: "", status: "queued", itemIds: [], cancelled: false, enabled: true, priority: "normal", createdAt: 0, updatedAt: 0 }, + pkg3: { id: "pkg3", name: "Gamma", outputDir: "", extractDir: "", status: "queued", itemIds: [], cancelled: false, enabled: true, priority: "normal", createdAt: 0, updatedAt: 0 } + }, + false + ); + expect(sorted).toEqual(["pkg1", "pkg2", "pkg3"]); + }); + + it("sorts package IDs alphabetically descending", () => { + const sorted = sortPackageOrderByName( + ["pkg1", "pkg2", "pkg3"], + { + pkg1: { id: "pkg1", name: "Alpha", outputDir: "", extractDir: "", status: "queued", itemIds: [], cancelled: false, enabled: true, priority: "normal", createdAt: 0, updatedAt: 0 }, + pkg2: { id: "pkg2", name: "beta", outputDir: "", extractDir: "", status: "queued", itemIds: [], cancelled: false, enabled: true, priority: "normal", createdAt: 0, updatedAt: 0 }, + pkg3: { id: "pkg3", name: "Gamma", outputDir: "", extractDir: "", status: "queued", itemIds: [], cancelled: false, enabled: true, priority: "normal", createdAt: 0, updatedAt: 0 } + }, + true + ); + expect(sorted).toEqual(["pkg3", "pkg2", "pkg1"]); + }); +}); diff --git a/tests/audit-log.test.ts b/tests/audit-log.test.ts new file mode 100644 index 0000000..5ca39c7 --- /dev/null +++ b/tests/audit-log.test.ts @@ -0,0 +1,48 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { getAuditLogPath, initAuditLog, logAuditEvent, shutdownAuditLog } from "../src/main/audit-log"; + +const tempDirs: string[] = []; + +afterEach(() => { + shutdownAuditLog(); + for (const dir of tempDirs.splice(0)) { + fs.rmSync(dir, { recursive: true, force: true }); + } +}); + +describe("audit-log", () => { + it("writes audit events to the audit log", () => { + const baseDir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-alog-")); + tempDirs.push(baseDir); + + initAuditLog(baseDir); + logAuditEvent("INFO", "Settings changed", { changedKeys: ["token", "autoExtract"] }); + + const logPath = getAuditLogPath(); + expect(logPath).not.toBeNull(); + expect(fs.existsSync(logPath!)).toBe(true); + const content = fs.readFileSync(logPath!, "utf8"); + expect(content).toContain("Audit-Log Start"); + expect(content).toContain("Settings changed"); + expect(content).toContain("changedKeys"); + }); + + it("rotates oversized audit logs on startup", () => { + const baseDir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-alog-rotate-")); + tempDirs.push(baseDir); + + const oversizedPath = path.join(baseDir, "audit.log"); + fs.mkdirSync(baseDir, { recursive: true }); + fs.writeFileSync(oversizedPath, "x".repeat(10 * 1024 * 1024 + 256), "utf8"); + + initAuditLog(baseDir); + + expect(fs.existsSync(oversizedPath)).toBe(true); + expect(fs.existsSync(`${oversizedPath}.old`)).toBe(true); + const content = fs.readFileSync(oversizedPath, "utf8"); + expect(content).toContain("Audit-Log Start"); + }); +}); diff --git a/tests/auto-rename.test.ts b/tests/auto-rename.test.ts new file mode 100644 index 0000000..2c974ed --- /dev/null +++ b/tests/auto-rename.test.ts @@ -0,0 +1,1089 @@ +import { describe, it, expect } from "vitest"; +import { + extractEpisodeToken, + applyEpisodeTokenToFolderName, + sourceHasRpToken, + ensureRepackToken, + buildAutoRenameBaseName, + buildAutoRenameBaseNameFromFolders, + buildAutoRenameBaseNameFromFoldersWithOptions, + hasMeaningfulSeriesPrefix, + looksLikeObfuscatedSceneFileName, + decideAutoRenameBaseName, + isBonusContent +} from "../src/main/download-manager"; + +describe("decideAutoRenameBaseName (shared naming decision — used by auto-rename AND mkv-collect)", () => { + + it("derives the clean name for a Herzflimmern episode from the per-episode folder (S07E12 — the reported failure)", () => { + const source = "tvarchiv.herzflimmern.die.klinik.am.see.s07e12-720.mkv"; + const folders = [ + "Herzflimmern.Die.Klinik.am.See.S07E12.German.720p.Webrip.x264-TVARCHiV", + "Herzflimmern.Die.Klinik.am.See.S07.German.720p.Webrip.x264-TVARCHiV" + ]; + const decision = decideAutoRenameBaseName( + folders, + source, + "tvarchiv.herzflimmern.die.klinik.am.see.s07e12-720", + folders[0], + folders[1] + ); + expect(decision.kind).toBe("rename"); + expect(decision.kind === "rename" && decision.baseName).toBe("Herzflimmern.Die.Klinik.am.See.S07E12.German.720p.Webrip.x264-TVARCHiV"); + }); + + it("derives the clean name from a SEASON-only folder by injecting the source episode token (Herzflimmern S03E14)", () => { + const source = "tvarchiv.herzflimmern.die.klinik.am.see.s03e14-720.mkv"; + const seasonFolder = "Herzflimmern.die.Klinik.am.See.S03.German.720p.Webrip.x264-TVARCHiV"; + const decision = decideAutoRenameBaseName( + [seasonFolder], + source, + "tvarchiv.herzflimmern.die.klinik.am.see.s03e14-720", + seasonFolder, + seasonFolder + ); + expect(decision.kind).toBe("rename"); + expect(decision.kind === "rename" && decision.baseName).toBe("Herzflimmern.die.Klinik.am.See.S03E14.German.720p.Webrip.x264-TVARCHiV"); + }); + + it("derives the clean name for the Fritzie S04 files that sat raw in Downloader Unfertig (4sf- scene group, season folder)", () => { + const source = "4sf-fritzie.himmel.muss.warten.web.7p-s04e01.mkv"; + const seasonFolder = "Fritzie.-.Der.Himmel.muss.warten.S04.GERMAN.720p.WEB.AVC-4SF"; + const decision = decideAutoRenameBaseName( + [seasonFolder], + source, + "4sf-fritzie.himmel.muss.warten.web.7p-s04e01", + seasonFolder, + seasonFolder + ); + expect(decision.kind).toBe("rename"); + expect(decision.kind === "rename" && decision.baseName).toBe("Fritzie.-.Der.Himmel.muss.warten.S04E01.GERMAN.720p.WEB.AVC-4SF"); + }); + + it("is idempotent: an already-clean file in its clean folder derives to the same name (no worse-than-now)", () => { + const clean = "Herzflimmern.Die.Klinik.am.See.S07E02.German.720p.Webrip.x264-TVARCHiV"; + const decision = decideAutoRenameBaseName( + [clean, "Herzflimmern.Die.Klinik.am.See.S07.German.720p.Webrip.x264-TVARCHiV"], + `${clean}.mkv`, + clean, + clean, + "Herzflimmern.Die.Klinik.am.See.S07.German.720p.Webrip.x264-TVARCHiV" + ); + expect(decision.kind).toBe("rename"); + expect(decision.kind === "rename" && decision.baseName).toBe(clean); + }); + + it("GUARD: lets the parent folder token override an OBFUSCATED source filename (anti-piracy scramble)", () => { + const decision = decideAutoRenameBaseName( + ["Die.Thundermans.S02E01.Der.Thunder.Van.GERMAN.x264-aWake"], + "awa-diethundermans02e16hd.mkv", + "awa-diethundermans02e16hd", + "Die.Thundermans.S02E01.Der.Thunder.Van.GERMAN.x264-aWake", + "Die.Thundermans.S02.GERMAN.x264-aWake" + ); + expect(decision.kind).toBe("rename"); + expect(decision.kind === "rename" && decision.baseName).toContain("S02E01"); + }); + + it("GUARD: a CLEAN scene source is NEVER overridden by a mismatching folder token (folder is wrong, not the file)", () => { + const decision = decideAutoRenameBaseName( + ["The.Royals.2015.S01E08.German.DL.720p.BluRay.x264-iNTENTiON"], + "the.royals.2015.s01e09.german.dl.720p.bluray.x264-j4f.mkv", + "the.royals.2015.s01e09.german.dl.720p.bluray.x264-j4f", + "The.Royals.2015.S01E08.German.DL.720p.BluRay.x264-iNTENTiON", + "The.Royals.2015.S01.German.DL.720p.BluRay.x264-iNTENTiON" + ); + expect(decision.kind).toBe("skip"); + expect(decision.kind === "skip" && decision.reason).toBe("token-mismatch"); + }); + + it("skips (no-target) when no folder candidate yields a usable scene name", () => { + const decision = decideAutoRenameBaseName( + ["random user folder", "another plain dir"], + "some.file.mkv", + "some.file", + "random user folder", + "another plain dir" + ); + expect(decision.kind).toBe("skip"); + }); + + it("uses the CLEAN per-episode folder (scene group WITH underscore, e.g. -idTV_iNT) — not the obfuscated package folder", () => { + const epFolder = "Castle.S08E02.GERMAN.DL.720p.WEB.H264-idTV_iNT"; + const decision = decideAutoRenameBaseName( + [epFolder, "scn2-cstl7"], + "castle.s08e02.german.dl.720p.web.h264-idtv_int.mkv", + "castle.s08e02.german.dl.720p.web.h264-idtv_int", + epFolder, + "scn2-cstl7" + ); + expect(decision.kind).toBe("rename"); + expect(decision.kind === "rename" && decision.baseName).toBe(epFolder); + }); + + it("uses the complete per-episode folder when the SOURCE has no SxxExx token (bare 'Folge 01' format)", () => { + const folders = [ + "Kreuzfahrt.ins.Glueck.01.Hochzeitsreise.nach.Burma.2007.German.720p.HDTV.x264-BET", + "kig.hdtv.7p-001", + "Kreuzfahrt ins Glück S01" + ]; + const decision = decideAutoRenameBaseName(folders, "bet_kig_01_hdt.mkv", "bet_kig_01_hdt", folders[0], folders[2]); + expect(decision.kind).toBe("rename"); + expect(decision.kind === "rename" && decision.baseName).toBe(folders[0]); + }); + + it("complete-folder fallback fires on CODEC alone (no resolution token — DVDRip/XviD class)", () => { + const folders = [ + "Kreuzfahrt.ins.Glueck.01.Hochzeitsreise.nach.Burma.2007.German.DVDRip.x264-BET", + "Kreuzfahrt ins Glück S01" + ]; + const decision = decideAutoRenameBaseName(folders, "bet_kig_01.mkv", "bet_kig_01", folders[0], folders[1]); + expect(decision.kind).toBe("rename"); + expect(decision.kind === "rename" && decision.baseName).toBe(folders[0]); + }); + + it("complete-folder fallback does NOT fire when the source HAS an episode token (generic pack stays no-target)", () => { + const decision = decideAutoRenameBaseName( + ["Mega-Direct-Pack"], + "Direct.Show.S01E01.German.1080p.WEB.x264-DIRECT.mkv", + "Direct.Show.S01E01.German.1080p.WEB.x264-DIRECT", + "Mega-Direct-Pack", + "Mega-Direct-Pack" + ); + expect(decision.kind).toBe("skip"); + }); +}); + +describe("hasMeaningfulSeriesPrefix", () => { + it("recognizes a real series name before the season token", () => { + expect(hasMeaningfulSeriesPrefix("Desperate.Housewives.S01.Synced.DL.720p.WEB-DL.AC3.h264")).toBe(true); + expect(hasMeaningfulSeriesPrefix("Die.Thundermans.S02E06.Tickets.und.Shreddy.GERMAN.WS.720p.HDTV.x264-aWake")).toBe(true); + expect(hasMeaningfulSeriesPrefix("Mistresses.2013.S02.GERMAN.DL.720p.WEB.x264-TSCC")).toBe(true); + expect(hasMeaningfulSeriesPrefix("show.name.s01e01.720p")).toBe(true); + }); + + it("rejects generic season-label folders without a series name", () => { + expect(hasMeaningfulSeriesPrefix("S01 Complete")).toBe(false); + expect(hasMeaningfulSeriesPrefix("S02")).toBe(false); + expect(hasMeaningfulSeriesPrefix("S01E01 Complete")).toBe(false); + expect(hasMeaningfulSeriesPrefix(".S01.bla")).toBe(false); + }); + + it("returns false when there is no season token at all", () => { + expect(hasMeaningfulSeriesPrefix("Some Random Folder")).toBe(false); + expect(hasMeaningfulSeriesPrefix("")).toBe(false); + }); +}); + +describe("looksLikeObfuscatedSceneFileName", () => { + it("flags hoster-obfuscated names with no scene markers as obfuscated", () => { + expect(looksLikeObfuscatedSceneFileName("awa-diethundermans02e16hd.mkv")).toBe(true); + expect(looksLikeObfuscatedSceneFileName("scn-dthund7-S02E06.mkv")).toBe(true); + expect(looksLikeObfuscatedSceneFileName("4sj-blue-bloods-s08e21-720p.mkv")).toBe(true); + }); + + it("treats clean scene releases with multiple markers as NOT obfuscated", () => { + expect(looksLikeObfuscatedSceneFileName("the.royals.2015.s01e09.german.dl.720p.bluray.x264-j4f.mkv")).toBe(false); + expect(looksLikeObfuscatedSceneFileName("Die.Thundermans.S02E06.Tickets.und.Shreddy.GERMAN.WS.720p.HDTV.x264-aWake.mkv")).toBe(false); + expect(looksLikeObfuscatedSceneFileName("Desperate.Housewives.S01E01.German.Synced.DL.720p.WEB-DL.AC3.h264.mkv")).toBe(false); + }); + + it("handles edge cases (empty, very short)", () => { + expect(looksLikeObfuscatedSceneFileName("")).toBe(true); + expect(looksLikeObfuscatedSceneFileName("a.mkv")).toBe(true); + }); + + it("treats long dotted names as scene-style even with few markers", () => { + expect(looksLikeObfuscatedSceneFileName("Some.Show.With.Many.Tokens.S01E01.mkv")).toBe(false); + }); +}); + +describe("extractEpisodeToken (extended formats)", () => { + it("recognizes the older xX format (capped at 2 episode digits)", () => { + expect(extractEpisodeToken("show.1x01.720p.mkv")).toBe("S01E01"); + expect(extractEpisodeToken("show-2x05-hdtv.mkv")).toBe("S02E05"); + expect(extractEpisodeToken("Show.Name.10x99.mkv")).toBe("S10E99"); + expect(extractEpisodeToken("Show.Name.10x100.mkv")).toBeNull(); + expect(extractEpisodeToken("Show.Name.S10E100.mkv")).toBe("S10E100"); + }); + + it("does not falsely match resolution tokens like 1080x720", () => { + expect(extractEpisodeToken("show.1080p.mkv")).toBeNull(); + expect(extractEpisodeToken("show.S01E01.1080p.mkv")).toBe("S01E01"); + }); + + it("does not falsely match codec tokens like x264 / x265 (caps episode digits)", () => { + expect(extractEpisodeToken("Movie.x264-GROUP.mkv")).toBeNull(); + expect(extractEpisodeToken("Movie.5x265.x265.mkv")).toBeNull(); + expect(extractEpisodeToken("Show.S01E01.x265-GROUP.mkv")).toBe("S01E01"); + }); + + it("does not falsely match common aspect ratios like 1920x1080", () => { + expect(extractEpisodeToken("Movie.1920x1080.mkv")).toBeNull(); + }); +}); + +describe("extractEpisodeToken", () => { + it("extracts S01E01 from standard scene format", () => { + expect(extractEpisodeToken("show.name.s01e01.720p")).toBe("S01E01"); + }); + + it("extracts episode with dot separators", () => { + expect(extractEpisodeToken("Show.S02E15.1080p")).toBe("S02E15"); + }); + + it("extracts episode with dash separators", () => { + expect(extractEpisodeToken("show-s3e5-720p")).toBe("S03E05"); + }); + + it("extracts episode with underscore separators", () => { + expect(extractEpisodeToken("show_s10e100_hdtv")).toBe("S10E100"); + }); + + it("extracts episode with space separators", () => { + expect(extractEpisodeToken("Show Name s1e2 720p")).toBe("S01E02"); + }); + + it("pads single-digit season and episode to 2 digits", () => { + expect(extractEpisodeToken("show.s1e3.720p")).toBe("S01E03"); + }); + + it("handles 3-digit episode numbers", () => { + expect(extractEpisodeToken("show.s01e123")).toBe("S01E123"); + }); + + it("returns null for no episode token", () => { + expect(extractEpisodeToken("some.random.file.720p")).toBeNull(); + }); + + it("returns null for season-only pattern (no episode)", () => { + expect(extractEpisodeToken("show.s01.720p")).toBeNull(); + }); + + it("returns null for empty string", () => { + expect(extractEpisodeToken("")).toBeNull(); + }); + + it("is case-insensitive", () => { + expect(extractEpisodeToken("Show.S05E10.1080p")).toBe("S05E10"); + expect(extractEpisodeToken("show.s05e10.1080p")).toBe("S05E10"); + }); + + it("extracts from episode token at start of string", () => { + expect(extractEpisodeToken("s01e01.720p")).toBe("S01E01"); + }); + + it("extracts from episode token at end of string", () => { + expect(extractEpisodeToken("show.s02e03")).toBe("S02E03"); + }); + + it("extracts double episode token s01e01e02", () => { + expect(extractEpisodeToken("tvr-mammon-s01e01e02-720p")).toBe("S01E01E02"); + }); + + it("extracts double episode with dot separators", () => { + expect(extractEpisodeToken("Show.S01E03E04.720p")).toBe("S01E03E04"); + }); + + it("extracts double episode at end of string", () => { + expect(extractEpisodeToken("show.s02e05e06")).toBe("S02E05E06"); + }); + + it("extracts double episode with single-digit numbers", () => { + expect(extractEpisodeToken("show-s1e1e2-720p")).toBe("S01E01E02"); + }); + + it("extracts episode when title and season token are joined", () => { + expect(extractEpisodeToken("mdgp-carters02e01-720p")).toBe("S02E01"); + }); +}); + +describe("applyEpisodeTokenToFolderName", () => { + it("replaces existing episode token in folder name", () => { + expect(applyEpisodeTokenToFolderName("Show.S01E01.720p-4sf", "S02E05")).toBe("Show.S02E05.720p-4sf"); + }); + + it("replaces season-only token when no episode in folder", () => { + expect(applyEpisodeTokenToFolderName("Show.S01.720p-4sf", "S01E03")).toBe("Show.S01E03.720p-4sf"); + }); + + it("inserts before -4sf suffix when no season/episode in folder", () => { + expect(applyEpisodeTokenToFolderName("Show.720p-4sf", "S01E05")).toBe("Show.720p.S01E05-4sf"); + }); + + it("inserts before -4sj suffix", () => { + expect(applyEpisodeTokenToFolderName("Show.720p-4sj", "S01E05")).toBe("Show.720p.S01E05-4sj"); + }); + + it("appends episode token when no recognized pattern", () => { + expect(applyEpisodeTokenToFolderName("SomeFolder", "S01E01")).toBe("SomeFolder.S01E01"); + }); + + it("returns episode token when folder name is empty", () => { + expect(applyEpisodeTokenToFolderName("", "S01E01")).toBe("S01E01"); + }); + + it("handles folder with existing multi-digit episode", () => { + expect(applyEpisodeTokenToFolderName("Show.S01E99.720p-4sf", "S01E05")).toBe("Show.S01E05.720p-4sf"); + }); + + it("is case-insensitive for -4SF/-4SJ suffix", () => { + expect(applyEpisodeTokenToFolderName("Show.720p-4SF", "S01E01")).toBe("Show.720p.S01E01-4SF"); + }); + + it("applies double episode token to season-only folder", () => { + expect(applyEpisodeTokenToFolderName("Mammon.S01.German.1080P.Bluray.x264-SMAHD", "S01E01E02")) + .toBe("Mammon.S01E01E02.German.1080P.Bluray.x264-SMAHD"); + }); + + it("replaces existing double episode in folder with new token", () => { + expect(applyEpisodeTokenToFolderName("Show.S01E01E02.720p-4sf", "S01E03E04")) + .toBe("Show.S01E03E04.720p-4sf"); + }); + + it("replaces existing single episode in folder with double episode token", () => { + expect(applyEpisodeTokenToFolderName("Show.S01E01.720p-4sf", "S01E01E02")) + .toBe("Show.S01E01E02.720p-4sf"); + }); +}); + +describe("sourceHasRpToken", () => { + it("detects .rp. in filename", () => { + expect(sourceHasRpToken("show.s01e01.rp.720p")).toBe(true); + }); + + it("detects -rp- in filename", () => { + expect(sourceHasRpToken("show-s01e01-rp-720p")).toBe(true); + }); + + it("detects _rp_ in filename", () => { + expect(sourceHasRpToken("show_s01e01_rp_720p")).toBe(true); + }); + + it("detects rp at end of string", () => { + expect(sourceHasRpToken("show.s01e01.rp")).toBe(true); + }); + + it("does not match rp inside a word", () => { + expect(sourceHasRpToken("enterprise.s01e01")).toBe(false); + }); + + it("returns false for empty string", () => { + expect(sourceHasRpToken("")).toBe(false); + }); + + it("is case-insensitive", () => { + expect(sourceHasRpToken("show.RP.720p")).toBe(true); + }); +}); + +describe("ensureRepackToken", () => { + it("inserts REPACK before quality token", () => { + expect(ensureRepackToken("Show.S01E01.1080p-4sf")).toBe("Show.S01E01.REPACK.1080p-4sf"); + }); + + it("inserts REPACK before 720p", () => { + expect(ensureRepackToken("Show.S01E01.720p-4sf")).toBe("Show.S01E01.REPACK.720p-4sf"); + }); + + it("inserts REPACK before 2160p", () => { + expect(ensureRepackToken("Show.S01E01.2160p-4sf")).toBe("Show.S01E01.REPACK.2160p-4sf"); + }); + + it("inserts REPACK before -4sf when no quality token", () => { + expect(ensureRepackToken("Show.S01E01-4sf")).toBe("Show.S01E01.REPACK-4sf"); + }); + + it("inserts REPACK before -4sj when no quality token", () => { + expect(ensureRepackToken("Show.S01E01-4sj")).toBe("Show.S01E01.REPACK-4sj"); + }); + + it("appends REPACK when no recognized insertion point", () => { + expect(ensureRepackToken("Show.S01E01")).toBe("Show.S01E01.REPACK"); + }); + + it("does not double-add REPACK if already present", () => { + expect(ensureRepackToken("Show.S01E01.REPACK.1080p-4sf")).toBe("Show.S01E01.REPACK.1080p-4sf"); + }); + + it("does not double-add repack (case-insensitive)", () => { + expect(ensureRepackToken("Show.s01e01.repack.720p-4sf")).toBe("Show.s01e01.repack.720p-4sf"); + }); +}); + +describe("buildAutoRenameBaseName", () => { + it("renames with episode token from source file", () => { + const result = buildAutoRenameBaseName("Show.S01.720p-4sf", "show.s01e05.720p.mkv"); + expect(result).toBe("Show.S01E05.720p-4sf"); + }); + + it("works with -4sj suffix", () => { + const result = buildAutoRenameBaseName("Show.S01.720p-4sj", "show.s01e03.720p.mkv"); + expect(result).toBe("Show.S01E03.720p-4sj"); + }); + + it("renames generic scene folder with group suffix", () => { + const result = buildAutoRenameBaseName("Show.S01.720p-GROUP", "show.s01e05.720p.mkv"); + expect(result).toBe("Show.S01.720p-GROUP"); + }); + + it("returns null when source has no episode token", () => { + const result = buildAutoRenameBaseName("Show.S01.720p-4sf", "random.file.720p.mkv"); + expect(result).toBeNull(); + }); + + it("adds REPACK when source has rp token", () => { + const result = buildAutoRenameBaseName("Show.S01.720p-4sf", "show.s01e05.rp.720p.mkv"); + expect(result).toBe("Show.S01E05.REPACK.720p-4sf"); + }); + + it("handles folder with existing episode that gets replaced", () => { + const result = buildAutoRenameBaseName("Show.S01E01.720p-4sf", "show.s01e10.720p.mkv"); + expect(result).toBe("Show.S01E10.720p-4sf"); + }); + + it("inserts episode before -4sf when folder has no season/episode", () => { + const result = buildAutoRenameBaseName("Show.720p-4sf", "show.s01e05.720p.mkv"); + expect(result).toBe("Show.720p.S01E05-4sf"); + }); + + it("handles case-insensitive 4SF suffix", () => { + const result = buildAutoRenameBaseName("Show.S01.720p-4SF", "show.s01e02.720p.mkv"); + expect(result).toBe("Show.S01E02.720p-4SF"); + }); + + it("handles rp + no quality token in folder", () => { + const result = buildAutoRenameBaseName("Show.S01-4sf", "show.s01e05.rp.mkv"); + expect(result).toBe("Show.S01E05.REPACK-4sf"); + }); + + it("returns null for empty folder name", () => { + const result = buildAutoRenameBaseName("", "show.s01e01.mkv"); + expect(result).toBeNull(); + }); + + it("returns null for empty source file name", () => { + const result = buildAutoRenameBaseName("Show.S01-4sf", ""); + expect(result).toBeNull(); + }); + + it("handles 2160p quality token", () => { + const result = buildAutoRenameBaseName("Show.S01.2160p-4sf", "show.s01e01.rp.2160p.mkv"); + expect(result).toBe("Show.S01E01.REPACK.2160p-4sf"); + }); + + it("handles 480p quality token", () => { + const result = buildAutoRenameBaseName("Show.S01.480p-4sf", "show.s01e07.480p.mkv"); + expect(result).toBe("Show.S01E07.480p-4sf"); + }); + + it("does not trigger on folders ending with similar but wrong suffix", () => { + expect(buildAutoRenameBaseName("Show.S01-4sfx", "show.s01e01.mkv")).toBeNull(); + expect(buildAutoRenameBaseName("Show.S01-x4sf", "show.s01e01.mkv")).toBeNull(); + }); + + it("handles high season and episode numbers", () => { + const result = buildAutoRenameBaseName("Show.S99.720p-4sf", "show.s99e999.720p.mkv"); + expect(result).not.toBeNull(); + expect(result!).toContain("S99E999"); + }); + + it("real-world: German series with dots", () => { + const result = buildAutoRenameBaseName( + "Der.Bergdoktor.S18.German.720p.WEB.x264-4SJ", + "der.bergdoktor.s18e01.german.720p.web.x264" + ); + expect(result).toBe("Der.Bergdoktor.S18E01.German.720p.WEB.x264-4SJ"); + }); + + it("real-world: English series with rp token", () => { + const result = buildAutoRenameBaseName( + "The.Last.of.Us.S02.1080p.WEB-4SF", + "the.last.of.us.s02e03.rp.1080p.web" + ); + expect(result).toBe("The.Last.of.Us.S02E03.REPACK.1080p.WEB-4SF"); + }); + + it("real-world: multiple dots in name", () => { + const result = buildAutoRenameBaseName( + "Grey.s.Anatomy.S21.German.DL.720p.WEB.x264-4SJ", + "grey.s.anatomy.s21e08.german.dl.720p.web.x264" + ); + expect(result).toBe("Grey.s.Anatomy.S21E08.German.DL.720p.WEB.x264-4SJ"); + }); + + it("real-world: 4K content", () => { + const result = buildAutoRenameBaseName( + "Severance.S02.2160p.ATVP.WEB-DL.DDP5.1.DV.H.265-4SF", + "severance.s02e07.2160p.atvp.web-dl.ddp5.1.dv.h.265" + ); + expect(result).toBe("Severance.S02E07.2160p.ATVP.WEB-DL.DDP5.1.DV.H.265-4SF"); + }); + + it("real-world: Britannia release keeps folder base name", () => { + const result = buildAutoRenameBaseName( + "Britannia.S02.GERMAN.720p.WEBRiP.x264-LAW", + "law-britannia.s02e01.720p.webrip" + ); + expect(result).toBe("Britannia.S02.GERMAN.720p.WEBRiP.x264-LAW"); + }); + + it("real-world: Britannia repack injects REPACK", () => { + const result = buildAutoRenameBaseName( + "Britannia.S02.GERMAN.720p.WEBRiP.x264-LAW", + "law-britannia.s02e09.720p.webrip.repack" + ); + expect(result).toBe("Britannia.S02.GERMAN.REPACK.720p.WEBRiP.x264-LAW"); + }); + + it("adds REPACK when folder name carries RP hint", () => { + const result = buildAutoRenameBaseName( + "Banshee.S02E01.German.RP.720p.BluRay.x264-RIPLEY", + "r-banshee.s02e01-720p" + ); + expect(result).toBe("Banshee.S02E01.German.REPACK.720p.BluRay.x264-RIPLEY"); + }); + + it("real-world: folder already has wrong episode", () => { + const result = buildAutoRenameBaseName( + "Cobra.Kai.S06E01.720p.NF.WEB-DL.DDP5.1.x264-4SF", + "cobra.kai.s06e14.720p.nf.web-dl.ddp5.1.x264" + ); + expect(result).toBe("Cobra.Kai.S06E14.720p.NF.WEB-DL.DDP5.1.x264-4SF"); + }); + + it("source filename extension is not included in episode detection", () => { + const result = buildAutoRenameBaseName("Show.S01-4sf", "show.s01e01.mkv"); + expect(result).not.toBeNull(); + expect(result!).toContain("S01E01"); + }); + + it("does not match episode-like patterns in codec strings", () => { + const token = extractEpisodeToken("show.s01e01.h.265"); + expect(token).toBe("S01E01"); + }); + + it("handles folder with dash separators throughout", () => { + const result = buildAutoRenameBaseName( + "Show-Name-S01-720p-4sf", + "show-name-s01e05-720p" + ); + expect(result).toBe("Show-Name-S01E05-720p-4sf"); + }); + + it("does not duplicate episode when folder already has the same episode", () => { + const result = buildAutoRenameBaseName( + "Show.S01E05.720p-4sf", + "show.s01e05.720p" + ); + expect(result).toBe("Show.S01E05.720p-4sf"); + }); + + it("handles folder with only -4sf suffix (edge case)", () => { + const result = buildAutoRenameBaseName("-4sf", "show.s01e01.mkv"); + expect(result).not.toBeNull(); + expect(result!).toContain("S01E01"); + expect(result!).toContain("-4sf"); + expect(result!).not.toContain(".S01E01.S01E01"); + }); + + it("sanitizes special characters from result", () => { + const result = buildAutoRenameBaseName("Show:Name.S01-4sf", "show.s01e01.mkv"); + expect(result).not.toBeNull(); + expect(result!).not.toContain(":"); + }); +}); + +describe("buildAutoRenameBaseNameFromFolders", () => { + it("uses parent folder when current folder is not a scene template", () => { + const result = buildAutoRenameBaseNameFromFolders( + [ + "Episode 01", + "Banshee.S02.German.720p.BluRay.x264-RIPLEY" + ], + "r-banshee.s02e01-720p" + ); + expect(result).toBe("Banshee.S02.German.720p.BluRay.x264-RIPLEY"); + }); + + it("uses nested scene subfolder directly", () => { + const result = buildAutoRenameBaseNameFromFolders( + [ + "Banshee.S02E01.German.720p.BluRay.x264-RIPLEY", + "Banshee.S02.German.720p.BluRay.x264-RIPLEY" + ], + "r-banshee.s02e01-720p" + ); + expect(result).toBe("Banshee.S02E01.German.720p.BluRay.x264-RIPLEY"); + }); + + it("injects REPACK when parent folder carries repack hint", () => { + const result = buildAutoRenameBaseNameFromFolders( + [ + "Banshee.S02E01.German.720p.BluRay.x264-RIPLEY", + "Banshee.S02.German.RP.720p.BluRay.x264-RIPLEY" + ], + "r-banshee.s02e01-720p" + ); + expect(result).toBe("Banshee.S02E01.German.REPACK.720p.BluRay.x264-RIPLEY"); + }); + + it("uses nested Arrow episode folder with title", () => { + const result = buildAutoRenameBaseNameFromFolders( + [ + "Arrow.S04E01.Green.Arrow.German.DL.720p.BluRay.x264-RSG", + "Arrow.S04.German.DL.720p.BluRay.x264-RSG" + ], + "rsg-arrow-s04e01-720p" + ); + expect(result).toBe("Arrow.S04E01.Green.Arrow.German.DL.720p.BluRay.x264-RSG"); + }); + + it("adds REPACK for Arrow when source contains rp token", () => { + const result = buildAutoRenameBaseNameFromFolders( + [ + "Arrow.S04E01.Green.Arrow.German.DL.720p.BluRay.x264-RSG", + "Arrow.S04.German.DL.720p.BluRay.x264-RSG" + ], + "rsg-arrow-s04e01.rp.720p" + ); + expect(result).toBe("Arrow.S04E01.Green.Arrow.German.DL.REPACK.720p.BluRay.x264-RSG"); + }); + + it("converts Teil token to episode using parent season", () => { + const result = buildAutoRenameBaseNameFromFolders( + [ + "Last.Impact.Der.Einschlag.Teil1.GERMAN.DL.720p.WEB.H264-SunDry", + "Last.Impact.Der.Einschlag.S01.GERMAN.DL.720p.WEB.H264-SunDry" + ], + "sundry-last.impact.der.einschlag.teil1.720p.web.h264" + ); + expect(result).toBe("Last.Impact.Der.Einschlag.S01E01.GERMAN.DL.720p.WEB.H264-SunDry"); + }); + + it("converts Teil token to episode with REPACK", () => { + const result = buildAutoRenameBaseNameFromFolders( + [ + "Last.Impact.Der.Einschlag.Teil1.GERMAN.DL.720p.WEB.H264-SunDry", + "Last.Impact.Der.Einschlag.S01.GERMAN.DL.720p.WEB.H264-SunDry" + ], + "sundry-last.impact.der.einschlag.teil1.rp.720p.web.h264" + ); + expect(result).toBe("Last.Impact.Der.Einschlag.S01E01.GERMAN.DL.REPACK.720p.WEB.H264-SunDry"); + }); + + it("forces episode insertion for flat season folder when many files share directory", () => { + const result = buildAutoRenameBaseNameFromFoldersWithOptions( + [ + "Arrow.S08.GERMAN.DUBBED.DL.720p.BluRay.x264-TMSF" + ], + "tmsf-arrow-s08e03-720p", + { forceEpisodeForSeasonFolder: true } + ); + expect(result).toBe("Arrow.S08E03.GERMAN.DUBBED.DL.720p.BluRay.x264-TMSF"); + }); + + it("forces episode insertion plus REPACK for flat season folder", () => { + const result = buildAutoRenameBaseNameFromFoldersWithOptions( + [ + "Gotham.S05.GERMAN.DUBBED.720p.BLURAY.x264-ZZGtv" + ], + "zzgtv-gotham-s05e02.rp", + { forceEpisodeForSeasonFolder: true } + ); + expect(result).toBe("Gotham.S05E02.GERMAN.DUBBED.REPACK.720p.BLURAY.x264-ZZGtv"); + }); + + it("uses nested episode title folder for Gotham TvR style", () => { + const result = buildAutoRenameBaseNameFromFoldersWithOptions( + [ + "Gotham.S04E01.Pax.Penguina.GERMAN.5.1.DL.AC3.720p.BDRiP.x264-TvR", + "Gotham.S04.GERMAN.5.1.DL.AC3.720p.BDRiP.x264-TvR" + ], + "tvr-gotham-s04e01-720p", + { forceEpisodeForSeasonFolder: true } + ); + expect(result).toBe("Gotham.S04E01.Pax.Penguina.GERMAN.5.1.DL.AC3.720p.BDRiP.x264-TvR"); + }); + + it("uses nested title folder for Britannia TV4A style", () => { + const result = buildAutoRenameBaseNameFromFoldersWithOptions( + [ + "Britannia.S01E01.Die.Landung.German.DL.720p.BluRay.x264-TV4A", + "Britannia.S01.German.DL.720p.BluRay.x264-TV4A" + ], + "tv4a-britannia.s01e01-720p", + { forceEpisodeForSeasonFolder: true } + ); + expect(result).toBe("Britannia.S01E01.Die.Landung.German.DL.720p.BluRay.x264-TV4A"); + }); + + it("handles odd source token style 101 by using nested Agent X folder", () => { + const result = buildAutoRenameBaseNameFromFoldersWithOptions( + [ + "Agent.X.S01E01.Pilot.German.DD51.Dubbed.DL.720p.iTunesHD.x264-TVS", + "Agent.X.S01.German.DD51.Dubbed.DL.720p.iTunesHD.x264-TVS" + ], + "tvs-agent-x-dd51-ded-dl-7p-ithd-x264-101", + { forceEpisodeForSeasonFolder: true } + ); + expect(result).toBe("Agent.X.S01E01.Pilot.German.DD51.Dubbed.DL.720p.iTunesHD.x264-TVS"); + }); + + it("maps compact code 301 to S03E01 for nested Legion folder", () => { + const result = buildAutoRenameBaseNameFromFoldersWithOptions( + [ + "Legion.S03E01.Kapitel.20.German.DD51.Dubbed.DL.720p.AmazonHD.AVC-TVS", + "Legion.S03.German.DD51.Dubbed.DL.720p.AmazonHD.AVC-TVS" + ], + "tvs-legion-dd51-ded-dl-7p-azhd-avc-301", + { forceEpisodeForSeasonFolder: true } + ); + expect(result).toBe("Legion.S03E01.Kapitel.20.German.DD51.Dubbed.DL.720p.AmazonHD.AVC-TVS"); + }); + + it("maps compact code 211 in flat season folder", () => { + const result = buildAutoRenameBaseNameFromFoldersWithOptions( + [ + "Lethal.Weapon.S02.German.DD51.Dubbed.DL.720p.AmazonHD.x264-TVS" + ], + "tvs-lethal-weapon-dd51-ded-dl-7p-azhd-x264-211", + { forceEpisodeForSeasonFolder: true } + ); + expect(result).toBe("Lethal.Weapon.S02E11.German.DD51.Dubbed.DL.720p.AmazonHD.x264-TVS"); + }); + + it("maps compact code 319a to episode 19 in season 3 folder", () => { + const result = buildAutoRenameBaseNameFromFoldersWithOptions( + [ + "Die.Bergpolizei.-.Ganz.nah.am.Himmel.S03.GERMAN.AC3.720p.HDTV.x264-hrs" + ], + "hrs-bpol.hdtv.7p-319a", + { forceEpisodeForSeasonFolder: true } + ); + expect(result).toBe("Die.Bergpolizei.-.Ganz.nah.am.Himmel.S03E19.GERMAN.AC3.720p.HDTV.x264-hrs"); + }); + + it("maps compact code 319b to next episode in season 3 folder", () => { + const result = buildAutoRenameBaseNameFromFoldersWithOptions( + [ + "Die.Bergpolizei.-.Ganz.nah.am.Himmel.S03.GERMAN.AC3.720p.HDTV.x264-hrs" + ], + "hrs-bpol.hdtv.7p-319b", + { forceEpisodeForSeasonFolder: true } + ); + expect(result).toBe("Die.Bergpolizei.-.Ganz.nah.am.Himmel.S03E20.GERMAN.AC3.720p.HDTV.x264-hrs"); + }); + + it("maps episode-only token e01 via season folder hint and keeps REPACK", () => { + const result = buildAutoRenameBaseNameFromFoldersWithOptions( + [ + "Cheat.der.Betrug.S01.GERMAN.720p.WEB.h264-TMSF" + ], + "tmsf-cheatderbetrug-e01-720p-repack", + { forceEpisodeForSeasonFolder: true } + ); + expect(result).toBe("Cheat.der.Betrug.S01E01.GERMAN.REPACK.720p.WEB.h264-TMSF"); + }); + + it("maps episode-only token e02 via season folder hint", () => { + const result = buildAutoRenameBaseNameFromFoldersWithOptions( + [ + "Cheat.der.Betrug.S01.GERMAN.720p.WEB.h264-TMSF" + ], + "tmsf-cheatderbetrug-e02-720p", + { forceEpisodeForSeasonFolder: true } + ); + expect(result).toBe("Cheat.der.Betrug.S01E02.GERMAN.720p.WEB.h264-TMSF"); + }); + + it("keeps renaming for odd source order like 4sf-bs-720p-s01e05", () => { + const result = buildAutoRenameBaseNameFromFoldersWithOptions( + [ + "Cheat.der.Betrug.S01.GERMAN.720p.WEB.h264-TMSF" + ], + "4sf-bs-720p-s01e05", + { forceEpisodeForSeasonFolder: true } + ); + expect(result).toBe("Cheat.der.Betrug.S01E05.GERMAN.720p.WEB.h264-TMSF"); + }); + + it("accepts lowercase scene group suffixes", () => { + const result = buildAutoRenameBaseNameFromFoldersWithOptions( + [ + "Cheat.der.Betrug.S01.GERMAN.720p.WEB.h264-tmsf" + ], + "tmsf-cheatderbetrug-e01-720p", + { forceEpisodeForSeasonFolder: true } + ); + expect(result).toBe("Cheat.der.Betrug.S01E01.GERMAN.720p.WEB.h264-tmsf"); + }); + + it("renames double episode file into season folder (Mammon style)", () => { + const result = buildAutoRenameBaseNameFromFoldersWithOptions( + [ + "Mammon.S01.German.1080P.Bluray.x264-SMAHD" + ], + "tvr-mammon-s01e01e02-720p", + { forceEpisodeForSeasonFolder: true } + ); + expect(result).toBe("Mammon.S01E01E02.German.1080P.Bluray.x264-SMAHD"); + }); + + it("renames second double episode file correctly", () => { + const result = buildAutoRenameBaseNameFromFoldersWithOptions( + [ + "Mammon.S01.German.1080P.Bluray.x264-SMAHD" + ], + "tvr-mammon-s01e03e04-720p", + { forceEpisodeForSeasonFolder: true } + ); + expect(result).toBe("Mammon.S01E03E04.German.1080P.Bluray.x264-SMAHD"); + }); + + it("renames third double episode file correctly", () => { + const result = buildAutoRenameBaseNameFromFoldersWithOptions( + [ + "Mammon.S01.German.1080P.Bluray.x264-SMAHD" + ], + "tvr-mammon-s01e05e06-720p", + { forceEpisodeForSeasonFolder: true } + ); + expect(result).toBe("Mammon.S01E05E06.German.1080P.Bluray.x264-SMAHD"); + }); + + it("renames when folder has season but no scene group suffix (Mystery Road case)", () => { + const result = buildAutoRenameBaseNameFromFoldersWithOptions( + ["Mystery Road S02"], + "myst.road.de.dl.hdtv.7p-s02e05", + { forceEpisodeForSeasonFolder: true } + ); + expect(result).toBe("Mystery Road S02E05"); + }); + + it("renames with season-only folder and custom name without dots", () => { + const result = buildAutoRenameBaseNameFromFoldersWithOptions( + ["Meine Serie S03"], + "meine-serie-s03e10-720p", + { forceEpisodeForSeasonFolder: true } + ); + expect(result).toBe("Meine Serie S03E10"); + }); + + it("prefers scene-group folder over season-only fallback", () => { + const result = buildAutoRenameBaseNameFromFoldersWithOptions( + [ + "Mystery Road S02", + "Mystery.Road.S02.GERMAN.DL.AC3.720p.HDTV.x264-hrs" + ], + "myst.road.de.dl.hdtv.7p-s02e05", + { forceEpisodeForSeasonFolder: true } + ); + expect(result).toBe("Mystery.Road.S02E05.GERMAN.DL.AC3.720p.HDTV.x264-hrs"); + }); + + it("does not use season-only fallback when forceEpisodeForSeasonFolder is false", () => { + const result = buildAutoRenameBaseNameFromFoldersWithOptions( + ["Mystery Road S02"], + "myst.road.de.dl.hdtv.7p-s02e05", + { forceEpisodeForSeasonFolder: false } + ); + expect(result).toBeNull(); + }); + + it("renames Riviera S02 with single-digit episode s02e2", () => { + const result = buildAutoRenameBaseNameFromFoldersWithOptions( + ["Riviera.S02.GERMAN.DUBBED.DL.720p.WebHD.x264-TVP"], + "tvp-riviera-s02e2-720p", + { forceEpisodeForSeasonFolder: true } + ); + expect(result).toBe("Riviera.S02E02.GERMAN.DUBBED.DL.720p.WebHD.x264-TVP"); + }); + + it("renames Room 104 abbreviated source r104.de.dl.web.7p-s04e02", () => { + const result = buildAutoRenameBaseNameFromFoldersWithOptions( + ["Room.104.S04.GERMAN.DL.720p.WEBRiP.x264-LAW"], + "r104.de.dl.web.7p-s04e02", + { forceEpisodeForSeasonFolder: true } + ); + expect(result).toBe("Room.104.S04E02.GERMAN.DL.720p.WEBRiP.x264-LAW"); + }); + + it("renames Room 104 wayne source with episode", () => { + const result = buildAutoRenameBaseNameFromFoldersWithOptions( + ["Room.104.S04.GERMAN.DL.720p.WEBRiP.x264-LAW"], + "room.104.s04e01.german.dl.720p.web.h264-wayne", + { forceEpisodeForSeasonFolder: true } + ); + expect(result).toBe("Room.104.S04E01.GERMAN.DL.720p.WEBRiP.x264-LAW"); + }); + + it("renames Carter when source joins title and season token", () => { + const result = buildAutoRenameBaseNameFromFoldersWithOptions( + ["Carter.S02.GERMAN.DL.720p.HDTV.x264-MDGP"], + "mdgp-carters02e01-720p", + { forceEpisodeForSeasonFolder: true } + ); + expect(result).toBe("Carter.S02E01.GERMAN.DL.720p.HDTV.x264-MDGP"); + }); + + it("renames abbreviated source bupr.de.dl.web.7p-s01e03 via season folder", () => { + const result = buildAutoRenameBaseNameFromFoldersWithOptions( + ["Burning.Promise.S01.GERMAN.DL.720p.WEB.H264-WvF"], + "bupr.de.dl.web.7p-s01e03", + { forceEpisodeForSeasonFolder: true } + ); + expect(result).toBe("Burning.Promise.S01E03.GERMAN.DL.720p.WEB.H264-WvF"); + }); + + it("renames abbreviated 4SF source amilllt.de.dl.web.7p-s03e10 via season folder", () => { + const result = buildAutoRenameBaseNameFromFoldersWithOptions( + ["A.Million.Little.Things.S03.GERMAN.DL.720p.WEB.H264-4SF"], + "4sf-amilllt.de.dl.web.7p-s03e10", + { forceEpisodeForSeasonFolder: true } + ); + expect(result).toBe("A.Million.Little.Things.S03E10.GERMAN.DL.720p.WEB.H264-4SF"); + }); + + it("renames abbreviated source jkl.web.7p-s01e13 via season folder", () => { + const result = buildAutoRenameBaseNameFromFoldersWithOptions( + ["9JKL.S01.GERMAN.720p.WEB.x264-WvF"], + "jkl.web.7p-s01e13", + { forceEpisodeForSeasonFolder: true } + ); + expect(result).toBe("9JKL.S01E13.GERMAN.720p.WEB.x264-WvF"); + }); + + it("renames abbreviated source jkl.web.7p-s01e14 via season folder", () => { + const result = buildAutoRenameBaseNameFromFoldersWithOptions( + ["9JKL.S01.GERMAN.720p.WEB.x264-WvF"], + "jkl.web.7p-s01e14", + { forceEpisodeForSeasonFolder: true } + ); + expect(result).toBe("9JKL.S01E14.GERMAN.720p.WEB.x264-WvF"); + }); + + it("documents malformed package name (S01GERMAN) limitation", () => { + const result = buildAutoRenameBaseNameFromFoldersWithOptions( + [ + "3MH.web.7p-101", + "Drei.Meter.ueber.dem.Himmel.S01GERMAN.DL.720P.WEB.X264-WAYNE" + ], + "Drei.Meter.ueber.dem.Himmel.S01E01.GERMAN.DL.720P.WEB.X264-WAYNE", + { forceEpisodeForSeasonFolder: true } + ); + if (result !== null) { + expect(typeof result).toBe("string"); + } + }); +}); + +describe("isBonusContent (numbered episodes are never bonus)", () => { + const pkgDir = "/pkg/Show.S04.GERMAN.DL.720p.WEB.x264-GRP"; + + it("does NOT treat a numbered episode as bonus even when its TITLE is a bonus word", () => { + const name = "Revenge.2011.S04E19.Interview.GERMAN.DL.720p.WEB.x264-TSCC"; + const fp = `${pkgDir}/${name}/${name}.mkv`; + expect(isBonusContent(fp, pkgDir, name)).toBe(false); + }); + + it("covers further bonus-word episode titles with a token", () => { + for (const title of ["Special", "Featurette", "Outtakes", "Bloopers", "Making.Of"]) { + const name = `Show.S04E07.${title}.GERMAN.720p.WEB.x264-GRP`; + expect(isBonusContent(`${pkgDir}/${name}.mkv`, pkgDir, name)).toBe(false); + } + }); + + it("STILL treats genuine extras WITHOUT an episode token as bonus", () => { + for (const name of [ + "Show.Making.Of.GERMAN.720p.WEB.x264-GRP", + "Show.Behind.The.Scenes.GERMAN-GRP", + "Some.Interview.With.Cast" + ]) { + expect(isBonusContent(`${pkgDir}/${name}.mkv`, pkgDir, name)).toBe(true); + } + }); + + it("a token-bearing file inside an Extras subfolder is still kept (numbered episode wins)", () => { + const name = "Show.S04E19.Interview.GROUP"; + const fp = `${pkgDir}/Extras/${name}/${name}.mkv`; + expect(isBonusContent(fp, pkgDir, name)).toBe(false); + }); + + it("a token-less file inside an Extras subfolder is bonus", () => { + const fp = `${pkgDir}/Extras/Making.Of.mkv`; + expect(isBonusContent(fp, pkgDir, "Making.Of")).toBe(true); + }); +}); + +describe("complete episode folder WITHOUT group suffix (codec/resolution only)", () => { + const hash = "c284d9d9072eaf3ac314d05f951dd115"; + + it("uses the clean folder name when it has an episode token + codec but no -GROUP (safari S04E08a)", () => { + const folder = "Fluss-Monster.S04E08a.Am.Essequibo.Teil.1.German.DOKU.SATRiP.XviD"; + const decision = decideAutoRenameBaseName([folder, hash], "safari-fm-s04e08a.avi", "safari-fm-s04e08a", hash, hash); + expect(decision).toEqual({ kind: "rename", baseName: folder }); + }); + + it("keeps multi-part letters a/b distinct (Teil.1 vs Teil.2 do NOT collide)", () => { + const fa = "Fluss-Monster.S04E08a.Am.Essequibo.Teil.1.German.DOKU.SATRiP.XviD"; + const fb = "Fluss-Monster.S04E08b.Am.Essequibo.Teil.2.German.DOKU.SATRiP.XviD"; + const da = decideAutoRenameBaseName([fa, hash], "safari-fm-s04e08a.avi", "safari-fm-s04e08a", hash, hash); + const db = decideAutoRenameBaseName([fb, hash], "safari-fm-s04e08b.avi", "safari-fm-s04e08b", hash, hash); + expect(da).toEqual({ kind: "rename", baseName: fa }); + expect(db).toEqual({ kind: "rename", baseName: fb }); + expect((da as any).baseName).not.toBe((db as any).baseName); + }); + + it("the previously-working group-suffix folder still works (no regression)", () => { + const folder = "Fluss-Monster.S01E02.Auf.der.Suche.nach.dem.Killer-Wels.German.DOKU.SATRiP.XviD-SAFARi"; + const decision = decideAutoRenameBaseName([folder, hash], "safari-fm-s01e02.avi", "safari-fm-s01e02", hash, hash); + expect(decision).toEqual({ kind: "rename", baseName: folder }); + }); + + it("does NOT use a bare episode folder WITHOUT any codec/resolution marker (stays conservative)", () => { + const decision = decideAutoRenameBaseName(["Show.S01E01", hash], "abc-s01e01.avi", "abc-s01e01", hash, hash); + expect(decision.kind).toBe("skip"); + }); + + it("does NOT fabricate a name from a token-LESS folder (Mega-Direct guard intact)", () => { + const decision = decideAutoRenameBaseName(["Mega-Direct-Pack", hash], "Direct.Show.S01E01.DIRECT.mkv", "Direct.Show.S01E01.DIRECT", hash, hash); + expect(decision.kind).toBe("skip"); + }); +}); + +describe("collect must not mangle an already-clean SxxExx name via an episode-title folder", () => { + const hash = "c284d9d9072eaf3ac314d05f951dd115"; + const epFolder = "Steven.Spielbergs.Taken.E01.Hinter.dem.Himmel.German.720p.HDTV.x264-GTVG"; + const pkgFolder = "Steven.Spielbergs.Taken.S01.German.720p.HDTV.x264-GTVG"; + const cleanSource = "Steven.Spielbergs.Taken.S01E01.German.720p.HDTV.x264-GTVG"; + + it("keeps the clean source (skip) instead of appending the token to the episode-title folder", () => { + const decision = decideAutoRenameBaseName([epFolder, pkgFolder], cleanSource + ".mkv", cleanSource, epFolder, pkgFolder); + expect(decision.kind).toBe("skip"); + expect(JSON.stringify(decision)).not.toContain("GTVG.S01E01"); + }); + + it("still cleans a JUNK/obfuscated source via an episode-title folder (append path intact, no skip)", () => { + const epFolder = "Show.E05.Die.Sache.German.720p.HDTV.x264-GRP"; + const seasonFolder = "Show.S01.German.720p.HDTV.x264-GRP"; + const decision = decideAutoRenameBaseName([epFolder, seasonFolder], "scn-show7-S01E05.mkv", "scn-show7-S01E05", epFolder, seasonFolder); + expect(decision.kind).toBe("rename"); + expect(extractEpisodeToken((decision as any).baseName)).toBe("S01E05"); + }); + + it("does NOT affect a folder that already carries an SxxExx token (safari S04E08a stays a rename)", () => { + const folder = "Fluss-Monster.S04E08a.Am.Essequibo.Teil.1.German.DOKU.SATRiP.XviD"; + const decision = decideAutoRenameBaseName([folder, hash], "safari-fm-s04e08a.avi", "safari-fm-s04e08a", hash, hash); + expect(decision).toEqual({ kind: "rename", baseName: folder }); + }); + + it("keeps a clean SHORT-prefix series source (ER) instead of the crippled token append", () => { + const epFolder = "ER.E01.Tag.und.Nacht.German.720p.HDTV.x264-GROUP"; + const seasonFolder = "ER.S01.German.720p.HDTV.x264-GROUP"; + const cleanSource = "ER.S01E01.German.720p.HDTV.x264-GROUP"; + const decision = decideAutoRenameBaseName([epFolder, seasonFolder], cleanSource + ".mkv", cleanSource, epFolder, seasonFolder); + expect(decision.kind).toBe("skip"); + expect(JSON.stringify(decision)).not.toContain("GROUP.S01E01"); + }); +}); diff --git a/tests/backup-crypto.test.ts b/tests/backup-crypto.test.ts new file mode 100644 index 0000000..211baab --- /dev/null +++ b/tests/backup-crypto.test.ts @@ -0,0 +1,82 @@ +import { describe, expect, it } from "vitest"; +import { encryptBackup, decryptBackup } from "../src/main/backup-crypto"; + +describe("backup-crypto", () => { + it("encrypts and decrypts a round-trip correctly", () => { + const original = JSON.stringify({ + version: 2, + settings: { token: "my-secret-api-key", outputDir: "C:\\Downloads" }, + session: { packages: {}, items: {} }, + history: [{ id: "h1", name: "Test" }] + }); + + const encrypted = encryptBackup(original); + const decrypted = decryptBackup(encrypted); + expect(decrypted).toBe(original); + }); + + it("produces binary output that is not plaintext readable", () => { + const secret = "super-secret-token-12345"; + const plaintext = JSON.stringify({ settings: { token: secret } }); + const encrypted = encryptBackup(plaintext); + + expect(encrypted.toString("utf8")).not.toContain(secret); + expect(encrypted.toString("latin1")).not.toContain(secret); + }); + + it("starts with the MDD1 magic bytes", () => { + const encrypted = encryptBackup("test"); + expect(encrypted.subarray(0, 4).toString("utf8")).toBe("MDD1"); + }); + + it("produces different ciphertext for the same input (random IV)", () => { + const plaintext = "same input data"; + const a = encryptBackup(plaintext); + const b = encryptBackup(plaintext); + expect(a.equals(b)).toBe(false); + expect(decryptBackup(a)).toBe(plaintext); + expect(decryptBackup(b)).toBe(plaintext); + }); + + it("throws on truncated data", () => { + const encrypted = encryptBackup("test data"); + const truncated = encrypted.subarray(0, 10); + expect(() => decryptBackup(truncated)).toThrow(); + }); + + it("throws on corrupted ciphertext", () => { + const encrypted = encryptBackup("test data"); + const corrupted = Buffer.from(encrypted); + corrupted[corrupted.length - 1] ^= 0xff; + expect(() => decryptBackup(corrupted)).toThrow(); + }); + + it("throws on wrong magic bytes", () => { + const encrypted = encryptBackup("test data"); + const wrongMagic = Buffer.from(encrypted); + wrongMagic[0] = 0x00; + expect(() => decryptBackup(wrongMagic)).toThrow(/Signatur/); + }); + + it("throws on empty buffer", () => { + expect(() => decryptBackup(Buffer.alloc(0))).toThrow(); + }); + + it("handles large payloads", () => { + const large = JSON.stringify({ data: "x".repeat(1_000_000) }); + const encrypted = encryptBackup(large); + const decrypted = decryptBackup(encrypted); + expect(decrypted).toBe(large); + }); + + it("handles unicode content", () => { + const unicode = JSON.stringify({ name: "Ünïcödé 日本語 🎉", path: "C:\\Benutzer\\Ö" }); + const encrypted = encryptBackup(unicode); + expect(decryptBackup(encrypted)).toBe(unicode); + }); + + it("handles empty string round-trip", () => { + const encrypted = encryptBackup(""); + expect(decryptBackup(encrypted)).toBe(""); + }); +}); diff --git a/tests/backup-payload.test.ts b/tests/backup-payload.test.ts new file mode 100644 index 0000000..2b115cd --- /dev/null +++ b/tests/backup-payload.test.ts @@ -0,0 +1,81 @@ +import { describe, expect, it } from "vitest"; +import { buildBackupPayload, planBackupImport } from "../src/main/backup-payload"; +import type { AppSettings, SessionState, HistoryEntry } from "../src/shared/types"; + +function settings(overrides: Partial = {}): AppSettings { + return { backupIncludeDownloads: false, token: "secret", outputDir: "C:\\dl" } as unknown as AppSettings; +} + +const session: SessionState = { + version: 2, packageOrder: ["p1"], packages: { p1: {} as never }, items: { i1: {} as never }, + runStartedAt: 0, totalDownloadedBytes: 0, summaryText: "", reconnectUntil: 0, + reconnectReason: "", paused: false, running: true, updatedAt: 0 +}; +const history: HistoryEntry[] = [{ id: "h1" } as unknown as HistoryEntry]; + +const baseInput = { appVersion: "1.7.183", exportedAt: "2026-06-07T00:00:00Z", session, history }; + +describe("buildBackupPayload — default is settings-only", () => { + it("omits session AND history when backupIncludeDownloads is false (default)", () => { + const p = buildBackupPayload({ ...baseInput, settings: { backupIncludeDownloads: false } as AppSettings }); + expect(p.kind).toBe("settings-only"); + expect(p.session).toBeUndefined(); + expect(p.history).toBeUndefined(); + expect(p.settings).toBeDefined(); + }); + + it("includes session + history when backupIncludeDownloads is true", () => { + const p = buildBackupPayload({ ...baseInput, settings: { backupIncludeDownloads: true } as AppSettings }); + expect(p.kind).toBe("full"); + expect(p.session).toBe(session); + expect(p.history).toBe(history); + }); + + it("treats a missing flag as settings-only (safe default)", () => { + const p = buildBackupPayload({ ...baseInput, settings: {} as AppSettings }); + expect(p.kind).toBe("settings-only"); + expect(p.session).toBeUndefined(); + }); + + it("ROUND-TRIP: toggle off -> exported payload carries the flag still false", () => { + // "Haken aus bleibt aus": the exported settings object preserves the flag, + // so importing it keeps the toggle off. + const p = buildBackupPayload({ ...baseInput, settings: { backupIncludeDownloads: false } as AppSettings }); + expect((p.settings as AppSettings).backupIncludeDownloads).toBe(false); + }); +}); + +describe("planBackupImport — decision follows the file, not the local toggle", () => { + it("settings-only backup (no session) -> restore settings only, no relaunch", () => { + const plan = planBackupImport({ version: 2, kind: "settings-only", settings: { theme: "dark" } }); + expect(plan.valid).toBe(true); + expect(plan.restoreDownloads).toBe(false); + expect(plan.message).toMatch(/Einstellungen/); + }); + + it("full backup (with session) -> restore downloads + relaunch", () => { + const plan = planBackupImport({ version: 2, kind: "full", settings: { theme: "dark" }, session }); + expect(plan.valid).toBe(true); + expect(plan.restoreDownloads).toBe(true); + }); + + it("rejects payloads without settings", () => { + expect(planBackupImport({ session }).valid).toBe(false); + expect(planBackupImport(null).valid).toBe(false); + expect(planBackupImport("nope").valid).toBe(false); + expect(planBackupImport({}).valid).toBe(false); + }); + + it("a settings-only export then import does NOT pull in the download list", () => { + // Build with toggle off, then plan the import of exactly that payload. + const exported = buildBackupPayload({ ...baseInput, settings: { backupIncludeDownloads: false } as AppSettings }); + const plan = planBackupImport(JSON.parse(JSON.stringify(exported))); + expect(plan.restoreDownloads).toBe(false); // queue stays untouched + }); + + it("a full export then import DOES restore the download list", () => { + const exported = buildBackupPayload({ ...baseInput, settings: { backupIncludeDownloads: true } as AppSettings }); + const plan = planBackupImport(JSON.parse(JSON.stringify(exported))); + expect(plan.restoreDownloads).toBe(true); + }); +}); diff --git a/tests/backup-remote-diagnostics.test.ts b/tests/backup-remote-diagnostics.test.ts new file mode 100644 index 0000000..a01315a --- /dev/null +++ b/tests/backup-remote-diagnostics.test.ts @@ -0,0 +1,251 @@ +import fs from "node:fs"; +import http from "node:http"; +import os from "node:os"; +import path from "node:path"; +import { once } from "node:events"; +import { afterEach, describe, expect, it } from "vitest"; + +import { buildBackupPayload, resolveRemoteDiagnosticsRestore, BackupRemoteDiagnostics } from "../src/main/backup-payload"; +import { defaultSettings } from "../src/main/constants"; +import { normalizeSettings } from "../src/main/storage"; +import { + startDebugServer, + stopDebugServer, + restartDebugServer, + writeDebugServerConfig, + getDebugAllowlist, + getDebugServerRuntimeStatus +} from "../src/main/debug-server"; +import type { DownloadManager } from "../src/main/download-manager"; +import type { AppSettings, SessionState } from "../src/shared/types"; + +const tempDirs: string[] = []; + +function input(settingsOverride: Partial, remoteDiagnostics?: BackupRemoteDiagnostics) { + return { + settings: { ...defaultSettings(), ...settingsOverride } as AppSettings, + appVersion: "1.7.233", + exportedAt: "2026-08-01T00:00:00.000Z", + session: {} as unknown as SessionState, + history: [], + remoteDiagnostics + }; +} + +async function getFreePort(): Promise { + const probe = http.createServer(); + probe.listen(0, "127.0.0.1"); + await once(probe, "listening"); + const address = probe.address(); + if (!address || typeof address === "string") { + throw new Error("port probe failed"); + } + probe.close(); + await once(probe, "close"); + return address.port; +} + +async function waitForReady(url: string): Promise { + const deadline = Date.now() + 5000; + while (Date.now() < deadline) { + try { + const res = await fetch(url); + if (res.ok) { + return; + } + } catch { + } + await new Promise((resolve) => setTimeout(resolve, 40)); + } + throw new Error(`debug server not ready: ${url}`); +} + +afterEach(() => { + stopDebugServer(); + while (tempDirs.length > 0) { + const dir = tempDirs.pop(); + if (!dir) { + continue; + } + try { + fs.rmSync(dir, { recursive: true, force: true }); + } catch { + } + } +}); + +describe("backup remoteDiagnostics export gating", () => { + it("includes remoteDiagnostics when backupIncludeRemoteDiagnostics is on", () => { + const settings = { + ...defaultSettings(), + backupIncludeRemoteDiagnostics: true + }; + const payload = buildBackupPayload({ + settings, + appVersion: "1.7.233", + exportedAt: "2026-08-01T00:00:00.000Z", + session: {} as unknown as SessionState, + history: [], + remoteDiagnostics: { + allowlist: ["192.0.2.0/24"], + port: 8976, + hostMode: "network" + } + }); + + expect(payload.remoteDiagnostics).toEqual({ + allowlist: ["192.0.2.0/24"], + port: 8976, + hostMode: "network" + }); + }); + + it("omits remoteDiagnostics when the toggle is off even if a section is provided", () => { + const payload = buildBackupPayload(input({ backupIncludeRemoteDiagnostics: false }, { allowlist: ["10.0.0.5"], port: 9868, hostMode: "network" })); + expect(payload.remoteDiagnostics).toBeUndefined(); + }); + + it("omits remoteDiagnostics when toggle on but no section gathered", () => { + const payload = buildBackupPayload(input({ backupIncludeRemoteDiagnostics: true }, undefined)); + expect(payload.remoteDiagnostics).toBeUndefined(); + }); + + it("the remoteDiagnostics section carries ONLY allowlist/port/hostMode (no token, publicHost, name)", () => { + const payload = buildBackupPayload(input({ backupIncludeRemoteDiagnostics: true }, { allowlist: ["10.0.0.5"], port: 9868, hostMode: "network" })); + expect(payload.remoteDiagnostics && Object.keys(payload.remoteDiagnostics).sort()).toEqual(["allowlist", "hostMode", "port"]); + const sectionJson = JSON.stringify(payload.remoteDiagnostics); + expect(sectionJson.toLowerCase()).not.toContain("token"); + expect(sectionJson).not.toContain("publicHost"); + expect(sectionJson.toLowerCase()).not.toContain("\"name\""); + }); +}); + +describe("backupIncludeRemoteDiagnostics settings persistence", () => { + it("normalizeSettings preserves backupIncludeRemoteDiagnostics (the toggle survives save/load)", () => { + expect(normalizeSettings({ backupIncludeRemoteDiagnostics: true } as unknown as AppSettings).backupIncludeRemoteDiagnostics).toBe(true); + expect(normalizeSettings({ backupIncludeRemoteDiagnostics: false } as unknown as AppSettings).backupIncludeRemoteDiagnostics).toBe(false); + expect(normalizeSettings({} as unknown as AppSettings).backupIncludeRemoteDiagnostics).toBe(false); + }); +}); + +describe("resolveRemoteDiagnosticsRestore", () => { + it("maps network + non-empty allowlist to 0.0.0.0", () => { + expect(resolveRemoteDiagnosticsRestore({ allowlist: ["10.0.0.5"], port: 9868, hostMode: "network" })) + .toEqual({ host: "0.0.0.0", port: 9868, allowlist: ["10.0.0.5"] }); + }); + + it("SAFETY: network with EMPTY allowlist binds local, never 0.0.0.0", () => { + expect(resolveRemoteDiagnosticsRestore({ allowlist: [], port: 9868, hostMode: "network" })?.host).toBe("127.0.0.1"); + }); + + it("maps local to 127.0.0.1", () => { + expect(resolveRemoteDiagnosticsRestore({ allowlist: ["10.0.0.5"], port: 9868, hostMode: "local" })?.host).toBe("127.0.0.1"); + }); + + it("rejects an out-of-range or non-integer port", () => { + expect(resolveRemoteDiagnosticsRestore({ allowlist: ["10.0.0.5"], port: 80, hostMode: "network" })?.port).toBeUndefined(); + expect(resolveRemoteDiagnosticsRestore({ allowlist: ["10.0.0.5"], port: 70000, hostMode: "network" })?.port).toBeUndefined(); + expect(resolveRemoteDiagnosticsRestore({ allowlist: ["10.0.0.5"], port: 9868.5, hostMode: "network" })?.port).toBeUndefined(); + }); + + it("filters non-string and blank allowlist entries and trims", () => { + const r = resolveRemoteDiagnosticsRestore({ allowlist: ["10.0.0.5", "", " ", 5, null, " 8.8.8.8 "], port: 9868, hostMode: "network" }); + expect(r?.allowlist).toEqual(["10.0.0.5", "8.8.8.8"]); + }); + + it("returns null for missing or empty/invalid sections", () => { + expect(resolveRemoteDiagnosticsRestore(undefined)).toBeNull(); + expect(resolveRemoteDiagnosticsRestore(null)).toBeNull(); + expect(resolveRemoteDiagnosticsRestore("x")).toBeNull(); + expect(resolveRemoteDiagnosticsRestore({})).toBeNull(); + }); +}); + +describe("backup remoteDiagnostics live restore round-trip", () => { + it("export -> resolve -> apply is reflected in the running debug-server (proves restart fired)", async () => { + const baseDir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-backup-remote-")); + tempDirs.push(baseDir); + const startPort = await getFreePort(); + const restorePort = await getFreePort(); + fs.writeFileSync(path.join(baseDir, "debug_token.txt"), "rt-secret", "utf8"); + fs.writeFileSync(path.join(baseDir, "debug_port.txt"), String(startPort), "utf8"); + fs.writeFileSync(path.join(baseDir, "debug_host.txt"), "127.0.0.1", "utf8"); + fs.writeFileSync(path.join(baseDir, "debug_allowlist.txt"), "", "utf8"); + startDebugServer({} as unknown as DownloadManager, baseDir); + await waitForReady(`http://127.0.0.1:${startPort}/health?token=rt-secret`); + expect(getDebugAllowlist()).toEqual([]); + + const payload = buildBackupPayload(input( + { backupIncludeRemoteDiagnostics: true }, + { allowlist: ["203.0.113.4", "10.0.0.0/24"], port: restorePort, hostMode: "network" } + )); + + const restore = resolveRemoteDiagnosticsRestore(payload.remoteDiagnostics); + expect(restore).not.toBeNull(); + writeDebugServerConfig({ host: restore!.host, port: restore!.port, allowlist: restore!.allowlist }); + const status = await restartDebugServer(); + + expect(getDebugAllowlist()).toEqual(["203.0.113.4", "10.0.0.0/24"]); + expect(status.port).toBe(restorePort); + expect(status.host).toBe("0.0.0.0"); + expect(status.allowlistCount).toBe(2); + + expect(fs.readFileSync(path.join(baseDir, "debug_token.txt"), "utf8").trim()).toBe("rt-secret"); + expect(fs.existsSync(path.join(baseDir, "debug_remote.json"))).toBe(false); + + await waitForReady(`http://127.0.0.1:${restorePort}/health?token=rt-secret`); + }); + + it("full-backup path writes the debug_* files to disk without a restart (boot picks them up)", async () => { + const baseDir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-backup-remote2-")); + tempDirs.push(baseDir); + const startPort = await getFreePort(); + fs.writeFileSync(path.join(baseDir, "debug_token.txt"), "rt2", "utf8"); + fs.writeFileSync(path.join(baseDir, "debug_port.txt"), String(startPort), "utf8"); + fs.writeFileSync(path.join(baseDir, "debug_host.txt"), "127.0.0.1", "utf8"); + fs.writeFileSync(path.join(baseDir, "debug_allowlist.txt"), "", "utf8"); + startDebugServer({} as unknown as DownloadManager, baseDir); + await waitForReady(`http://127.0.0.1:${startPort}/health?token=rt2`); + + const restore = resolveRemoteDiagnosticsRestore({ allowlist: ["198.51.100.9"], port: 9100, hostMode: "network" }); + writeDebugServerConfig({ host: restore!.host, port: restore!.port, allowlist: restore!.allowlist }); + + expect(fs.readFileSync(path.join(baseDir, "debug_host.txt"), "utf8").trim()).toBe("0.0.0.0"); + expect(fs.readFileSync(path.join(baseDir, "debug_port.txt"), "utf8").trim()).toBe("9100"); + expect(fs.readFileSync(path.join(baseDir, "debug_allowlist.txt"), "utf8")).toContain("198.51.100.9"); + expect(getDebugServerRuntimeStatus().port).toBe(startPort); + }); +}); + +describe("debug-server live diagnostics endpoints", () => { + it("serves /providers (live cooldown/runtime snapshot) and /logs/conversion over authenticated HTTP", async () => { + const baseDir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-prov-")); + tempDirs.push(baseDir); + const port = await getFreePort(); + fs.writeFileSync(path.join(baseDir, "debug_token.txt"), "prov-secret", "utf8"); + fs.writeFileSync(path.join(baseDir, "debug_port.txt"), String(port), "utf8"); + fs.writeFileSync(path.join(baseDir, "debug_host.txt"), "127.0.0.1", "utf8"); + fs.writeFileSync(path.join(baseDir, "debug_allowlist.txt"), "", "utf8"); + startDebugServer({} as unknown as DownloadManager, baseDir); + await waitForReady(`http://127.0.0.1:${port}/health?token=prov-secret`); + + const provRes = await fetch(`http://127.0.0.1:${port}/providers?token=prov-secret`); + expect(provRes.status).toBe(200); + const prov = await provRes.json(); + expect(typeof prov.capturedAtMs).toBe("number"); + expect(prov.megaDebrid).toBeTruthy(); + expect(Array.isArray(prov.megaDebrid.accounts)).toBe(true); + expect(typeof prov.megaDebrid.rotationCursor).toBe("number"); + expect(prov.debridLink).toBeTruthy(); + expect(Array.isArray(prov.debridLink.keys)).toBe(true); + + const unauth = await fetch(`http://127.0.0.1:${port}/providers`); + expect(unauth.status).toBe(401); + + const convRes = await fetch(`http://127.0.0.1:${port}/logs/conversion?token=prov-secret`); + expect(convRes.status).toBe(200); + const conv = await convRes.json(); + expect(Array.isArray(conv.lines)).toBe(true); + expect(conv).toHaveProperty("available"); + }); +}); diff --git a/tests/bestdebrid-web.test.ts b/tests/bestdebrid-web.test.ts new file mode 100644 index 0000000..850c754 --- /dev/null +++ b/tests/bestdebrid-web.test.ts @@ -0,0 +1,167 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const { + mockCookiesSet, + mockFetch, + mockClearStorageData, + mockClearCache, + mockFromPartition, + mockSession +} = vi.hoisted(() => { + const cookiesSet = vi.fn(); + const fetch = vi.fn(); + const clearStorageData = vi.fn(); + const clearCache = vi.fn(); + const fromPartition = vi.fn(); + return { + mockCookiesSet: cookiesSet, + mockFetch: fetch, + mockClearStorageData: clearStorageData, + mockClearCache: clearCache, + mockFromPartition: fromPartition, + mockSession: { + cookies: { + set: cookiesSet + }, + fetch, + clearStorageData, + clearCache + } + }; +}); + +vi.mock("electron", () => ({ + session: { + fromPartition: mockFromPartition + } +})); + +vi.mock("../src/main/logger", () => ({ + logger: { + info: vi.fn(), + warn: vi.fn(), + error: vi.fn() + } +})); + +import { BestDebridWebFallback } from "../src/main/bestdebrid-web"; + +function createCookieFile(contents: string): string { + const filePath = path.join(os.tmpdir(), `bestdebrid-cookies-${Date.now()}-${Math.random().toString(16).slice(2)}.txt`); + fs.writeFileSync(filePath, contents, "utf8"); + return filePath; +} + +describe("bestdebrid-web", () => { + const tempFiles: string[] = []; + + beforeEach(() => { + mockFromPartition.mockReturnValue(mockSession); + }); + + afterEach(() => { + vi.clearAllMocks(); + mockFromPartition.mockReturnValue(mockSession); + while (tempFiles.length > 0) { + const filePath = tempFiles.pop(); + if (!filePath) { + continue; + } + try { + fs.rmSync(filePath, { force: true }); + } catch { + } + } + }); + + it("imports HttpOnly Netscape cookies instead of skipping them as comments", async () => { + const filePath = createCookieFile([ + "# Netscape HTTP Cookie File", + "#HttpOnly_.bestdebrid.com\tTRUE\t/\tTRUE\t1803585385\tPHPSESSID\tsecret-session", + ".bestdebrid.com\tTRUE\t/\tFALSE\t1806720721\t_ga\ttracking" + ].join("\n")); + tempFiles.push(filePath); + + const fallback = new BestDebridWebFallback(() => true); + const count = await fallback.importCookiesFromFile(filePath); + + expect(count).toBe(2); + expect(mockClearStorageData).toHaveBeenCalledTimes(1); + expect(mockClearStorageData).toHaveBeenCalledWith({ + storages: ["cookies", "indexdb", "localstorage", "serviceworkers", "cachestorage"] + }); + expect(mockCookiesSet).toHaveBeenCalledTimes(2); + expect(mockCookiesSet).toHaveBeenCalledWith(expect.objectContaining({ + name: "PHPSESSID", + domain: ".bestdebrid.com", + httpOnly: true, + secure: true + })); + }); + + it("deduplicates conflicting session cookies and prefers the HttpOnly variant", async () => { + const filePath = createCookieFile([ + "# Netscape HTTP Cookie File", + "bestdebrid.com\tFALSE\t/\tTRUE\t1803585384\tPHPSESSID\tnon-http-only", + "#HttpOnly_.bestdebrid.com\tTRUE\t/\tTRUE\t1803585385\tPHPSESSID\thttp-only" + ].join("\n")); + tempFiles.push(filePath); + + const fallback = new BestDebridWebFallback(() => true); + const count = await fallback.importCookiesFromFile(filePath); + + expect(count).toBe(1); + expect(mockCookiesSet).toHaveBeenCalledTimes(1); + expect(mockCookiesSet).toHaveBeenCalledWith(expect.objectContaining({ + name: "PHPSESSID", + value: "http-only", + httpOnly: true, + domain: ".bestdebrid.com" + })); + }); + + it("rejects cookie files that only contain tracking cookies", async () => { + const filePath = createCookieFile([ + "# Netscape HTTP Cookie File", + ".bestdebrid.com\tTRUE\t/\tTRUE\t1803585385\t__stripe_mid\tstripe", + ".bestdebrid.com\tTRUE\t/\tFALSE\t1806720721\t_ga\ttracking" + ].join("\n")); + tempFiles.push(filePath); + + const fallback = new BestDebridWebFallback(() => true); + + await expect(fallback.importCookiesFromFile(filePath)) + .rejects.toThrow("Login-Cookie"); + expect(mockCookiesSet).not.toHaveBeenCalled(); + }); + + it("treats BestDebrid free-user errors as logged-out sessions when the account page is guest-only", async () => { + const filePath = createCookieFile([ + "# Netscape HTTP Cookie File", + "bestdebrid.com\tFALSE\t/\tTRUE\t1803585385\tPHPSESSID\tsecret-session" + ].join("\n")); + tempFiles.push(filePath); + + mockFetch + .mockResolvedValueOnce(new Response(JSON.stringify({ + error: 1, + message: "Free users are not allowed to download using a VPN or proxy. Please purchase a premium plan." + }), { status: 200 })) + .mockResolvedValueOnce(new Response("
Guest
", { status: 200 })); + + const fallback = new BestDebridWebFallback(() => true); + await fallback.importCookiesFromFile(filePath); + + await expect(fallback.unrestrict("https://1fichier.com/?abc")) + .rejects.toThrow("Nicht eingeloggt"); + await expect(fallback.unrestrict("https://1fichier.com/?abc")) + .rejects.toThrow("Keine Cookies importiert"); + + expect(mockFetch).toHaveBeenCalledTimes(2); + expect(mockFetch.mock.calls[0]?.[0]).toBe("https://bestdebrid.com/api/v1/generateLink"); + expect(mockFetch.mock.calls[1]?.[0]).toBe("https://bestdebrid.com/en/downloader/"); + }); +}); diff --git a/tests/cleanup.test.ts b/tests/cleanup.test.ts new file mode 100644 index 0000000..49d3573 --- /dev/null +++ b/tests/cleanup.test.ts @@ -0,0 +1,100 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { cleanupCancelledPackageArtifacts, removeDownloadLinkArtifacts, removeSampleArtifacts } from "../src/main/cleanup"; + +const tempDirs: string[] = []; + +afterEach(() => { + for (const dir of tempDirs.splice(0)) { + fs.rmSync(dir, { recursive: true, force: true }); + } +}); + +describe("cleanup", () => { + it("removes archive artifacts but keeps media", () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-clean-")); + tempDirs.push(dir); + fs.writeFileSync(path.join(dir, "release.part1.rar"), "x"); + fs.writeFileSync(path.join(dir, "movie.mkv"), "x"); + + const removed = cleanupCancelledPackageArtifacts(dir); + expect(removed).toBeGreaterThan(0); + expect(fs.existsSync(path.join(dir, "release.part1.rar"))).toBe(false); + expect(fs.existsSync(path.join(dir, "movie.mkv"))).toBe(true); + }); + + it("removes sample artifacts and link files", async () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-clean-")); + tempDirs.push(dir); + fs.mkdirSync(path.join(dir, "Samples"), { recursive: true }); + fs.writeFileSync(path.join(dir, "Samples", "demo-sample.mkv"), "x"); + fs.writeFileSync(path.join(dir, "download_links.txt"), "https://example.com/a\n"); + + const links = await removeDownloadLinkArtifacts(dir); + const samples = await removeSampleArtifacts(dir); + expect(links).toBeGreaterThan(0); + expect(samples.files + samples.dirs).toBeGreaterThan(0); + }); + + it("cleans up archive files in nested directories", () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-clean-")); + tempDirs.push(dir); + + const sub1 = path.join(dir, "season1"); + const sub2 = path.join(dir, "season1", "extras"); + fs.mkdirSync(sub2, { recursive: true }); + + fs.writeFileSync(path.join(sub1, "episode.part1.rar"), "x"); + fs.writeFileSync(path.join(sub1, "episode.part2.rar"), "x"); + fs.writeFileSync(path.join(sub2, "bonus.zip"), "x"); + fs.writeFileSync(path.join(sub2, "bonus.7z"), "x"); + fs.writeFileSync(path.join(sub1, "video.mkv"), "real content"); + fs.writeFileSync(path.join(sub2, "subtitle.srt"), "subtitle content"); + + const removed = cleanupCancelledPackageArtifacts(dir); + expect(removed).toBe(4); + expect(fs.existsSync(path.join(sub1, "episode.part1.rar"))).toBe(false); + expect(fs.existsSync(path.join(sub1, "episode.part2.rar"))).toBe(false); + expect(fs.existsSync(path.join(sub2, "bonus.zip"))).toBe(false); + expect(fs.existsSync(path.join(sub2, "bonus.7z"))).toBe(false); + expect(fs.existsSync(path.join(sub1, "video.mkv"))).toBe(true); + expect(fs.existsSync(path.join(sub2, "subtitle.srt"))).toBe(true); + }); + + it("detects link artifacts by URL content in text files", async () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-clean-")); + tempDirs.push(dir); + + fs.writeFileSync(path.join(dir, "download_links.txt"), "https://rapidgator.net/file/abc123\nhttps://uploaded.net/file/def456\n"); + fs.writeFileSync(path.join(dir, "my_downloads.txt"), "Just some random text without URLs"); + fs.writeFileSync(path.join(dir, "readme.txt"), "https://example.com"); + fs.writeFileSync(path.join(dir, "bookmark.url"), "[InternetShortcut]\nURL=https://example.com"); + fs.writeFileSync(path.join(dir, "container.dlc"), "encrypted-data"); + + const removed = await removeDownloadLinkArtifacts(dir); + expect(removed).toBeGreaterThanOrEqual(3); + expect(fs.existsSync(path.join(dir, "download_links.txt"))).toBe(false); + expect(fs.existsSync(path.join(dir, "bookmark.url"))).toBe(false); + expect(fs.existsSync(path.join(dir, "container.dlc"))).toBe(false); + expect(fs.existsSync(path.join(dir, "readme.txt"))).toBe(true); + }); + + it("does not recurse into sample symlink or junction targets", async () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-clean-")); + const external = fs.mkdtempSync(path.join(os.tmpdir(), "rd-clean-ext-")); + tempDirs.push(dir, external); + + const outsideFile = path.join(external, "outside-sample.mkv"); + fs.writeFileSync(outsideFile, "keep", "utf8"); + + const linkedSampleDir = path.join(dir, "sample"); + const linkType: fs.symlink.Type = process.platform === "win32" ? "junction" : "dir"; + fs.symlinkSync(external, linkedSampleDir, linkType); + + const result = await removeSampleArtifacts(dir); + expect(result.files).toBe(0); + expect(fs.existsSync(outsideFile)).toBe(true); + }); +}); diff --git a/tests/connection-code.test.ts b/tests/connection-code.test.ts new file mode 100644 index 0000000..e8485f9 --- /dev/null +++ b/tests/connection-code.test.ts @@ -0,0 +1,54 @@ +import { describe, it, expect } from "vitest"; +import { encodeConnectionCode } from "../src/main/connection-code"; + +function decodeConnectionCode(code: string) { + const encoded = code.slice("rddiag:v1:".length).replace(/-/g, "+").replace(/_/g, "/"); + const payload = JSON.parse(Buffer.from(encoded, "base64").toString("utf8")) as Record; + return { + host: payload.h, + port: payload.p, + token: payload.t, + name: payload.n, + scheme: payload.s ?? "http", + fingerprint: payload.fp + }; +} + +describe("connection-code", () => { + it("encodes a directly decodable payload", () => { + const code = encodeConnectionCode({ host: "203.0.113.5", port: 9868, token: "deadbeef", name: "server-1" }); + expect(code.startsWith("rddiag:v1:")).toBe(true); + const decoded = decodeConnectionCode(code); + expect(decoded.host).toBe("203.0.113.5"); + expect(decoded.port).toBe(9868); + expect(decoded.token).toBe("deadbeef"); + expect(decoded.name).toBe("server-1"); + expect(decoded.scheme).toBe("http"); + }); + + it("carries https scheme and fingerprint when set", () => { + const code = encodeConnectionCode({ + host: "diag.example.com", + port: 8443, + token: "abc", + scheme: "https", + fingerprint: "AA:BB:CC" + }); + const decoded = decodeConnectionCode(code); + expect(decoded.scheme).toBe("https"); + expect(decoded.fingerprint).toBe("AA:BB:CC"); + }); + + it("omits scheme key for plain http (default)", () => { + const code = encodeConnectionCode({ host: "10.0.0.2", port: 9868, token: "t" }); + const json = JSON.parse(Buffer.from(code.slice("rddiag:v1:".length).replace(/-/g, "+").replace(/_/g, "/"), "base64").toString("utf8")); + expect(json.s).toBeUndefined(); + expect(json).toMatchObject({ v: 1, h: "10.0.0.2", p: 9868, t: "t" }); + }); + + it("rejects invalid input", () => { + expect(() => encodeConnectionCode({ host: "", port: 9868, token: "t" })).toThrow(); + expect(() => encodeConnectionCode({ host: "h", port: 0, token: "t" })).toThrow(); + expect(() => encodeConnectionCode({ host: "h", port: 9868, token: "" })).toThrow(); + }); +}); diff --git a/tests/container.test.ts b/tests/container.test.ts new file mode 100644 index 0000000..5424103 --- /dev/null +++ b/tests/container.test.ts @@ -0,0 +1,202 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { importDlcContainers } from "../src/main/container"; + +const tempDirs: string[] = []; +const originalFetch = globalThis.fetch; + +afterEach(() => { + globalThis.fetch = originalFetch; + vi.restoreAllMocks(); + for (const dir of tempDirs.splice(0)) { + fs.rmSync(dir, { recursive: true, force: true }); + } +}); + +describe("container", () => { + it("skips oversized DLC files without throwing and blocking other files", async () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dlc-")); + tempDirs.push(dir); + const oversizedFilePath = path.join(dir, "oversized.dlc"); + fs.writeFileSync(oversizedFilePath, Buffer.alloc((8 * 1024 * 1024) + 1, 1)); + + const validFilePath = path.join(dir, "valid.dlc"); + fs.writeFileSync(validFilePath, Buffer.from("Valid but not real DLC content...")); + + const fetchSpy = vi.fn(async (url: string | URL | Request) => { + const urlStr = String(url); + if (urlStr.includes("dcrypt.it/decrypt/upload")) { + return new Response("http://example.com/file1.rar\nhttp://example.com/file2.rar", { status: 200 }); + } + return new Response("", { status: 404 }); + }); + globalThis.fetch = fetchSpy as unknown as typeof fetch; + + const result = await importDlcContainers([oversizedFilePath, validFilePath]); + + expect(result).toHaveLength(1); + expect(result[0].name).toBe("valid"); + expect(result[0].links).toEqual(["http://example.com/file1.rar", "http://example.com/file2.rar"]); + expect(fetchSpy).toHaveBeenCalledTimes(1); + }); + + it("skips non-dlc files completely", async () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dlc-non-")); + tempDirs.push(dir); + const txtPath = path.join(dir, "links.txt"); + fs.writeFileSync(txtPath, "http://link.com/1"); + + const result = await importDlcContainers([txtPath]); + expect(result).toEqual([]); + }); + + it("falls back to dcrypt if local decryption returns empty", async () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dlc-")); + tempDirs.push(dir); + const filePath = path.join(dir, "fallback.dlc"); + + fs.writeFileSync(filePath, Buffer.alloc(100, 1).toString("base64")); + + const fetchSpy = vi.fn(async (url: string | URL | Request) => { + const urlStr = String(url); + if (urlStr.includes("service.jdownloader.org")) { + return new Response("", { status: 404 }); + } + if (urlStr.includes("dcrypt.it/decrypt/upload")) { + return new Response("http://fallback.com/1", { status: 200 }); + } + return new Response("", { status: 404 }); + }); + globalThis.fetch = fetchSpy as unknown as typeof fetch; + + const result = await importDlcContainers([filePath]); + expect(result).toHaveLength(1); + expect(result[0].name).toBe("fallback"); + expect(result[0].links).toEqual(["http://fallback.com/1"]); + expect(fetchSpy).toHaveBeenCalledTimes(2); + }); + + it("falls back to dcrypt when local decryption throws invalid padding", async () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dlc-")); + tempDirs.push(dir); + const filePath = path.join(dir, "invalid-local.dlc"); + fs.writeFileSync(filePath, "X".repeat(120)); + + const fetchSpy = vi.fn(async (url: string | URL | Request) => { + const urlStr = String(url); + if (urlStr.includes("service.jdownloader.org")) { + return new Response(`${Buffer.alloc(16).toString("base64")}`, { status: 200 }); + } + if (urlStr.includes("dcrypt.it/decrypt/upload")) { + return new Response("http://example.com/fallback1", { status: 200 }); + } + return new Response("", { status: 404 }); + }); + globalThis.fetch = fetchSpy as unknown as typeof fetch; + + const result = await importDlcContainers([filePath]); + expect(result).toHaveLength(1); + expect(result[0].name).toBe("invalid-local"); + expect(result[0].links).toEqual(["http://example.com/fallback1"]); + expect(fetchSpy).toHaveBeenCalledTimes(2); + }); + + it("falls back to paste endpoint when upload returns 413", async () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dlc-")); + tempDirs.push(dir); + const filePath = path.join(dir, "big-dlc.dlc"); + fs.writeFileSync(filePath, Buffer.alloc(100, 1).toString("base64")); + + const fetchSpy = vi.fn(async (url: string | URL | Request) => { + const urlStr = String(url); + if (urlStr.includes("service.jdownloader.org")) { + return new Response("", { status: 404 }); + } + if (urlStr.includes("dcrypt.it/decrypt/upload")) { + return new Response("Request Entity Too Large", { status: 413 }); + } + if (urlStr.includes("dcrypt.it/decrypt/paste")) { + return new Response("http://paste-fallback.com/file1.rar\nhttp://paste-fallback.com/file2.rar", { status: 200 }); + } + return new Response("", { status: 404 }); + }); + globalThis.fetch = fetchSpy as unknown as typeof fetch; + + const result = await importDlcContainers([filePath]); + expect(result).toHaveLength(1); + expect(result[0].name).toBe("big-dlc"); + expect(result[0].links).toEqual(["http://paste-fallback.com/file1.rar", "http://paste-fallback.com/file2.rar"]); + expect(fetchSpy).toHaveBeenCalledTimes(3); + }); + + it("throws when both dcrypt endpoints return 413", async () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dlc-")); + tempDirs.push(dir); + const filePath = path.join(dir, "huge.dlc"); + fs.writeFileSync(filePath, Buffer.alloc(100, 1).toString("base64")); + + const fetchSpy = vi.fn(async (url: string | URL | Request) => { + const urlStr = String(url); + if (urlStr.includes("service.jdownloader.org")) { + return new Response("", { status: 404 }); + } + if (urlStr.includes("dcrypt.it/decrypt/upload")) { + return new Response("Request Entity Too Large", { status: 413 }); + } + if (urlStr.includes("dcrypt.it/decrypt/paste")) { + return new Response("Request Entity Too Large", { status: 413 }); + } + return new Response("", { status: 500 }); + }); + globalThis.fetch = fetchSpy as unknown as typeof fetch; + + await expect(importDlcContainers([filePath])).rejects.toThrow(/zu groß für dcrypt/i); + }); + + it("throws when upload returns 413 and paste returns 500", async () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dlc-")); + tempDirs.push(dir); + const filePath = path.join(dir, "doomed.dlc"); + fs.writeFileSync(filePath, Buffer.from("not a valid dlc payload at all")); + + const fetchSpy = vi.fn(async (url: string | URL | Request) => { + const urlStr = String(url); + if (urlStr.includes("service.jdownloader.org")) { + return new Response("", { status: 404 }); + } + if (urlStr.includes("dcrypt.it/decrypt/upload")) { + return new Response("Request Entity Too Large", { status: 413 }); + } + if (urlStr.includes("dcrypt.it/decrypt/paste")) { + return new Response("paste failure", { status: 500 }); + } + return new Response("", { status: 500 }); + }); + globalThis.fetch = fetchSpy as unknown as typeof fetch; + + await expect(importDlcContainers([filePath])).rejects.toThrow(/DLC konnte nicht importiert werden/i); + }); + + it("throws clear error when all dlc imports fail", async () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dlc-")); + tempDirs.push(dir); + const filePath = path.join(dir, "broken.dlc"); + fs.writeFileSync(filePath, Buffer.from("not a valid dlc payload at all")); + + const fetchSpy = vi.fn(async (url: string | URL | Request) => { + const urlStr = String(url); + if (urlStr.includes("service.jdownloader.org")) { + return new Response("", { status: 404 }); + } + if (urlStr.includes("dcrypt.it/decrypt/upload")) { + return new Response("upstream failure", { status: 500 }); + } + return new Response("", { status: 500 }); + }); + globalThis.fetch = fetchSpy as unknown as typeof fetch; + + await expect(importDlcContainers([filePath])).rejects.toThrow(/DLC konnte nicht importiert werden/i); + }); +}); diff --git a/tests/conversion-trace.test.ts b/tests/conversion-trace.test.ts new file mode 100644 index 0000000..13ba546 --- /dev/null +++ b/tests/conversion-trace.test.ts @@ -0,0 +1,71 @@ +import { describe, expect, it } from "vitest"; +import { + formatConversionBlock, + hasActiveConversionTrace, + runWithConversionTrace, + traceConversionPhase, + type ConversionTrace +} from "../src/main/conversion-trace"; + +describe("formatConversionBlock", () => { + it("renders a header with verdict + total and one indented line per phase", () => { + const trace: ConversionTrace = { + startedAt: 1000, + itemId: "id1", + itemName: "tvs-foo.part5.rar", + link: "https://rapidgator.net/file/abc/tvs-foo.part5.rar.html", + providerOrder: "megadebrid-api,megadebrid-web", + notes: { slots: "conv2/dl6/max8" }, + phases: [ + { atMs: 0, phase: "chain-try", provider: "megadebrid-api" }, + { atMs: 5, phase: "token", provider: "megadebrid-api", account: "2/2(e3)", tokenState: "fresh", workMs: 812, outcome: "ok" }, + { atMs: 820, phase: "api-getlink", provider: "megadebrid-api", account: "2/2(e3)", workMs: 634, outcome: "ok" } + ] + }; + const block = formatConversionBlock(trace, "OK", "", 1450); + + const lines = block.split("\n"); + expect(lines[0]).toContain("[CONV]"); + expect(lines[0]).toContain("item=tvs-foo.part5.rar"); + expect(lines[0]).toContain("result=OK"); + expect(lines[0]).toContain("total=1450ms"); + expect(lines[0]).toContain("slots=conv2/dl6/max8"); + expect(lines).toHaveLength(4); + expect(lines[2]).toContain("+5ms token"); + expect(lines[2]).toContain("token=fresh"); + expect(lines[2]).toContain("workMs=812"); + }); + + it("includes the failure detail in the header verdict", () => { + const trace: ConversionTrace = { + startedAt: 0, itemId: "i", itemName: "x", link: "l", providerOrder: "megadebrid-web", notes: {}, + phases: [{ atMs: 60000, phase: "caller-timeout", provider: "megadebrid-web", outcome: "timeout", detail: "Unrestrict Timeout nach 60s" }] + }; + const block = formatConversionBlock(trace, "FAIL", "Unrestrict Timeout nach 60s", 60003); + expect(block.split("\n")[0]).toContain("result=FAIL (Unrestrict Timeout nach 60s)"); + expect(block).toContain("caller-timeout"); + }); +}); + +describe("conversion trace context", () => { + it("traceConversionPhase is a no-op outside an active trace and does not throw", () => { + expect(hasActiveConversionTrace()).toBe(false); + expect(() => traceConversionPhase({ phase: "orphan" })).not.toThrow(); + }); + + it("activates an ambient trace across awaits inside runWithConversionTrace", async () => { + expect(hasActiveConversionTrace()).toBe(false); + const seen = await runWithConversionTrace( + { itemId: "i", itemName: "n", link: "l", providerOrder: "megadebrid-api" }, + async () => { + const before = hasActiveConversionTrace(); + traceConversionPhase({ phase: "chain-try", provider: "megadebrid-api" }); + await Promise.resolve(); + const afterAwait = hasActiveConversionTrace(); + return before && afterAwait; + } + ); + expect(seen).toBe(true); + expect(hasActiveConversionTrace()).toBe(false); + }); +}); diff --git a/tests/debrid.test.ts b/tests/debrid.test.ts new file mode 100644 index 0000000..cea4a5b --- /dev/null +++ b/tests/debrid.test.ts @@ -0,0 +1,2813 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { defaultSettings, REQUEST_RETRIES } from "../src/main/constants"; +import { parseDebridLinkApiKeys } from "../src/shared/debrid-link-keys"; +import { getMegaDebridAccountId } from "../src/shared/mega-debrid-accounts"; +import { getProviderUsageDayKey } from "../src/shared/provider-daily-limits"; +import { isMegaDebridTransientResolveFailure } from "../src/shared/mega-debrid-errors"; +import { classifyMegaDebridAccountFailureForTests, clearMegaDebridEmptyResponseStreak, DebridService, extractRapidgatorFilenameFromHtml, fetchAllDebridHostInfo, fetchDebridLinkHostLimits, filenameFromRapidgatorUrlPath, getDebridLinkKeyCooldownStateForTests, getDebridLinkKeyRuntimeStateForTests, getMegaDebridAccountCooldownState, getProviderRuntimeSnapshot, leadProviderChainWith, MEGA_DEBRID_EMPTY_STREAK_UNTIL_RESTART, MEGA_DEBRID_STICKY_LINKS, normalizeResolvedFilename, primeMegaDebridRuntimeCooldownForTests, primeMegaDebridUntilRestartForTests, recordMegaDebridEmptyResponseStreak, resetDebridLinkRuntimeStateForTests, resetMegaDebridRuntimeStateForTests } from "../src/main/debrid"; + +const originalFetch = globalThis.fetch; + +afterEach(() => { + globalThis.fetch = originalFetch; + resetDebridLinkRuntimeStateForTests(); + resetMegaDebridRuntimeStateForTests(); + delete process.env.RD_MEGA_ABORT_MIN_RUN_MS; + vi.restoreAllMocks(); +}); + +describe("leadProviderChainWith", () => { + it("leaves the order unchanged when no preferred provider is given", () => { + expect(leadProviderChainWith(["realdebrid", "debridlink", "alldebrid"], null)).toEqual(["realdebrid", "debridlink", "alldebrid"]); + expect(leadProviderChainWith(["realdebrid", "debridlink"], undefined)).toEqual(["realdebrid", "debridlink"]); + }); + + it("leads with the preferred provider but keeps every other provider as a later fallback", () => { + expect(leadProviderChainWith(["realdebrid", "debridlink", "alldebrid"], "alldebrid")).toEqual(["alldebrid", "realdebrid", "debridlink"]); + }); + + it("does not drop or strand any provider when the preferred one is not in the order", () => { + expect(leadProviderChainWith(["realdebrid", "debridlink"], "alldebrid")).toEqual(["realdebrid", "debridlink"]); + }); +}); + +describe("debrid service", () => { + it("falls back to Mega web when Real-Debrid fails", async () => { + const settings = { + ...defaultSettings(), + token: "rd-token", + megaLogin: "user", + megaPassword: "pass", + megaCredentials: "user:pass", + bestToken: "", + providerOrder: [] as const, + providerPrimary: "realdebrid" as const, + providerSecondary: "megadebrid" as const, + providerTertiary: "bestdebrid" as const, + autoProviderFallback: true + }; + + globalThis.fetch = (async (input: RequestInfo | URL): Promise => { + const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url; + if (url.includes("api.real-debrid.com/rest/1.0/unrestrict/link")) { + return new Response(JSON.stringify({ error: "traffic_limit" }), { + status: 403, + headers: { "Content-Type": "application/json" } + }); + } + return new Response("not-found", { status: 404 }); + }) as typeof fetch; + + const megaWeb = vi.fn(async () => ({ + fileName: "file.bin", + directUrl: "https://mega-web.example/file.bin", + fileSize: null, + retriesUsed: 0 + })); + + const service = new DebridService(settings, { megaWebUnrestrict: megaWeb }); + const result = await service.unrestrictLink("https://rapidgator.net/file/example.part1.rar.html"); + expect(result.provider).toBe("megadebrid"); + expect(result.directUrl).toBe("https://mega-web.example/file.bin"); + expect(megaWeb).toHaveBeenCalledTimes(1); + }); + + it("does not fallback when auto fallback is disabled", async () => { + const settings = { + ...defaultSettings(), + token: "rd-token", + megaLogin: "user", + megaPassword: "pass", + megaCredentials: "user:pass", + providerPrimary: "realdebrid" as const, + providerSecondary: "megadebrid" as const, + providerTertiary: "bestdebrid" as const, + autoProviderFallback: false + }; + + globalThis.fetch = (async (input: RequestInfo | URL): Promise => { + const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url; + if (url.includes("api.real-debrid.com/rest/1.0/unrestrict/link")) { + return new Response("traffic exhausted", { status: 429 }); + } + return new Response("not-found", { status: 404 }); + }) as typeof fetch; + + const megaWeb = vi.fn(async () => ({ + fileName: "unused.bin", + directUrl: "https://unused", + fileSize: null, + retriesUsed: 0 + })); + + const service = new DebridService(settings, { megaWebUnrestrict: megaWeb }); + await expect(service.unrestrictLink("https://rapidgator.net/file/example.part2.rar.html")).rejects.toThrow(); + expect(megaWeb).toHaveBeenCalledTimes(0); + }); + + it("skips a provider whose daily limit is already reached and uses the next provider", async () => { + const calledUrls: string[] = []; + const settings = { + ...defaultSettings(), + token: "rd-token", + debridLinkApiKeys: "dl-token", + providerOrder: ["realdebrid", "debridlink"] as const, + providerPrimary: "realdebrid" as const, + providerSecondary: "debridlink" as const, + providerTertiary: "none" as const, + autoProviderFallback: true, + providerDailyLimitBytes: { realdebrid: 100 }, + providerDailyUsageBytes: { realdebrid: 100 }, + providerDailyUsageDay: getProviderUsageDayKey() + }; + + globalThis.fetch = (async (input: RequestInfo | URL): Promise => { + const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url; + calledUrls.push(url); + if (url.includes("debrid-link.com/api/v2/downloader/add")) { + return new Response(JSON.stringify({ + success: true, + value: { + downloadUrl: "https://debrid-link.example/file.bin", + name: "file.bin", + size: 1234 + } + }), { + status: 200, + headers: { "Content-Type": "application/json" } + }); + } + if (url.includes("api.real-debrid.com/rest/1.0/unrestrict/link")) { + throw new Error("Real-Debrid should have been skipped due to daily limit"); + } + return new Response("not-found", { status: 404 }); + }) as typeof fetch; + + const service = new DebridService(settings); + const result = await service.unrestrictLink("https://hoster.example/file.bin"); + expect(result.provider).toBe("debridlink"); + expect(result.directUrl).toBe("https://debrid-link.example/file.bin"); + expect(calledUrls.some((url) => url.includes("api.real-debrid.com/rest/1.0/unrestrict/link"))).toBe(false); + }); + + it("leads the provider chain with the preferred (non-cooled) provider when one is hinted", async () => { + const settings = { + ...defaultSettings(), + token: "rd-token", + debridLinkApiKeys: "dl-token", + providerOrder: ["realdebrid", "debridlink"] as const, + providerPrimary: "realdebrid" as const, + providerSecondary: "debridlink" as const, + providerTertiary: "none" as const, + autoProviderFallback: true + }; + + globalThis.fetch = (async (input: RequestInfo | URL): Promise => { + const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url; + if (url.includes("debrid-link.com/api/v2/downloader/add")) { + return new Response(JSON.stringify({ + success: true, + value: { downloadUrl: "https://debrid-link.example/file.bin", name: "file.bin", size: 1234 } + }), { status: 200, headers: { "Content-Type": "application/json" } }); + } + if (url.includes("api.real-debrid.com/rest/1.0/unrestrict/link")) { + return new Response(JSON.stringify({ + download: "https://rd.example/file.bin", + filename: "file.bin", + filesize: 1234 + }), { status: 200, headers: { "Content-Type": "application/json" } }); + } + return new Response("not-found", { status: 404 }); + }) as typeof fetch; + + const service = new DebridService(settings); + const control = await service.unrestrictLink("https://hoster.example/lead-control.bin"); + expect(control.provider).toBe("realdebrid"); + + const preferred = await service.unrestrictLink("https://hoster.example/lead-pref.bin", undefined, undefined, "debridlink"); + expect(preferred.provider).toBe("debridlink"); + }); + + it("uses the next Debrid-Link key when the first key hit its local daily limit", async () => { + const keys = parseDebridLinkApiKeys("dl-key-one\ndl-key-two"); + let usedAuthHeader = ""; + const settings = { + ...defaultSettings(), + debridLinkApiKeys: "dl-key-one\ndl-key-two", + providerOrder: ["debridlink"] as const, + providerPrimary: "debridlink" as const, + providerSecondary: "none" as const, + providerTertiary: "none" as const, + debridLinkApiKeyDailyLimitBytes: { + [keys[0].id]: 100 + }, + debridLinkApiKeyDailyUsageBytes: { + [keys[0].id]: 100 + }, + providerDailyUsageDay: getProviderUsageDayKey() + }; + + globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit): Promise => { + const headers = init?.headers; + if (headers instanceof Headers) { + usedAuthHeader = headers.get("Authorization") || ""; + } else if (Array.isArray(headers)) { + usedAuthHeader = headers.find(([key]) => key.toLowerCase() === "authorization")?.[1] || ""; + } else { + usedAuthHeader = String((headers as Record | undefined)?.Authorization || ""); + } + return new Response(JSON.stringify({ + success: true, + value: { + downloadUrl: "https://debrid-link.example/file.bin", + name: "file.bin", + size: 1234 + } + }), { + status: 200, + headers: { "Content-Type": "application/json" } + }); + }) as typeof fetch; + + const service = new DebridService(settings); + const result = await service.unrestrictLink("https://hoster.example/file.bin"); + + expect(usedAuthHeader).toBe("Bearer dl-key-two"); + expect(result.provider).toBe("debridlink"); + expect(result.providerLabel).toContain("Key 2"); + }); + + it("uses JSON add payload and refreshes missing Debrid-Link downloadUrl via downloader/list", async () => { + const settings = { + ...defaultSettings(), + debridLinkApiKeys: "dl-key-one", + providerOrder: ["debridlink"] as const, + providerPrimary: "debridlink" as const, + providerSecondary: "none" as const, + providerTertiary: "none" as const, + autoProviderFallback: true + }; + + let addBody = ""; + let addContentType = ""; + let addAccept = ""; + const calledUrls: string[] = []; + + globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit): Promise => { + const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url; + calledUrls.push(url); + if (url.includes("debrid-link.com/api/v2/downloader/add")) { + const headers = init?.headers; + if (headers instanceof Headers) { + addContentType = headers.get("Content-Type") || ""; + addAccept = headers.get("Accept") || ""; + } else if (Array.isArray(headers)) { + addContentType = headers.find(([key]) => key.toLowerCase() === "content-type")?.[1] || ""; + addAccept = headers.find(([key]) => key.toLowerCase() === "accept")?.[1] || ""; + } else { + addContentType = String((headers as Record | undefined)?.["Content-Type"] || ""); + addAccept = String((headers as Record | undefined)?.Accept || ""); + } + addBody = String(init?.body || ""); + return new Response(JSON.stringify({ + success: true, + value: { + id: "dl-link-1", + url: "https://hoster.example/file.bin", + name: "file.bin", + expired: true + } + }), { + status: 200, + headers: { "Content-Type": "application/json" } + }); + } + if (url.includes("debrid-link.com/api/v2/downloader/list?ids=dl-link-1")) { + return new Response(JSON.stringify({ + success: true, + value: [ + { + id: "dl-link-1", + url: "https://hoster.example/file.bin", + name: "file.bin", + downloadUrl: "https://debrid-link.example/file.bin", + size: 1234, + expired: false + } + ] + }), { + status: 200, + headers: { "Content-Type": "application/json" } + }); + } + return new Response("not-found", { status: 404 }); + }) as typeof fetch; + + const service = new DebridService(settings); + const result = await service.unrestrictLink("https://hoster.example/file.bin"); + + expect(addContentType).toBe("application/json"); + expect(addAccept).toBe("application/json"); + expect(addBody).toBe(JSON.stringify({ url: "https://hoster.example/file.bin" })); + expect(result.provider).toBe("debridlink"); + expect(result.directUrl).toBe("https://debrid-link.example/file.bin"); + expect(calledUrls.some((url) => url.includes("debrid-link.com/api/v2/downloader/list?ids=dl-link-1"))).toBe(true); + }); + + it("rotates to the next Debrid-Link key when the first key is invalid", async () => { + const settings = { + ...defaultSettings(), + debridLinkApiKeys: "dl-key-one\ndl-key-two", + providerOrder: ["debridlink"] as const, + providerPrimary: "debridlink" as const, + providerSecondary: "none" as const, + providerTertiary: "none" as const, + autoProviderFallback: true + }; + + const authHeaders: string[] = []; + + globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit): Promise => { + const headers = init?.headers; + let authHeader = ""; + if (headers instanceof Headers) { + authHeader = headers.get("Authorization") || ""; + } else if (Array.isArray(headers)) { + authHeader = headers.find(([key]) => key.toLowerCase() === "authorization")?.[1] || ""; + } else { + authHeader = String((headers as Record | undefined)?.Authorization || ""); + } + authHeaders.push(authHeader); + if (authHeader === "Bearer dl-key-one") { + return new Response(JSON.stringify({ + success: false, + error: "badToken", + error_description: "token expired" + }), { + status: 401, + headers: { "Content-Type": "application/json" } + }); + } + return new Response(JSON.stringify({ + success: true, + value: { + downloadUrl: "https://debrid-link.example/valid.bin", + name: "valid.bin", + size: 2048 + } + }), { + status: 200, + headers: { "Content-Type": "application/json" } + }); + }) as typeof fetch; + + const service = new DebridService(settings); + const result = await service.unrestrictLink("https://hoster.example/needs-rotation.bin"); + + expect(authHeaders).toEqual(["Bearer dl-key-one", "Bearer dl-key-two"]); + expect(result.provider).toBe("debridlink"); + expect(result.providerLabel).toContain("Key 2"); + expect(result.directUrl).toBe("https://debrid-link.example/valid.bin"); + }); + + it("looks up limits and rotates keys when Debrid-Link host quota is reached", async () => { + const settings = { + ...defaultSettings(), + debridLinkApiKeys: "dl-key-one\ndl-key-two", + providerOrder: ["debridlink"] as const, + providerPrimary: "debridlink" as const, + providerSecondary: "none" as const, + providerTertiary: "none" as const, + autoProviderFallback: true + }; + + let limitCalls = 0; + const authHeaders: string[] = []; + + globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit): Promise => { + const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url; + const headers = init?.headers; + let authHeader = ""; + if (headers instanceof Headers) { + authHeader = headers.get("Authorization") || ""; + } else if (Array.isArray(headers)) { + authHeader = headers.find(([key]) => key.toLowerCase() === "authorization")?.[1] || ""; + } else { + authHeader = String((headers as Record | undefined)?.Authorization || ""); + } + + if (url.includes("debrid-link.com/api/v2/downloader/limits")) { + limitCalls += 1; + return new Response(JSON.stringify({ + success: true, + value: { + nextResetSeconds: { value: 900 } + } + }), { + status: 200, + headers: { "Content-Type": "application/json" } + }); + } + + authHeaders.push(authHeader); + if (authHeader === "Bearer dl-key-one") { + return new Response(JSON.stringify({ + success: false, + error: "maxDataHost", + error_description: "host quota reached" + }), { + status: 403, + headers: { "Content-Type": "application/json" } + }); + } + + return new Response(JSON.stringify({ + success: true, + value: { + downloadUrl: "https://debrid-link.example/quota-ok.bin", + name: "quota-ok.bin", + size: 4096 + } + }), { + status: 200, + headers: { "Content-Type": "application/json" } + }); + }) as typeof fetch; + + const service = new DebridService(settings); + const result = await service.unrestrictLink("https://rapidgator.net/file/quota-test"); + + expect(limitCalls).toBe(1); + expect(authHeaders).toEqual(["Bearer dl-key-one", "Bearer dl-key-two"]); + expect(result.provider).toBe("debridlink"); + expect(result.providerLabel).toContain("Key 2"); + expect(result.directUrl).toBe("https://debrid-link.example/quota-ok.bin"); + }); + + it("scopes Debrid-Link maxDataHost cooldown to the (key, host) pair so the key stays usable for other hosters", async () => { + const settings = { + ...defaultSettings(), + debridLinkApiKeys: "dl-key-one\ndl-key-two", + providerOrder: ["debridlink"] as const, + providerPrimary: "debridlink" as const, + providerSecondary: "none" as const, + providerTertiary: "none" as const, + autoProviderFallback: true + }; + + const unrestrictAuthHeaders: string[] = []; + + globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit): Promise => { + const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url; + const headers = init?.headers; + let authHeader = ""; + if (headers instanceof Headers) { + authHeader = headers.get("Authorization") || ""; + } else if (Array.isArray(headers)) { + authHeader = headers.find(([key]) => key.toLowerCase() === "authorization")?.[1] || ""; + } else { + authHeader = String((headers as Record | undefined)?.Authorization || ""); + } + + if (url.includes("debrid-link.com/api/v2/downloader/limits")) { + return new Response(JSON.stringify({ + success: true, + value: { nextResetSeconds: { value: 900 } } + }), { + status: 200, + headers: { "Content-Type": "application/json" } + }); + } + + if (url.includes("/downloader/add")) { + unrestrictAuthHeaders.push(authHeader); + const bodyText = init?.body ? String(init.body) : ""; + const isRapidgator = /rapidgator/i.test(bodyText); + if (authHeader === "Bearer dl-key-one" && isRapidgator) { + return new Response(JSON.stringify({ + success: false, + error: "maxDataHost", + error_description: "host quota reached" + }), { status: 403, headers: { "Content-Type": "application/json" } }); + } + return new Response(JSON.stringify({ + success: true, + value: { + downloadUrl: `https://debrid-link.example/${authHeader.slice(-3)}-${isRapidgator ? "rg" : "ot"}.bin`, + name: "ok.bin", + size: 1024 + } + }), { status: 200, headers: { "Content-Type": "application/json" } }); + } + + return new Response("not-found", { status: 404 }); + }) as typeof fetch; + + const service = new DebridService(settings); + + const r1 = await service.unrestrictLink("https://rapidgator.net/file/first"); + expect(r1.providerLabel).toContain("Key 2"); + + unrestrictAuthHeaders.length = 0; + const r2 = await service.unrestrictLink("https://rapidgator.net/file/second"); + expect(unrestrictAuthHeaders).toEqual(["Bearer dl-key-two"]); + expect(r2.providerLabel).toContain("Key 2"); + + unrestrictAuthHeaders.length = 0; + const r3 = await service.unrestrictLink("https://uploaded.net/file/third"); + expect(unrestrictAuthHeaders).toEqual(["Bearer dl-key-one"]); + expect(r3.providerLabel).toContain("Key 1"); + }); + + it("does not mark Debrid-Link key as errored when the API returns fileNotAvailable (link-level, not key-level)", async () => { + const settings = { + ...defaultSettings(), + debridLinkApiKeys: "dl-key-one\ndl-key-two", + providerOrder: ["debridlink"] as const, + providerPrimary: "debridlink" as const, + providerSecondary: "none" as const, + providerTertiary: "none" as const, + autoProviderFallback: true + }; + + globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit): Promise => { + const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url; + const headers = init?.headers; + let authHeader = ""; + if (headers instanceof Headers) { + authHeader = headers.get("Authorization") || ""; + } else if (Array.isArray(headers)) { + authHeader = headers.find(([key]) => key.toLowerCase() === "authorization")?.[1] || ""; + } else { + authHeader = String((headers as Record | undefined)?.Authorization || ""); + } + + if (!url.includes("/downloader/add")) { + return new Response("not-found", { status: 404 }); + } + if (authHeader === "Bearer dl-key-one") { + return new Response(JSON.stringify({ + success: false, + error: "fileNotAvailable", + error_description: "link is currently not available" + }), { status: 403, headers: { "Content-Type": "application/json" } }); + } + return new Response(JSON.stringify({ + success: true, + value: { + downloadUrl: "https://debrid-link.example/ok.bin", + name: "ok.bin", + size: 1024 + } + }), { status: 200, headers: { "Content-Type": "application/json" } }); + }) as typeof fetch; + + const key1Id = parseDebridLinkApiKeys("dl-key-one")[0].id; + const key2Id = parseDebridLinkApiKeys("dl-key-two")[0].id; + + const service = new DebridService(settings); + const result = await service.unrestrictLink("https://rapidgator.net/file/example"); + expect(result.providerLabel).toContain("Key 2"); + + expect(getDebridLinkKeyRuntimeStateForTests(key1Id)).not.toBe("error"); + expect(getDebridLinkKeyRuntimeStateForTests(key2Id)).toBe("ready"); + }); + + it("does NOT cool down a Debrid-Link key on a quick user-cancel abort (below the min-run threshold)", async () => { + const settings = { + ...defaultSettings(), + token: "", + bestToken: "", + allDebridToken: "", + megaLogin: "", + megaPassword: "", + megaCredentials: "", + debridLinkApiKeys: "dl-key-one", + providerOrder: ["debridlink"] as const, + providerPrimary: "debridlink" as const, + providerSecondary: "none" as const, + providerTertiary: "none" as const, + autoProviderFallback: false + }; + const controller = new AbortController(); + globalThis.fetch = (async (input: RequestInfo | URL): Promise => { + const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url; + if (url.includes("/downloader/add")) { + controller.abort(); + throw new Error("aborted"); + } + return new Response("not-found", { status: 404 }); + }) as typeof fetch; + + const keyId = parseDebridLinkApiKeys("dl-key-one")[0].id; + const service = new DebridService(settings); + await expect( + service.unrestrictLink("https://rapidgator.net/file/dl-quick-cancel", controller.signal) + ).rejects.toThrow(); + + expect(getDebridLinkKeyCooldownStateForTests(keyId)).toBeNull(); + }); + + it("cools down a Debrid-Link key on an abort that ran long enough (retry rotates to the next key)", async () => { + process.env.RD_MEGA_ABORT_MIN_RUN_MS = "0"; + const settings = { + ...defaultSettings(), + token: "", + bestToken: "", + allDebridToken: "", + megaLogin: "", + megaPassword: "", + megaCredentials: "", + debridLinkApiKeys: "dl-key-one", + providerOrder: ["debridlink"] as const, + providerPrimary: "debridlink" as const, + providerSecondary: "none" as const, + providerTertiary: "none" as const, + autoProviderFallback: false + }; + const controller = new AbortController(); + globalThis.fetch = (async (input: RequestInfo | URL): Promise => { + const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url; + if (url.includes("/downloader/add")) { + controller.abort(); + throw new Error("aborted"); + } + return new Response("not-found", { status: 404 }); + }) as typeof fetch; + + const keyId = parseDebridLinkApiKeys("dl-key-one")[0].id; + const service = new DebridService(settings); + await expect( + service.unrestrictLink("https://rapidgator.net/file/dl-long-abort", controller.signal) + ).rejects.toThrow(); + + const cooldown = getDebridLinkKeyCooldownStateForTests(keyId); + expect(cooldown?.remainingMs ?? 0).toBeGreaterThan(60_000); + }); + + it("treats bad Debrid-Link file passwords as fatal and does not rotate keys", async () => { + const settings = { + ...defaultSettings(), + debridLinkApiKeys: "dl-key-one\ndl-key-two", + providerOrder: ["debridlink"] as const, + providerPrimary: "debridlink" as const, + providerSecondary: "none" as const, + providerTertiary: "none" as const, + autoProviderFallback: true + }; + + const authHeaders: string[] = []; + + globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit): Promise => { + const headers = init?.headers; + let authHeader = ""; + if (headers instanceof Headers) { + authHeader = headers.get("Authorization") || ""; + } else if (Array.isArray(headers)) { + authHeader = headers.find(([key]) => key.toLowerCase() === "authorization")?.[1] || ""; + } else { + authHeader = String((headers as Record | undefined)?.Authorization || ""); + } + authHeaders.push(authHeader); + return new Response(JSON.stringify({ + success: false, + error: "badFilePassword", + error_description: "wrong password" + }), { + status: 400, + headers: { "Content-Type": "application/json" } + }); + }) as typeof fetch; + + const service = new DebridService(settings); + await expect(service.unrestrictLink("https://hoster.example/protected.bin")).rejects.toThrow("wrong password"); + expect(authHeaders).toEqual(["Bearer dl-key-one"]); + }); + + it("returns a cooldown marker when all Debrid-Link keys are temporarily cooling down", async () => { + const settings = { + ...defaultSettings(), + debridLinkApiKeys: "dl-key-one\ndl-key-two", + providerOrder: ["debridlink"] as const, + providerPrimary: "debridlink" as const, + providerSecondary: "none" as const, + providerTertiary: "none" as const, + autoProviderFallback: true + }; + + let addCalls = 0; + + globalThis.fetch = (async (input: RequestInfo | URL): Promise => { + const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url; + if (!url.includes("debrid-link.com/api/v2/downloader/add")) { + return new Response("not-found", { status: 404 }); + } + addCalls += 1; + return new Response(JSON.stringify({ + success: false, + error: "floodDetected", + error_description: "too many requests" + }), { + status: 403, + headers: { "Content-Type": "application/json" } + }); + }) as typeof fetch; + + const service = new DebridService(settings); + await expect(service.unrestrictLink("https://hoster.example/cooldown.bin")).rejects.toThrow("API-Rate-Limit erreicht"); + await expect(service.unrestrictLink("https://hoster.example/cooldown.bin")).rejects.toThrow(/debrid_link_cooldown:\d+:/i); + expect(addCalls).toBe(2); + }); + + it("returns an invalid-all marker when all Debrid-Link keys are invalid", async () => { + const settings = { + ...defaultSettings(), + debridLinkApiKeys: "dl-key-one\ndl-key-two", + providerOrder: ["debridlink"] as const, + providerPrimary: "debridlink" as const, + providerSecondary: "none" as const, + providerTertiary: "none" as const, + autoProviderFallback: true + }; + + const authHeaders: string[] = []; + + globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit): Promise => { + const headers = init?.headers; + let authHeader = ""; + if (headers instanceof Headers) { + authHeader = headers.get("Authorization") || ""; + } else if (Array.isArray(headers)) { + authHeader = headers.find(([key]) => key.toLowerCase() === "authorization")?.[1] || ""; + } else { + authHeader = String((headers as Record | undefined)?.Authorization || ""); + } + authHeaders.push(authHeader); + return new Response(JSON.stringify({ + success: false, + error: "badToken", + error_description: "token expired" + }), { + status: 401, + headers: { "Content-Type": "application/json" } + }); + }) as typeof fetch; + + const service = new DebridService(settings); + await expect(service.unrestrictLink("https://hoster.example/all-invalid.bin")).rejects.toThrow(/debrid_link_invalid_all:/i); + expect(authHeaders).toEqual(["Bearer dl-key-one", "Bearer dl-key-two"]); + }); + + it("returns a clear error when all Debrid-Link keys are locally exhausted", async () => { + const keys = parseDebridLinkApiKeys("dl-key-one\ndl-key-two"); + const settings = { + ...defaultSettings(), + debridLinkApiKeys: "dl-key-one\ndl-key-two", + providerOrder: ["debridlink"] as const, + providerPrimary: "debridlink" as const, + providerSecondary: "none" as const, + providerTertiary: "none" as const, + debridLinkApiKeyDailyLimitBytes: { + [keys[0].id]: 100, + [keys[1].id]: 100 + }, + debridLinkApiKeyDailyUsageBytes: { + [keys[0].id]: 100, + [keys[1].id]: 100 + }, + providerDailyUsageDay: getProviderUsageDayKey() + }; + + const service = new DebridService(settings); + await expect(service.unrestrictLink("https://hoster.example/no-key-left.bin")).rejects.toThrow(/debrid-link nicht verfuegbar|kein aktiver api-key/i); + }); + + it("stops rotation immediately on Debrid-Link notDebrid (provider-wide) — does NOT burn remaining keys", async () => { + const settings = { + ...defaultSettings(), + debridLinkApiKeys: "dl-key-one\ndl-key-two", + providerOrder: ["debridlink"] as const, + providerPrimary: "debridlink" as const, + providerSecondary: "none" as const, + providerTertiary: "none" as const, + autoProviderFallback: true + }; + + const authHeaders: string[] = []; + + globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit): Promise => { + authHeaders.push(String((init?.headers as Record | undefined)?.Authorization || "")); + return new Response(JSON.stringify({ + success: false, + error: "notDebrid", + error_description: "notDebrid" + }), { + status: 403, + headers: { "Content-Type": "application/json" } + }); + }) as typeof fetch; + + const service = new DebridService(settings); + await expect(service.unrestrictLink("https://hoster.example/not-debrid.bin")).rejects.toThrow(/debrid_link_cooldown.*notDebrid/); + expect(authHeaders).toEqual(["Bearer dl-key-one"]); + }); + + it("continues to the next Debrid-Link key for non-provider-wide skip errors without caching a cooldown", async () => { + const settings = { + ...defaultSettings(), + debridLinkApiKeys: "dl-key-one\ndl-key-two", + providerOrder: ["debridlink"] as const, + providerPrimary: "debridlink" as const, + providerSecondary: "none" as const, + providerTertiary: "none" as const, + autoProviderFallback: true + }; + + const authHeaders: string[] = []; + + globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit): Promise => { + const authHeader = String((init?.headers as Record | undefined)?.Authorization || ""); + authHeaders.push(authHeader); + if (authHeader === "Bearer dl-key-one") { + return new Response(JSON.stringify({ + success: false, + error: "noServerHost", + error_description: "host temporarily unavailable" + }), { + status: 403, + headers: { "Content-Type": "application/json" } + }); + } + return new Response(JSON.stringify({ + success: true, + value: { + downloadUrl: "https://debrid-link.example/second-key.bin", + name: "second-key.bin", + size: 4096 + } + }), { + status: 200, + headers: { "Content-Type": "application/json" } + }); + }) as typeof fetch; + + const service = new DebridService(settings); + const result = await service.unrestrictLink("https://hoster.example/skip-key.bin"); + expect(result.directUrl).toBe("https://debrid-link.example/second-key.bin"); + expect(result.sourceAccountLabel).toBe("Key 2"); + expect(authHeaders).toEqual(["Bearer dl-key-one", "Bearer dl-key-two"]); + }); + + it("uses BestDebrid auth header without token query fallback", async () => { + const settings = { + ...defaultSettings(), + token: "", + bestToken: "best-token", + providerPrimary: "bestdebrid" as const, + providerSecondary: "realdebrid" as const, + providerTertiary: "megadebrid" as const, + autoProviderFallback: true + }; + + const calledUrls: string[] = []; + globalThis.fetch = (async (input: RequestInfo | URL): Promise => { + const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url; + calledUrls.push(url); + if (url.includes("/api/v1/generateLink?link=")) { + return new Response(JSON.stringify({ download: "https://best.example/file.bin", filename: "file.bin", filesize: 2048 }), { + status: 200, + headers: { "Content-Type": "application/json" } + }); + } + return new Response("not-found", { status: 404 }); + }) as typeof fetch; + + const service = new DebridService(settings); + const result = await service.unrestrictLink("https://rapidgator.net/file/example.part3.rar.html"); + expect(result.provider).toBe("bestdebrid"); + expect(result.fileSize).toBe(2048); + expect(calledUrls.some((url) => url.includes("auth="))).toBe(false); + }); + + it("sends Bearer auth header to BestDebrid", async () => { + const settings = { + ...defaultSettings(), + token: "", + bestToken: "best-token", + providerPrimary: "bestdebrid" as const, + providerSecondary: "none" as const, + providerTertiary: "none" as const, + autoProviderFallback: true + }; + + let authHeader = ""; + globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit): Promise => { + const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url; + if (url.includes("/api/v1/generateLink?link=")) { + const headers = init?.headers; + if (headers instanceof Headers) { + authHeader = headers.get("Authorization") || ""; + } else if (Array.isArray(headers)) { + const tuple = headers.find(([key]) => key.toLowerCase() === "authorization"); + authHeader = tuple?.[1] || ""; + } else { + authHeader = String((headers as Record | undefined)?.Authorization || ""); + } + return new Response(JSON.stringify({ download: "https://best.example/file.bin", filename: "file.bin", filesize: 42 }), { + status: 200, + headers: { "Content-Type": "application/json" } + }); + } + return new Response("not-found", { status: 404 }); + }) as typeof fetch; + + const service = new DebridService(settings); + const result = await service.unrestrictLink("https://hoster.example/file/abc"); + expect(result.provider).toBe("bestdebrid"); + expect(authHeader).toBe("Bearer best-token"); + }); + + it("does not retry BestDebrid auth failures (401)", async () => { + const settings = { + ...defaultSettings(), + token: "", + bestToken: "best-token", + providerPrimary: "bestdebrid" as const, + providerSecondary: "none" as const, + providerTertiary: "none" as const, + autoProviderFallback: true + }; + + let calls = 0; + globalThis.fetch = (async (input: RequestInfo | URL): Promise => { + const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url; + if (url.includes("/api/v1/generateLink?link=")) { + calls += 1; + return new Response(JSON.stringify({ message: "Unauthorized" }), { + status: 401, + headers: { "Content-Type": "application/json" } + }); + } + return new Response("not-found", { status: 404 }); + }) as typeof fetch; + + const service = new DebridService(settings); + await expect(service.unrestrictLink("https://hoster.example/file/no-retry")).rejects.toThrow(); + expect(calls).toBe(1); + }); + + it("does not retry AllDebrid auth failures (403)", async () => { + const settings = { + ...defaultSettings(), + allDebridToken: "ad-token", + providerOrder: [] as const, + providerPrimary: "alldebrid" as const, + providerSecondary: "none" as const, + providerTertiary: "none" as const, + autoProviderFallback: true + }; + + let calls = 0; + globalThis.fetch = (async (input: RequestInfo | URL): Promise => { + const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url; + if (url.includes("api.alldebrid.com/v4/link/unlock")) { + calls += 1; + return new Response(JSON.stringify({ status: "error", error: { message: "forbidden" } }), { + status: 403, + headers: { "Content-Type": "application/json" } + }); + } + return new Response("not-found", { status: 404 }); + }) as typeof fetch; + + const service = new DebridService(settings); + await expect(service.unrestrictLink("https://hoster.example/file/no-retry-ad")).rejects.toThrow(); + expect(calls).toBe(1); + }); + + it("supports AllDebrid unlock", async () => { + const settings = { + ...defaultSettings(), + token: "", + bestToken: "", + allDebridToken: "ad-token", + providerOrder: [] as const, + providerPrimary: "alldebrid" as const, + providerSecondary: "realdebrid" as const, + providerTertiary: "megadebrid" as const, + autoProviderFallback: true + }; + + globalThis.fetch = (async (input: RequestInfo | URL): Promise => { + const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url; + if (url.includes("api.alldebrid.com/v4/link/unlock")) { + return new Response(JSON.stringify({ + status: "success", + data: { + link: "https://alldebrid.example/file.bin", + filename: "file.bin", + filesize: 4096 + } + }), { + status: 200, + headers: { "Content-Type": "application/json" } + }); + } + return new Response("not-found", { status: 404 }); + }) as typeof fetch; + + const service = new DebridService(settings); + const result = await service.unrestrictLink("https://rapidgator.net/file/example.part4.rar.html"); + expect(result.provider).toBe("alldebrid"); + expect(result.directUrl).toBe("https://alldebrid.example/file.bin"); + expect(result.fileSize).toBe(4096); + }); + + it("loads AllDebrid host info via api", async () => { + globalThis.fetch = (async (input: RequestInfo | URL): Promise => { + const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url; + if (url.includes("api.alldebrid.com/v4.1/user/hosts")) { + return new Response(JSON.stringify({ + status: "success", + data: { + hosts: { + rapidgator: { + name: "rapidgator", + status: false, + quota: 1200, + quotaMax: 2400, + quotaType: "traffic", + limitSimuDl: 2 + } + } + } + }), { + status: 200, + headers: { "Content-Type": "application/json" } + }); + } + return new Response("not-found", { status: 404 }); + }) as typeof fetch; + + const info = await fetchAllDebridHostInfo("ad-token", "rapidgator"); + expect(info.source).toBe("api"); + expect(info.host).toBe("rapidgator"); + expect(info.state).toBe("down"); + expect(info.statusLabel).toBe("Unverfügbar"); + expect(info.quota).toBe(1200); + expect(info.quotaMax).toBe(2400); + expect(info.quotaType).toBe("traffic"); + expect(info.limitSimuDl).toBe(2); + }); + + it("loads Debrid-Link rapidgator limits per api key", async () => { + globalThis.fetch = (async (input: RequestInfo | URL): Promise => { + const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url; + if (url.includes("debrid-link.com/api/v2/downloader/limits/all")) { + return new Response(JSON.stringify({ + success: true, + value: { + hosters: [ + { + name: "rapidgator", + daySize: { current: 0, value: 150323855360 }, + dayCount: { current: 0, value: 500 } + } + ] + } + }), { + status: 200, + headers: { "Content-Type": "application/json" } + }); + } + return new Response("not-found", { status: 404 }); + }) as typeof fetch; + + const info = await fetchDebridLinkHostLimits("key-a", "rapidgator"); + expect(info).toHaveLength(1); + expect(info[0].keyLabel).toBe("Key 1"); + expect(info[0].host).toBe("rapidgator"); + expect(info[0].trafficCurrentBytes).toBe(0); + expect(info[0].trafficMaxBytes).toBe(150323855360); + expect(info[0].linksCurrent).toBe(0); + expect(info[0].linksMax).toBe(500); + }); + + it("falls back from Debrid-Link limits/all to limits when the host is only present in limits", async () => { + const calledUrls: string[] = []; + + globalThis.fetch = (async (input: RequestInfo | URL): Promise => { + const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url; + calledUrls.push(url); + if (url.includes("debrid-link.com/api/v2/downloader/limits/all")) { + return new Response(JSON.stringify({ + success: true, + value: { + hosters: [ + { + name: "uploaded", + daySize: { current: 1, value: 2 }, + dayCount: { current: 3, value: 4 } + } + ] + } + }), { + status: 200, + headers: { "Content-Type": "application/json" } + }); + } + if (url.includes("debrid-link.com/api/v2/downloader/limits")) { + return new Response(JSON.stringify({ + success: true, + value: { + hosters: [ + { + name: "rapidgator", + displayName: "Rapidgator", + daySize: { current: 2147483648, value: 150323855360 }, + dayCount: { current: 42, value: 500 } + } + ] + } + }), { + status: 200, + headers: { "Content-Type": "application/json" } + }); + } + return new Response("not-found", { status: 404 }); + }) as typeof fetch; + + const info = await fetchDebridLinkHostLimits("key-a", "rapidgator"); + expect(info).toHaveLength(1); + expect(info[0].host).toBe("rapidgator"); + expect(info[0].trafficCurrentBytes).toBe(2147483648); + expect(info[0].trafficMaxBytes).toBe(150323855360); + expect(info[0].linksCurrent).toBe(42); + expect(info[0].linksMax).toBe(500); + expect(calledUrls.some((url) => url.includes("/limits/all"))).toBe(true); + expect(calledUrls.some((url) => url.includes("/limits"))).toBe(true); + }); + + it("includes Debrid-Link host and key state diagnostics in host limits", async () => { + globalThis.fetch = (async (input: RequestInfo | URL): Promise => { + const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url; + if (url.includes("debrid-link.com/api/v2/downloader/hosts")) { + return new Response(JSON.stringify({ + success: true, + value: [ + { + name: "rapidgator", + status: 1, + domains: ["rapidgator.net", "rg.to"] + } + ] + }), { + status: 200, + headers: { "Content-Type": "application/json" } + }); + } + if (url.includes("debrid-link.com/api/v2/downloader/limits/all")) { + return new Response(JSON.stringify({ + success: true, + value: { + hosters: [ + { + name: "rapidgator", + daySize: { current: 1024, value: 2048 }, + dayCount: { current: 2, value: 5 } + } + ] + } + }), { + status: 200, + headers: { "Content-Type": "application/json" } + }); + } + return new Response("not-found", { status: 404 }); + }) as typeof fetch; + + const info = await fetchDebridLinkHostLimits("key-a", "rapidgator"); + expect(info[0].state).toBe("ready"); + expect(info[0].stateLabel).toBe("Bereit"); + expect(info[0].hostState).toBe("up"); + expect(info[0].hostStateLabel).toBe("Online"); + }); + + it("returns invalid Debrid-Link key diagnostics instead of failing the whole popup request", async () => { + globalThis.fetch = (async (input: RequestInfo | URL): Promise => { + const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url; + if (url.includes("debrid-link.com/api/v2/downloader/hosts")) { + return new Response(JSON.stringify({ + success: true, + value: [ + { + name: "rapidgator", + status: 0, + domains: ["rapidgator.net"] + } + ] + }), { + status: 200, + headers: { "Content-Type": "application/json" } + }); + } + if (url.includes("debrid-link.com/api/v2/downloader/limits/all")) { + return new Response(JSON.stringify({ + success: false, + error: "badToken", + error_description: "token expired" + }), { + status: 401, + headers: { "Content-Type": "application/json" } + }); + } + return new Response("not-found", { status: 404 }); + }) as typeof fetch; + + const info = await fetchDebridLinkHostLimits("key-a", "rapidgator"); + expect(info).toHaveLength(1); + expect(info[0].state).toBe("invalid"); + expect(info[0].cooldownRemainingMs).toBeGreaterThan(0); + expect(info[0].hostState).toBe("down"); + expect(info[0].hostStateLabel).toBe("Offline"); + }); + + it("uses AllDebrid web path when enabled", async () => { + const settings = { + ...defaultSettings(), + allDebridToken: "ad-token", + allDebridUseWebLogin: true, + providerOrder: [] as const, + providerPrimary: "alldebrid" as const, + providerSecondary: "none" as const, + providerTertiary: "none" as const, + autoProviderFallback: false + }; + + const fetchSpy = vi.fn(async () => new Response("not-found", { status: 404 })); + globalThis.fetch = fetchSpy as unknown as typeof fetch; + + const allDebridWeb = vi.fn(async () => ({ + fileName: "from-web.rar", + directUrl: "https://df4ea4.debrid.it/dl/example/from-web.rar", + fileSize: 1234, + retriesUsed: 0 + })); + + const service = new DebridService(settings, { allDebridWebUnrestrict: allDebridWeb }); + const result = await service.unrestrictLink("https://rapidgator.net/file/example.part4.rar.html"); + expect(result.provider).toBe("alldebrid"); + expect(result.directUrl).toContain("debrid.it/dl/"); + expect(result.fileSize).toBe(1234); + expect(allDebridWeb).toHaveBeenCalledTimes(1); + expect(fetchSpy).toHaveBeenCalledTimes(0); + }); + + it("treats AllDebrid web mode as not configured when callback is unavailable", async () => { + const settings = { + ...defaultSettings(), + allDebridToken: "", + allDebridUseWebLogin: true, + providerPrimary: "alldebrid" as const, + providerSecondary: "none" as const, + providerTertiary: "none" as const, + autoProviderFallback: false + }; + + const service = new DebridService(settings); + await expect(service.unrestrictLink("https://rapidgator.net/file/missing-alldebrid-web")).rejects.toThrow(/nicht konfiguriert/i); + }); + + it("uses Real-Debrid web path when enabled", async () => { + const settings = { + ...defaultSettings(), + token: "rd-token", + realDebridUseWebLogin: true, + providerPrimary: "realdebrid" as const, + providerSecondary: "none" as const, + providerTertiary: "none" as const, + autoProviderFallback: false + }; + + const fetchSpy = vi.fn(async () => new Response("not-found", { status: 404 })); + globalThis.fetch = fetchSpy as unknown as typeof fetch; + + const realDebridWeb = vi.fn(async () => ({ + fileName: "from-rd-web.rar", + directUrl: "https://download.real-debrid.com/d/example/from-rd-web.rar", + fileSize: 5678, + retriesUsed: 0 + })); + + const service = new DebridService(settings, { realDebridWebUnrestrict: realDebridWeb }); + const result = await service.unrestrictLink("https://rapidgator.net/file/example.part5.rar.html"); + expect(result.provider).toBe("realdebrid"); + expect(result.directUrl).toContain("real-debrid.com/d/"); + expect(result.fileSize).toBe(5678); + expect(realDebridWeb).toHaveBeenCalledTimes(1); + expect(fetchSpy).toHaveBeenCalledTimes(0); + }); + + it("treats Real-Debrid web mode as not configured when callback is unavailable and no token", async () => { + const settings = { + ...defaultSettings(), + token: "", + realDebridUseWebLogin: true, + providerPrimary: "realdebrid" as const, + providerSecondary: "none" as const, + providerTertiary: "none" as const, + autoProviderFallback: false + }; + + const service = new DebridService(settings); + await expect(service.unrestrictLink("https://rapidgator.net/file/missing-rd-web")).rejects.toThrow(/nicht konfiguriert/i); + }); + + it("falls back to API token when Real-Debrid web login is disabled", async () => { + const settings = { + ...defaultSettings(), + token: "rd-token", + realDebridUseWebLogin: false, + providerPrimary: "realdebrid" as const, + providerSecondary: "none" as const, + providerTertiary: "none" as const, + autoProviderFallback: false + }; + + globalThis.fetch = (async () => new Response(JSON.stringify({ + download: "https://download.real-debrid.com/d/test/file.rar", + filename: "file.rar", + filesize: 9999 + }), { + status: 200, + headers: { "Content-Type": "application/json" } + })) as typeof fetch; + + const realDebridWeb = vi.fn(async () => null); + const service = new DebridService(settings, { realDebridWebUnrestrict: realDebridWeb }); + const result = await service.unrestrictLink("https://rapidgator.net/file/test.rar.html"); + expect(result.provider).toBe("realdebrid"); + expect(realDebridWeb).not.toHaveBeenCalled(); + }); + + it("treats MegaDebrid as not configured when no credentials are set", async () => { + const settings = { + ...defaultSettings(), + megaLogin: "", + megaPassword: "", + providerPrimary: "megadebrid" as const, + providerSecondary: "none" as const, + providerTertiary: "none" as const, + autoProviderFallback: false + }; + + const service = new DebridService(settings); + await expect(service.unrestrictLink("https://rapidgator.net/file/missing-mega-web")).rejects.toThrow(/nicht konfiguriert/i); + }); + + it("uses Mega web fallback when API fails", async () => { + const settings = { + ...defaultSettings(), + token: "", + bestToken: "", + allDebridToken: "", + megaLogin: "user", + megaPassword: "pass", + megaCredentials: "user:pass", + providerOrder: [] as const, + providerPrimary: "megadebrid" as const, + providerSecondary: "megadebrid" as const, + providerTertiary: "megadebrid" as const, + autoProviderFallback: true + }; + + const fetchSpy = vi.fn(async () => new Response("not-found", { status: 404 })); + globalThis.fetch = fetchSpy as unknown as typeof fetch; + + const megaWeb = vi.fn(async () => ({ + fileName: "from-web.rar", + directUrl: "https://www11.unrestrict.link/download/file/abc/from-web.rar", + fileSize: null, + retriesUsed: 0 + })); + + const service = new DebridService(settings, { megaWebUnrestrict: megaWeb }); + const result = await service.unrestrictLink("https://rapidgator.net/file/abc/from-web.rar.html"); + expect(result.provider).toBe("megadebrid"); + expect(result.directUrl).toContain("unrestrict.link/download/file/"); + expect(megaWeb).toHaveBeenCalledTimes(1); + }); + + it("does not fallback from Mega API to Mega Web unless Mega Web is a separate provider in the order", async () => { + const settings = { + ...defaultSettings(), + token: "", + bestToken: "", + allDebridToken: "", + megaLogin: "user", + megaPassword: "pass", + megaCredentials: "user:pass", + megaDebridApiEnabled: true, + megaDebridWebEnabled: true, + providerPrimary: "megadebrid-api" as const, + providerSecondary: "none" as const, + providerTertiary: "none" as const, + autoProviderFallback: true + }; + + globalThis.fetch = (async () => new Response("not-found", { status: 404 })) as typeof fetch; + + const megaWeb = vi.fn(async () => ({ + fileName: "should-not-run.rar", + directUrl: "https://unused", + fileSize: null, + retriesUsed: 0 + })); + + const service = new DebridService(settings, { megaWebUnrestrict: megaWeb }); + await expect(service.unrestrictLink("https://rapidgator.net/file/mega-api-only.rar.html")).rejects.toThrow(/mega-debrid api/i); + expect(megaWeb).toHaveBeenCalledTimes(0); + }); + + it("treats a Mega-Debrid 'Fichier supprimé' as transient: no account cooldown, German message, retryable", async () => { + const settings = { + ...defaultSettings(), + token: "", + bestToken: "", + allDebridToken: "", + megaLogin: "user", + megaPassword: "pass", + megaCredentials: "user:pass", + megaDebridApiEnabled: true, + megaDebridWebEnabled: false, + providerPrimary: "megadebrid-api" as const, + providerSecondary: "none" as const, + providerTertiary: "none" as const, + autoProviderFallback: true + }; + + globalThis.fetch = (async (input: RequestInfo | URL): Promise => { + const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url; + if (url.includes("action=connectUser")) { + return new Response(JSON.stringify({ response_code: "ok", token: "tok", vip_end: Math.floor(Date.now() / 1000) + 999999 }), { status: 200, headers: { "Content-Type": "application/json" } }); + } + if (url.includes("action=getLink")) { + return new Response(JSON.stringify({ response_code: "error", response_text: "Fichier supprimé chez l'hébergeur" }), { status: 200, headers: { "Content-Type": "application/json" } }); + } + return new Response("not-found", { status: 404 }); + }) as typeof fetch; + + const service = new DebridService(settings); + const err = await service.unrestrictLink("https://rapidgator.net/file/maybe-dead.rar.html").then(() => null, (e: unknown) => e); + expect(err).toBeTruthy(); + expect(String(err)).toMatch(/nicht abrufbar/i); + expect(String(err)).not.toMatch(/supprim/i); + expect(getMegaDebridAccountCooldownState(`${getMegaDebridAccountId("user")}:api`)).toBeNull(); + }); + + it("does NOT slap a 60-minute 'invalid' cooldown on a working account when the API returns no result (transient)", async () => { + const settings = { + ...defaultSettings(), + token: "", + bestToken: "", + allDebridToken: "", + megaLogin: "user", + megaPassword: "pass", + megaCredentials: "user:pass", + megaDebridApiEnabled: true, + megaDebridWebEnabled: false, + providerPrimary: "megadebrid-api" as const, + providerSecondary: "none" as const, + providerTertiary: "none" as const, + autoProviderFallback: true + }; + + globalThis.fetch = (async (input: RequestInfo | URL): Promise => { + const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url; + if (url.includes("action=connectUser")) { + return new Response(JSON.stringify({ response_code: "ok", token: "tok", vip_end: Math.floor(Date.now() / 1000) + 999999 }), { status: 200, headers: { "Content-Type": "application/json" } }); + } + if (url.includes("action=getLink")) { + return new Response(JSON.stringify({ response_code: "ok" }), { status: 200, headers: { "Content-Type": "application/json" } }); + } + return new Response("not-found", { status: 404 }); + }) as typeof fetch; + + const service = new DebridService(settings); + const err = await service.unrestrictLink("https://rapidgator.net/file/no-result.rar.html").then(() => null, (e: unknown) => e); + expect(err).toBeTruthy(); + expect(String(err)).not.toMatch(/Login oder Unrestrict/i); + + const cooldown = getMegaDebridAccountCooldownState(`${getMegaDebridAccountId("user")}:api`); + if (cooldown) { + expect(cooldown.category).not.toBe("invalid"); + expect(cooldown.remainingMs).toBeLessThan(5 * 60 * 1000); + } + }); + + it("treats a Mega-Debrid API 'Token error, please log-in' as a short transient cooldown, not invalid", async () => { + const settings = { + ...defaultSettings(), + token: "", + bestToken: "", + allDebridToken: "", + megaLogin: "user", + megaPassword: "pass", + megaCredentials: "user:pass", + megaDebridApiEnabled: true, + megaDebridWebEnabled: false, + providerPrimary: "megadebrid-api" as const, + providerSecondary: "none" as const, + providerTertiary: "none" as const, + autoProviderFallback: true + }; + + globalThis.fetch = (async (input: RequestInfo | URL): Promise => { + const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url; + if (url.includes("action=connectUser")) { + return new Response(JSON.stringify({ response_code: "ok", token: "tok", vip_end: Math.floor(Date.now() / 1000) + 999999 }), { status: 200, headers: { "Content-Type": "application/json" } }); + } + if (url.includes("action=getLink")) { + return new Response(JSON.stringify({ response_code: "error_token", response_text: "Token error, please log-in" }), { status: 200, headers: { "Content-Type": "application/json" } }); + } + return new Response("not-found", { status: 404 }); + }) as typeof fetch; + + const service = new DebridService(settings); + await service.unrestrictLink("https://rapidgator.net/file/token-collision.rar.html").then(() => null, (e: unknown) => e); + + const cooldown = getMegaDebridAccountCooldownState(`${getMegaDebridAccountId("user")}:api`); + if (cooldown) { + expect(cooldown.category).not.toBe("invalid"); + expect(cooldown.remainingMs).toBeLessThan(60 * 1000); + } + }); + + it("categorizes a Mega-Debrid 'rate limit' error as rate_limit, not quota (regex ordering)", async () => { + const settings = { + ...defaultSettings(), + token: "", + bestToken: "", + allDebridToken: "", + megaLogin: "user", + megaPassword: "pass", + megaCredentials: "user:pass", + megaDebridApiEnabled: true, + megaDebridWebEnabled: false, + providerPrimary: "megadebrid-api" as const, + providerSecondary: "none" as const, + providerTertiary: "none" as const, + autoProviderFallback: true + }; + + globalThis.fetch = (async (input: RequestInfo | URL): Promise => { + const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url; + if (url.includes("action=connectUser")) { + return new Response(JSON.stringify({ response_code: "ok", token: "tok", vip_end: Math.floor(Date.now() / 1000) + 999999 }), { status: 200, headers: { "Content-Type": "application/json" } }); + } + if (url.includes("action=getLink")) { + return new Response(JSON.stringify({ response_code: "error", response_text: "Rate limit exceeded, too many requests" }), { status: 200, headers: { "Content-Type": "application/json" } }); + } + return new Response("not-found", { status: 404 }); + }) as typeof fetch; + + const service = new DebridService(settings); + await service.unrestrictLink("https://rapidgator.net/file/rl.rar.html").then(() => null, (e: unknown) => e); + + const cooldown = getMegaDebridAccountCooldownState(`${getMegaDebridAccountId("user")}:api`); + expect(cooldown).not.toBeNull(); + expect(cooldown!.category).toBe("rate_limit"); + }); + + it("does not fall through a failed 1fichier link to the provider chain when autoProviderFallback is off", async () => { + let megaGetLinkCalled = false; + const settings = { + ...defaultSettings(), + token: "", + bestToken: "", + allDebridToken: "", + oneFichierApiKey: "1f-key", + megaLogin: "user", + megaPassword: "pass", + megaCredentials: "user:pass", + megaDebridApiEnabled: true, + megaDebridWebEnabled: false, + megaDebridPreferApi: true, + providerPrimary: "megadebrid" as const, + providerSecondary: "none" as const, + providerTertiary: "none" as const, + autoProviderFallback: false + }; + + globalThis.fetch = (async (input: RequestInfo | URL): Promise => { + const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url; + if (url.includes("api.1fichier.com")) { + return new Response(JSON.stringify({ status: "KO", message: "not available" }), { status: 500, headers: { "Content-Type": "application/json" } }); + } + if (url.includes("action=connectUser")) { + return new Response(JSON.stringify({ response_code: "ok", token: "tok", vip_end: Math.floor(Date.now() / 1000) + 999999 }), { status: 200, headers: { "Content-Type": "application/json" } }); + } + if (url.includes("action=getLink")) { + megaGetLinkCalled = true; + return new Response(JSON.stringify({ response_code: "ok", debridLink: "https://mega-cdn.example/file.rar", filename: "file.rar" }), { status: 200, headers: { "Content-Type": "application/json" } }); + } + return new Response("not-found", { status: 404 }); + }) as typeof fetch; + + const service = new DebridService(settings); + const result = await service.unrestrictLink("https://1fichier.com/?abc12345xyz").then((r) => ({ ok: true, r }), (e: unknown) => ({ ok: false, e })); + + expect(result.ok).toBe(false); + expect(megaGetLinkCalled).toBe(false); + }); + + it("uses Mega Web only when it is configured as a separate fallback provider", async () => { + const settings = { + ...defaultSettings(), + token: "", + bestToken: "", + allDebridToken: "", + megaLogin: "user", + megaPassword: "pass", + megaCredentials: "user:pass", + megaDebridApiEnabled: true, + megaDebridWebEnabled: true, + providerOrder: [] as const, + providerPrimary: "megadebrid-api" as const, + providerSecondary: "megadebrid-web" as const, + providerTertiary: "none" as const, + autoProviderFallback: true + }; + + globalThis.fetch = (async () => new Response("not-found", { status: 404 })) as typeof fetch; + + const megaWeb = vi.fn(async () => ({ + fileName: "from-separate-web.rar", + directUrl: "https://mega-web.example/from-separate-web.rar", + fileSize: null, + retriesUsed: 0 + })); + + const service = new DebridService(settings, { megaWebUnrestrict: megaWeb }); + const result = await service.unrestrictLink("https://rapidgator.net/file/from-separate-web.rar.html"); + expect(result.provider).toBe("megadebrid-web"); + expect(result.directUrl).toBe("https://mega-web.example/from-separate-web.rar"); + expect(megaWeb).toHaveBeenCalledTimes(1); + }); + + it("aborts Mega web unrestrict when caller signal is cancelled", async () => { + const settings = { + ...defaultSettings(), + token: "", + bestToken: "", + allDebridToken: "", + megaLogin: "user", + megaPassword: "pass", + megaCredentials: "user:pass", + providerOrder: [] as const, + providerPrimary: "megadebrid" as const, + providerSecondary: "none" as const, + providerTertiary: "none" as const, + autoProviderFallback: false + }; + + globalThis.fetch = (async () => new Response("error", { status: 500 })) as typeof fetch; + + const megaWeb = vi.fn((_link: string, signal?: AbortSignal): Promise => new Promise((_, reject) => { + const onAbort = (): void => reject(new Error("aborted:mega-web-test")); + if (signal?.aborted) { + onAbort(); + return; + } + signal?.addEventListener("abort", onAbort, { once: true }); + })); + + const service = new DebridService(settings, { megaWebUnrestrict: megaWeb }); + const controller = new AbortController(); + const abortTimer = setTimeout(() => { + controller.abort("test"); + }, 200); + + try { + await expect(service.unrestrictLink("https://rapidgator.net/file/abort-mega-web", controller.signal)).rejects.toThrow(/aborted/i); + expect(megaWeb).toHaveBeenCalledTimes(1); + expect(megaWeb.mock.calls[0]?.[1]).toBe(controller.signal); + } finally { + clearTimeout(abortTimer); + } + }); + + it("bleibt klebrig bei einem funktionierenden Account (kein Account-Wechsel pro Link)", async () => { + const settings = { + ...defaultSettings(), + token: "", bestToken: "", allDebridToken: "", + megaLogin: "user1", megaPassword: "pass1", + megaCredentials: "user1:pass1\nuser2:pass2\nuser3:pass3\nuser4:pass4", + megaDebridPreferApi: false, + providerOrder: [] as const, providerPrimary: "megadebrid" as const, + providerSecondary: "none" as const, providerTertiary: "none" as const, + autoProviderFallback: false + }; + + globalThis.fetch = (async () => new Response("error", { status: 500 })) as typeof fetch; + + const megaWeb = vi.fn(async () => ({ fileName: "ok.rar", directUrl: "https://mega-web.example/ok.rar", fileSize: null, retriesUsed: 0 })); + + const service = new DebridService(settings, { megaWebUnrestrict: megaWeb }); + const usedIds: (string | undefined)[] = []; + for (let i = 0; i < 5; i += 1) { + const result = await service.unrestrictLink(`https://rapidgator.net/file/sticky-${i}`); + usedIds.push((result as { sourceAccountId?: string }).sourceAccountId); + } + + expect(usedIds).toEqual(new Array(5).fill(getMegaDebridAccountId("user1"))); + }, 30000); + + it("wechselt erst nach einem Schwung Links auf den naechsten Account", async () => { + const settings = { + ...defaultSettings(), + token: "", bestToken: "", allDebridToken: "", + megaLogin: "user1", megaPassword: "pass1", + megaCredentials: "user1:pass1\nuser2:pass2", + megaDebridPreferApi: false, + providerOrder: [] as const, providerPrimary: "megadebrid" as const, + providerSecondary: "none" as const, providerTertiary: "none" as const, + autoProviderFallback: false + }; + + globalThis.fetch = (async () => new Response("error", { status: 500 })) as typeof fetch; + + const megaWeb = vi.fn(async () => ({ fileName: "ok.rar", directUrl: "https://mega-web.example/ok.rar", fileSize: null, retriesUsed: 0 })); + + const service = new DebridService(settings, { megaWebUnrestrict: megaWeb }); + const usedIds: (string | undefined)[] = []; + for (let i = 0; i < MEGA_DEBRID_STICKY_LINKS + 1; i += 1) { + const result = await service.unrestrictLink(`https://rapidgator.net/file/chunk-${i}`); + usedIds.push((result as { sourceAccountId?: string }).sourceAccountId); + } + + expect(usedIds.slice(0, MEGA_DEBRID_STICKY_LINKS)).toEqual(new Array(MEGA_DEBRID_STICKY_LINKS).fill(getMegaDebridAccountId("user1"))); + expect(usedIds[MEGA_DEBRID_STICKY_LINKS]).toBe(getMegaDebridAccountId("user2")); + }, 30000); + + it("ueberspringt einen gesperrten Account und bleibt dann klebrig beim naechsten", async () => { + const settings = { + ...defaultSettings(), + token: "", bestToken: "", allDebridToken: "", + megaLogin: "user1", megaPassword: "pass1", + megaCredentials: "user1:pass1\nuser2:pass2\nuser3:pass3", + megaDebridPreferApi: false, + providerOrder: [] as const, providerPrimary: "megadebrid" as const, + providerSecondary: "none" as const, providerTertiary: "none" as const, + autoProviderFallback: false + }; + + globalThis.fetch = (async () => new Response("error", { status: 500 })) as typeof fetch; + + const megaWeb = vi.fn(async () => ({ fileName: "ok.rar", directUrl: "https://mega-web.example/ok.rar", fileSize: null, retriesUsed: 0 })); + + primeMegaDebridUntilRestartForTests(`${getMegaDebridAccountId("user1")}:web`); + + const service = new DebridService(settings, { megaWebUnrestrict: megaWeb }); + const usedIds: (string | undefined)[] = []; + for (let i = 0; i < 3; i += 1) { + const result = await service.unrestrictLink(`https://rapidgator.net/file/skip-${i}`); + usedIds.push((result as { sourceAccountId?: string }).sourceAccountId); + } + + expect(usedIds).toEqual(new Array(3).fill(getMegaDebridAccountId("user2"))); + }, 30000); + + it("verteilt gleichzeitige Umwandlungen auf verschiedene Accounts (parallel, in-flight-Routing)", async () => { + const settings = { + ...defaultSettings(), + token: "", bestToken: "", allDebridToken: "", + megaLogin: "user1", megaPassword: "pass1", + megaCredentials: "user1:pass1\nuser2:pass2\nuser3:pass3\nuser4:pass4", + megaDebridPreferApi: false, + providerOrder: [] as const, providerPrimary: "megadebrid" as const, + providerSecondary: "none" as const, providerTertiary: "none" as const, + autoProviderFallback: false + }; + + globalThis.fetch = (async () => new Response("error", { status: 500 })) as typeof fetch; + + let active = 0; + let maxActive = 0; + const accountsSeen = new Set(); + const allInFlight = new Promise((resolve) => { + const check = setInterval(() => { if (active >= 4) { clearInterval(check); resolve(); } }, 5); + }); + const megaWeb = vi.fn(async (_link: string, _signal: AbortSignal | undefined, account?: { login: string; password: string }) => { + active += 1; + maxActive = Math.max(maxActive, active); + if (account) accountsSeen.add(account.login); + await allInFlight; + active -= 1; + return { fileName: "ok.rar", directUrl: "https://mega-web.example/ok.rar", fileSize: null, retriesUsed: 0 }; + }); + + const service = new DebridService(settings, { megaWebUnrestrict: megaWeb }); + const results = await Promise.all([0, 1, 2, 3].map((i) => service.unrestrictLink(`https://rapidgator.net/file/conc-${i}`))); + + expect(results.every((r) => Boolean((r as { directUrl?: string }).directUrl))).toBe(true); + expect(accountsSeen.size).toBe(4); + expect(maxActive).toBe(4); + }, 15000); + + it("verteilt Ueberzahl-Umwandlungen (mehr gleichzeitig als Accounts) gleichmaessig statt sie zu stapeln", async () => { + const settings = { + ...defaultSettings(), + token: "", bestToken: "", allDebridToken: "", + megaLogin: "user1", megaPassword: "pass1", + megaCredentials: "user1:pass1\nuser2:pass2", + megaDebridPreferApi: false, + providerOrder: [] as const, providerPrimary: "megadebrid" as const, + providerSecondary: "none" as const, providerTertiary: "none" as const, + autoProviderFallback: false + }; + + globalThis.fetch = (async () => new Response("error", { status: 500 })) as typeof fetch; + + let active = 0; + const callsPerAccount = new Map(); + const allInFlight = new Promise((resolve) => { + const check = setInterval(() => { if (active >= 8) { clearInterval(check); resolve(); } }, 5); + }); + const megaWeb = vi.fn(async (_link: string, _signal: AbortSignal | undefined, account?: { login: string; password: string }) => { + active += 1; + if (account) callsPerAccount.set(account.login, (callsPerAccount.get(account.login) ?? 0) + 1); + await allInFlight; + return { fileName: "ok.rar", directUrl: "https://mega-web.example/ok.rar", fileSize: null, retriesUsed: 0 }; + }); + + const service = new DebridService(settings, { megaWebUnrestrict: megaWeb }); + await Promise.all(Array.from({ length: 8 }, (_unused, i) => service.unrestrictLink(`https://rapidgator.net/file/ov-${i}`))); + + // 8 gleichzeitige Aufloesungen auf 2 Accounts → 4 je Account (statt 7/1 beim alten belegt/frei-Set). + expect(callsPerAccount.get("user1")).toBe(4); + expect(callsPerAccount.get("user2")).toBe(4); + }, 15000); + + it("faellt ein Account in der Mitte aus (1,2,4 ok, 3 nicht): parallel nur ueber die funktionierenden, alle Links loesen auf", async () => { + const settings = { + ...defaultSettings(), + token: "", bestToken: "", allDebridToken: "", + megaLogin: "user1", megaPassword: "pass1", + megaCredentials: "user1:pass1\nuser2:pass2\nuser3:pass3\nuser4:pass4", + megaDebridPreferApi: false, + providerOrder: [] as const, providerPrimary: "megadebrid" as const, + providerSecondary: "none" as const, providerTertiary: "none" as const, + autoProviderFallback: false + }; + + globalThis.fetch = (async () => new Response("error", { status: 500 })) as typeof fetch; + + let active = 0; + const used = new Set(); + const threeInFlight = new Promise((resolve) => { + const check = setInterval(() => { if (active >= 3) { clearInterval(check); resolve(); } }, 5); + }); + const megaWeb = vi.fn(async (_link: string, _signal: AbortSignal | undefined, account?: { login: string; password: string }) => { + active += 1; + if (account) used.add(account.login); + await threeInFlight; + return { fileName: "ok.rar", directUrl: "https://mega-web.example/ok.rar", fileSize: null, retriesUsed: 0 }; + }); + + // Account 3 ist ausgefallen (z.B. zuvor fehlgeschlagen -> gesperrt) und faellt aus der Rotation. + primeMegaDebridUntilRestartForTests(`${getMegaDebridAccountId("user3")}:web`); + + const service = new DebridService(settings, { megaWebUnrestrict: megaWeb }); + const results = await Promise.all([0, 1, 2].map((i) => service.unrestrictLink(`https://rapidgator.net/file/dead3-${i}`))); + + expect(results.every((r) => Boolean((r as { directUrl?: string }).directUrl))).toBe(true); + expect(used.has("user3")).toBe(false); + expect(used.has("user1")).toBe(true); + expect(used.has("user2")).toBe(true); + expect(used.has("user4")).toBe(true); + }, 15000); + + it("rotates to the next Mega-Debrid account when one hits its daily limit (error-based)", async () => { + const settings = { + ...defaultSettings(), + token: "", + bestToken: "", + allDebridToken: "", + megaLogin: "user1", + megaPassword: "pass1", + megaCredentials: "user1:pass1\nuser2:pass2", + megaDebridPreferApi: false, + providerOrder: [] as const, + providerPrimary: "megadebrid" as const, + providerSecondary: "none" as const, + providerTertiary: "none" as const, + autoProviderFallback: false + }; + + globalThis.fetch = (async () => new Response("error", { status: 500 })) as typeof fetch; + + let webCalls = 0; + const megaWeb = vi.fn(async (_link: string, _signal?: AbortSignal) => { + webCalls += 1; + if (webCalls <= 3) { + throw new Error("Mega-Web: daily limit reached (Tageslimit erreicht)"); + } + return { + fileName: "rotated-to-acc2.rar", + directUrl: "https://mega-web.example/rotated-to-acc2.rar", + fileSize: null, + retriesUsed: 0 + }; + }); + + const service = new DebridService(settings, { megaWebUnrestrict: megaWeb }); + const result = await service.unrestrictLink("https://rapidgator.net/file/limit-rotation-test"); + + expect(result.directUrl).toBe("https://mega-web.example/rotated-to-acc2.rar"); + expect(webCalls).toBeGreaterThanOrEqual(4); + }, 30000); + + it("skips a manually disabled Mega-Debrid account and uses the next one", async () => { + const settings = { + ...defaultSettings(), + token: "", + bestToken: "", + allDebridToken: "", + megaLogin: "user1", + megaPassword: "pass1", + megaCredentials: "user1:pass1\nuser2:pass2", + megaDebridDisabledAccountIds: [getMegaDebridAccountId("user1")], + megaDebridPreferApi: false, + providerOrder: [] as const, + providerPrimary: "megadebrid" as const, + providerSecondary: "none" as const, + providerTertiary: "none" as const, + autoProviderFallback: false + }; + + globalThis.fetch = (async () => new Response("error", { status: 500 })) as typeof fetch; + + const megaWeb = vi.fn(async () => ({ + fileName: "from-acc2.rar", + directUrl: "https://mega-web.example/from-acc2.rar", + fileSize: null, + retriesUsed: 0 + })); + + const service = new DebridService(settings, { megaWebUnrestrict: megaWeb }); + const result = await service.unrestrictLink("https://rapidgator.net/file/disabled-acc-test"); + + expect((result as { sourceAccountId?: string }).sourceAccountId).toBe(getMegaDebridAccountId("user2")); + expect(result.directUrl).toBe("https://mega-web.example/from-acc2.rar"); + expect(megaWeb).toHaveBeenCalledTimes(1); + }, 20000); + + it("fails fast on Mega-Debrid hoster quota ('Kein Server') and rotates to the next account", async () => { + const settings = { + ...defaultSettings(), + token: "", + bestToken: "", + allDebridToken: "", + megaLogin: "user1", + megaPassword: "pass1", + megaCredentials: "user1:pass1\nuser2:pass2", + megaDebridPreferApi: false, + providerOrder: [] as const, + providerPrimary: "megadebrid" as const, + providerSecondary: "none" as const, + providerTertiary: "none" as const, + autoProviderFallback: false + }; + globalThis.fetch = (async () => new Response("error", { status: 500 })) as typeof fetch; + + let calls = 0; + const megaWeb = vi.fn(async () => { + calls += 1; + if (calls === 1) { + throw new Error("Mega-Web: Kein Server für diesen Hoster verfügbar. Bitte versuchen Sie es später noch einmal."); + } + return { fileName: "acc2.rar", directUrl: "https://mega-web.example/acc2.rar", fileSize: null, retriesUsed: 0 }; + }); + + const service = new DebridService(settings, { megaWebUnrestrict: megaWeb }); + const result = await service.unrestrictLink("https://rapidgator.net/file/quota-rotate-test"); + + expect((result as { sourceAccountId?: string }).sourceAccountId).toBe(getMegaDebridAccountId("user2")); + expect(result.directUrl).toBe("https://mega-web.example/acc2.rar"); + expect(calls).toBe(2); + }, 20000); + + it("passes each account's OWN credentials to the Mega web unrestrict during rotation", async () => { + const settings = { + ...defaultSettings(), + token: "", + bestToken: "", + allDebridToken: "", + megaLogin: "user1", + megaPassword: "pass1", + megaCredentials: "user1:pass1\nuser2:pass2", + megaDebridPreferApi: false, + providerOrder: [] as const, + providerPrimary: "megadebrid" as const, + providerSecondary: "none" as const, + providerTertiary: "none" as const, + autoProviderFallback: false + }; + globalThis.fetch = (async () => new Response("error", { status: 500 })) as typeof fetch; + + const accountsSeen: Array = []; + const megaWeb = vi.fn(async (_link: string, _signal: AbortSignal | undefined, account?: { login: string; password: string }) => { + accountsSeen.push(account?.login); + if (account?.login === "user1") { + throw new Error("Mega-Web: Kein Server für diesen Hoster verfügbar. Bitte versuchen Sie es später noch einmal."); + } + return { fileName: "ok.rar", directUrl: "https://mega-web.example/ok.rar", fileSize: null, retriesUsed: 0 }; + }); + + const service = new DebridService(settings, { megaWebUnrestrict: megaWeb }); + const result = await service.unrestrictLink("https://rapidgator.net/file/per-account-creds"); + + expect(accountsSeen).toContain("user1"); + expect(accountsSeen).toContain("user2"); + expect((result as { sourceAccountId?: string }).sourceAccountId).toBe(getMegaDebridAccountId("user2")); + expect(result.directUrl).toBe("https://mega-web.example/ok.rar"); + }, 20000); + + it("setzt KEINEN Account-Cooldown, wenn der Mega-Web-Abbruch nur ein Queue-Timeout war (Account war belegt, nicht ungesund)", async () => { + process.env.RD_MEGA_ABORT_MIN_RUN_MS = "0"; + const settings = { + ...defaultSettings(), + token: "", + bestToken: "", + allDebridToken: "", + megaLogin: "user", + megaPassword: "pass", + megaCredentials: "user:pass", + megaDebridPreferApi: false, + providerOrder: [] as const, + providerPrimary: "megadebrid" as const, + providerSecondary: "none" as const, + providerTertiary: "none" as const, + autoProviderFallback: false + }; + globalThis.fetch = (async () => new Response("error", { status: 500 })) as typeof fetch; + + const controller = new AbortController(); + let calls = 0; + const megaWeb = vi.fn(async () => { + calls += 1; + controller.abort("caller-timeout"); + throw new Error("Mega-Web Queue-Timeout (abgebrochen nach 60s Wartezeit, Account war belegt)"); + }); + + const service = new DebridService(settings, { megaWebUnrestrict: megaWeb }); + await expect( + service.unrestrictLink("https://rapidgator.net/file/queue-timeout-no-cooldown", controller.signal) + ).rejects.toThrow(); + + const key = `${getMegaDebridAccountId("user")}:web`; + expect(getMegaDebridAccountCooldownState(key)).toBeNull(); + expect(calls).toBeGreaterThanOrEqual(1); + }, 20000); + + it("getProviderRuntimeSnapshot surfaces a live Mega-Debrid account cooldown (until/remaining/reason) for the diagnostics endpoint", () => { + const accId = getMegaDebridAccountId("user"); + const key = `${accId}:web`; + expect(getProviderRuntimeSnapshot().megaDebrid.accounts.find((a) => a.key === key)?.cooldown ?? null).toBeNull(); + + primeMegaDebridRuntimeCooldownForTests(key, 90_000, "Abbruch/Timeout nach 60s"); + + const snap = getProviderRuntimeSnapshot(); + expect(typeof snap.capturedAtMs).toBe("number"); + const acc = snap.megaDebrid.accounts.find((a) => a.key === key); + expect(acc).toBeTruthy(); + expect(acc!.cooldown).not.toBeNull(); + expect(acc!.cooldown!.remainingMs).toBeGreaterThan(0); + expect(acc!.cooldown!.remainingMs).toBeLessThanOrEqual(90_000); + expect(acc!.cooldown!.untilMs).toBeGreaterThan(snap.capturedAtMs); + expect(acc!.cooldown!.message).toContain("Abbruch"); + }); + + it("single Mega-Debrid account: a long Web abort parks only the slow link and does NOT freeze the sole account", async () => { + process.env.RD_MEGA_ABORT_MIN_RUN_MS = "0"; + const settings = { + ...defaultSettings(), + token: "", + bestToken: "", + allDebridToken: "", + megaLogin: "user", + megaPassword: "pass", + megaCredentials: "user:pass", + megaDebridPreferApi: false, + providerOrder: [] as const, + providerPrimary: "megadebrid" as const, + providerSecondary: "none" as const, + providerTertiary: "none" as const, + autoProviderFallback: false + }; + globalThis.fetch = (async () => new Response("error", { status: 500 })) as typeof fetch; + + const controller = new AbortController(); + let calls = 0; + const megaWeb = vi.fn((): Promise<{ fileName: string; directUrl: string; fileSize: number | null; retriesUsed: number }> => { + calls += 1; + if (calls === 1) { + controller.abort("simulated-60s-timeout"); + return Promise.reject(new Error("aborted")); + } + return Promise.resolve({ + fileName: "healthy.rar", + directUrl: "https://www11.unrestrict.link/download/file/ok/healthy.rar", + fileSize: null, + retriesUsed: 0 + }); + }); + + const service = new DebridService(settings, { megaWebUnrestrict: megaWeb }); + + const err = await service.unrestrictLink("https://rapidgator.net/file/slow-link.rar.html", controller.signal).then(() => null, (e: unknown) => e); + expect(err).toBeTruthy(); + expect(String(err)).toMatch(/mega_debrid_slow_link:\d+:/i); + + const key = `${getMegaDebridAccountId("user")}:web`; + expect(getMegaDebridAccountCooldownState(key)).toBeNull(); + + const second = await service.unrestrictLink("https://rapidgator.net/file/healthy.rar.html"); + expect(second.provider).toBe("megadebrid"); + expect(calls).toBeGreaterThanOrEqual(2); + }, 20000); + + it("escalates a Mega-Debrid account to 'until restart' after the empty-response streak threshold", () => { + const key = `${getMegaDebridAccountId("user1")}:web`; + expect(MEGA_DEBRID_EMPTY_STREAK_UNTIL_RESTART).toBe(10); + expect(recordMegaDebridEmptyResponseStreak(key)).toBe(1); + expect(recordMegaDebridEmptyResponseStreak(key)).toBe(2); + expect(recordMegaDebridEmptyResponseStreak(key)).toBe(3); + clearMegaDebridEmptyResponseStreak(key); + expect(recordMegaDebridEmptyResponseStreak(key)).toBe(1); + }); + + it("self-heals a daily-limit park after the daily reset instead of staying locked until process restart", () => { + const key = `${getMegaDebridAccountId("user1")}:api`; + primeMegaDebridUntilRestartForTests(key); + const active = getMegaDebridAccountCooldownState(key); + expect(active?.untilRestart).toBe(true); + expect(getMegaDebridAccountCooldownState(key, Date.now() + 60_000)?.untilRestart).toBe(true); + const afterReset = Date.now() + 25 * 60 * 60 * 1000; + expect(getMegaDebridAccountCooldownState(key, afterReset)).toBeNull(); + }); + + it("does NOT treat a per-hoster 'no server' failure as an account daily-limit signal (no until-restart park)", () => { + const noServer = classifyMegaDebridAccountFailureForTests(new Error("no server available for this host")); + expect(noServer.limitSignal).toBeFalsy(); + expect(noServer.category).toBe("quota"); + expect(noServer.cooldownMs).toBeGreaterThan(0); + + const genuineEmpty = classifyMegaDebridAccountFailureForTests(new Error("Antwort leer")); + expect(genuineEmpty.limitSignal).toBe(true); + }); + + it("classifies an empty Mega-Debrid API result ('Linkgenerierung lieferte kein Ergebnis') as a fast transient, not a 30s cooldown", () => { + const result = classifyMegaDebridAccountFailureForTests(new Error("Mega-Debrid API: Linkgenerierung lieferte kein Ergebnis")); + expect(result.fatal).toBe(false); + expect(result.cooldownMs).toBe(0); + expect(result.limitSignal).toBeFalsy(); + expect(isMegaDebridTransientResolveFailure(result.message)).toBe(true); + }); + + it("skips a Mega-Debrid account parked until restart and rotates to the next, without re-testing it", async () => { + const settings = { + ...defaultSettings(), + token: "", + bestToken: "", + allDebridToken: "", + megaLogin: "user1", + megaPassword: "pass1", + megaCredentials: "user1:pass1\nuser2:pass2", + megaDebridPreferApi: false, + providerOrder: [] as const, + providerPrimary: "megadebrid" as const, + providerSecondary: "none" as const, + providerTertiary: "none" as const, + autoProviderFallback: false + }; + globalThis.fetch = (async () => new Response("error", { status: 500 })) as typeof fetch; + + const user1 = getMegaDebridAccountId("user1"); + primeMegaDebridUntilRestartForTests(`${user1}:api`); + primeMegaDebridUntilRestartForTests(`${user1}:web`); + + const loginsSeen: Array = []; + const megaWeb = vi.fn(async (_link: string, _signal: AbortSignal | undefined, account?: { login: string; password: string }) => { + loginsSeen.push(account?.login); + return { fileName: "acc2.rar", directUrl: "https://mega-web.example/acc2.rar", fileSize: null, retriesUsed: 0 }; + }); + + const service = new DebridService(settings, { megaWebUnrestrict: megaWeb }); + const result = await service.unrestrictLink("https://rapidgator.net/file/parked-skip-test"); + + expect(loginsSeen).not.toContain("user1"); + expect(loginsSeen).toContain("user2"); + expect((result as { sourceAccountId?: string }).sourceAccountId).toBe(getMegaDebridAccountId("user2")); + }, 20000); + + it("emits an until-Tagesreset park token (so the manager parks, not 2min-retries) when ALL Mega-Debrid accounts are parked until restart", async () => { + const settings = { + ...defaultSettings(), + token: "", + bestToken: "", + allDebridToken: "", + megaLogin: "user1", + megaPassword: "pass1", + megaCredentials: "user1:pass1\nuser2:pass2", + megaDebridPreferApi: false, + providerOrder: [] as const, + providerPrimary: "megadebrid" as const, + providerSecondary: "none" as const, + providerTertiary: "none" as const, + autoProviderFallback: false + }; + globalThis.fetch = (async () => new Response("error", { status: 500 })) as typeof fetch; + + for (const login of ["user1", "user2"]) { + const id = getMegaDebridAccountId(login); + primeMegaDebridUntilRestartForTests(`${id}:api`); + primeMegaDebridUntilRestartForTests(`${id}:web`); + } + + const megaWeb = vi.fn(async () => ({ fileName: "x.rar", directUrl: "https://mega-web.example/x.rar", fileSize: null, retriesUsed: 0 })); + const service = new DebridService(settings, { megaWebUnrestrict: megaWeb }); + + const err = await service.unrestrictLink("https://rapidgator.net/file/all-parked-test").then(() => null, (e: unknown) => e as Error); + expect(err).toBeInstanceOf(Error); + expect(err!.message).toMatch(/bis zum Tagesreset gesperrt/i); + expect(err!.message).toMatch(/mega_debrid_reset_park:\d+:/); + expect(megaWeb).not.toHaveBeenCalled(); + }, 20000); + + it("drives a real empty response through the full rotation into an until-restart park (wiring test)", async () => { + const settings = { + ...defaultSettings(), + token: "", + bestToken: "", + allDebridToken: "", + megaLogin: "user1", + megaPassword: "pass1", + megaCredentials: "user1:pass1", + megaDebridPreferApi: false, + providerOrder: [] as const, + providerPrimary: "megadebrid" as const, + providerSecondary: "none" as const, + providerTertiary: "none" as const, + autoProviderFallback: false + }; + globalThis.fetch = (async () => new Response("error", { status: 500 })) as typeof fetch; + + const key = `${getMegaDebridAccountId("user1")}:web`; + for (let i = 0; i < MEGA_DEBRID_EMPTY_STREAK_UNTIL_RESTART - 1; i += 1) { + recordMegaDebridEmptyResponseStreak(key); + } + expect(getMegaDebridAccountCooldownState(key)?.untilRestart ?? false).toBe(false); + + const megaWeb = vi.fn(async () => null); + const service = new DebridService(settings, { megaWebUnrestrict: megaWeb }); + await service.unrestrictLink("https://rapidgator.net/file/wiring").catch(() => undefined); + + expect(megaWeb).toHaveBeenCalled(); + expect(getMegaDebridAccountCooldownState(key)?.untilRestart).toBe(true); + }, 20000); + + it("cools down a Mega-Web account that aborts (timeout) so the NEXT unrestrict rotates to the next account", async () => { + process.env.RD_MEGA_ABORT_MIN_RUN_MS = "0"; // treat the instant mock abort as a real timeout + const settings = { + ...defaultSettings(), + token: "", + bestToken: "", + allDebridToken: "", + megaLogin: "user1", + megaPassword: "pass1", + megaCredentials: "user1:pass1\nuser2:pass2", + megaDebridPreferApi: false, + providerOrder: [] as const, + providerPrimary: "megadebrid" as const, + providerSecondary: "none" as const, + providerTertiary: "none" as const, + autoProviderFallback: false + }; + globalThis.fetch = (async () => new Response("error", { status: 500 })) as typeof fetch; + + const loginsSeen: Array = []; + const megaWeb = vi.fn(async (_link: string, _signal: AbortSignal | undefined, account?: { login: string; password: string }) => { + loginsSeen.push(account?.login); + if (account?.login === "user1") { + throw new Error("aborted:debrid"); + } + return { fileName: "acc2.rar", directUrl: "https://mega-web.example/acc2.rar", fileSize: null, retriesUsed: 0 }; + }); + const service = new DebridService(settings, { megaWebUnrestrict: megaWeb }); + const user1Key = `${getMegaDebridAccountId("user1")}:web`; + + // Call 1: account 1 aborts -> rotation stops this pass, account 2 NOT tried, but account 1 is cooled down. + await expect(service.unrestrictLink("https://rapidgator.net/file/abort-call-1")).rejects.toThrow(); + expect(loginsSeen).toContain("user1"); + expect(loginsSeen).not.toContain("user2"); + expect(getMegaDebridAccountCooldownState(user1Key)).not.toBeNull(); + + // Call 2 (the retry, same state): account 1 is on cooldown -> skipped -> account 2 served. + loginsSeen.length = 0; + const result = await service.unrestrictLink("https://rapidgator.net/file/abort-call-2"); + expect(loginsSeen).not.toContain("user1"); + expect(loginsSeen).toContain("user2"); + expect((result as { sourceAccountId?: string }).sourceAccountId).toBe(getMegaDebridAccountId("user2")); + }, 20000); + + it("does NOT cool down a Mega-Web account on a quick abort (below the min-run threshold = user cancel)", async () => { + process.env.RD_MEGA_ABORT_MIN_RUN_MS = "99999"; // any realistic elapsed stays below -> no cooldown + const settings = { + ...defaultSettings(), + token: "", + bestToken: "", + allDebridToken: "", + megaLogin: "user1", + megaPassword: "pass1", + megaCredentials: "user1:pass1\nuser2:pass2", + megaDebridPreferApi: false, + providerOrder: [] as const, + providerPrimary: "megadebrid" as const, + providerSecondary: "none" as const, + providerTertiary: "none" as const, + autoProviderFallback: false + }; + globalThis.fetch = (async () => new Response("error", { status: 500 })) as typeof fetch; + + const megaWeb = vi.fn(async () => { throw new Error("aborted:debrid"); }); + const service = new DebridService(settings, { megaWebUnrestrict: megaWeb }); + const user1Key = `${getMegaDebridAccountId("user1")}:web`; + + await expect(service.unrestrictLink("https://rapidgator.net/file/quick-cancel")).rejects.toThrow(); + expect(getMegaDebridAccountCooldownState(user1Key)).toBeNull(); + }, 20000); + + it("respects provider selection and does not append hidden providers", async () => { + const settings = { + ...defaultSettings(), + token: "", + bestToken: "", + allDebridToken: "ad-token", + megaLogin: "user", + megaPassword: "pass", + megaCredentials: "user:pass", + providerPrimary: "megadebrid" as const, + providerSecondary: "megadebrid" as const, + providerTertiary: "megadebrid" as const, + autoProviderFallback: true + }; + + let allDebridCalls = 0; + globalThis.fetch = (async (input: RequestInfo | URL): Promise => { + const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url; + if (url.includes("api.alldebrid.com/v4/link/unlock")) { + allDebridCalls += 1; + return new Response(JSON.stringify({ status: "success", data: { link: "https://alldebrid.example/file.bin" } }), { + status: 200, + headers: { "Content-Type": "application/json" } + }); + } + return new Response("not-found", { status: 404 }); + }) as typeof fetch; + + const megaWeb = vi.fn(async () => null); + const service = new DebridService(settings, { megaWebUnrestrict: megaWeb }); + await expect(service.unrestrictLink("https://rapidgator.net/file/example.part5.rar.html")).rejects.toThrow(); + expect(allDebridCalls).toBe(0); + }); + + it("does not use secondary provider when fallback is disabled and primary is missing", async () => { + const settings = { + ...defaultSettings(), + token: "", + megaLogin: "user", + megaPassword: "pass", + megaCredentials: "user:pass", + providerPrimary: "realdebrid" as const, + providerSecondary: "megadebrid" as const, + providerTertiary: "none" as const, + autoProviderFallback: false + }; + + const megaWeb = vi.fn(async () => ({ + fileName: "should-not-run.bin", + directUrl: "https://unused", + fileSize: null, + retriesUsed: 0 + })); + + const service = new DebridService(settings, { megaWebUnrestrict: megaWeb }); + await expect(service.unrestrictLink("https://rapidgator.net/file/example.part5.rar.html")).rejects.toThrow(/nicht konfiguriert/i); + expect(megaWeb).toHaveBeenCalledTimes(0); + }); + + it("allows disabling secondary and tertiary providers", async () => { + const settings = { + ...defaultSettings(), + token: "rd-token", + megaLogin: "user", + megaPassword: "pass", + megaCredentials: "user:pass", + providerPrimary: "realdebrid" as const, + providerSecondary: "none" as const, + providerTertiary: "none" as const, + autoProviderFallback: true + }; + + globalThis.fetch = (async (input: RequestInfo | URL): Promise => { + const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url; + if (url.includes("api.real-debrid.com/rest/1.0/unrestrict/link")) { + return new Response(JSON.stringify({ error: "traffic_limit" }), { + status: 403, + headers: { "Content-Type": "application/json" } + }); + } + return new Response("not-found", { status: 404 }); + }) as typeof fetch; + + const megaWeb = vi.fn(async () => ({ + fileName: "unused.bin", + directUrl: "https://unused", + fileSize: null, + retriesUsed: 0 + })); + + const service = new DebridService(settings, { megaWebUnrestrict: megaWeb }); + await expect(service.unrestrictLink("https://rapidgator.net/file/example.part6.rar.html")).rejects.toThrow(); + expect(megaWeb).toHaveBeenCalledTimes(0); + }); + + it("resolves rapidgator filename from page when provider returns hash", async () => { + const settings = { + ...defaultSettings(), + token: "rd-token", + providerPrimary: "realdebrid" as const, + providerSecondary: "none" as const, + providerTertiary: "none" as const, + autoProviderFallback: true + }; + + globalThis.fetch = (async (input: RequestInfo | URL): Promise => { + const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url; + if (url.includes("api.real-debrid.com/rest/1.0/unrestrict/link")) { + return new Response(JSON.stringify({ + download: "https://cdn.example/file.bin", + filename: "6f09df2984fe01378537c7cd8d7fa7ce", + filesize: 2048 + }), { + status: 200, + headers: { "Content-Type": "application/json" } + }); + } + if (url.includes("rapidgator.net/file/6f09df2984fe01378537c7cd8d7fa7ce")) { + return new Response("download file Banshee.S04E01.German.DL.720p.part01.rar - Rapidgator", { + status: 200, + headers: { "Content-Type": "text/html" } + }); + } + return new Response("not-found", { status: 404 }); + }) as typeof fetch; + + const service = new DebridService(settings); + const result = await service.unrestrictLink("https://rapidgator.net/file/6f09df2984fe01378537c7cd8d7fa7ce"); + expect(result.provider).toBe("realdebrid"); + expect(result.fileName).toBe("Banshee.S04E01.German.DL.720p.part01.rar"); + }); + + it("resolves filenames for rg.to links", async () => { + const settings = { + ...defaultSettings(), + allDebridToken: "" + }; + + const link = "https://rg.to/file/685cec6dcc1837dc725755fc9c726dd9"; + globalThis.fetch = (async (input: RequestInfo | URL): Promise => { + const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url; + if (url === link) { + return new Response("Download file Bulletproof.S01E01.German.DL.DD20.Synced.720p.AmazonHD.h264-GDR.part01.rar", { + status: 200, + headers: { "Content-Type": "text/html" } + }); + } + return new Response("not-found", { status: 404 }); + }) as typeof fetch; + + const service = new DebridService(settings); + const resolved = await service.resolveFilenames([link]); + expect(resolved.get(link)).toBe("Bulletproof.S01E01.German.DL.DD20.Synced.720p.AmazonHD.h264-GDR.part01.rar"); + }); + + it("does not unrestrict non-rapidgator links during filename scan", async () => { + const settings = { + ...defaultSettings(), + token: "rd-token", + providerPrimary: "realdebrid" as const, + providerSecondary: "none" as const, + providerTertiary: "none" as const, + autoProviderFallback: true, + allDebridToken: "" + }; + + const linkFromPage = "https://rapidgator.net/file/11111111111111111111111111111111"; + const linkFromProvider = "https://hoster.example/file/22222222222222222222222222222222"; + let unrestrictCalls = 0; + + globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit): Promise => { + const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url; + + if (url === linkFromPage) { + return new Response("Download file from-page.part1.rar", { + status: 200, + headers: { "Content-Type": "text/html" } + }); + } + + if (url.includes("api.real-debrid.com/rest/1.0/unrestrict/link")) { + unrestrictCalls += 1; + const body = init?.body; + const bodyText = body instanceof URLSearchParams ? body.toString() : String(body || ""); + const linkValue = new URLSearchParams(bodyText).get("link") || ""; + if (linkValue === linkFromProvider) { + return new Response(JSON.stringify({ + download: "https://cdn.example/from-provider", + filename: "from-provider.part2.rar", + filesize: 1024 + }), { + status: 200, + headers: { "Content-Type": "application/json" } + }); + } + } + + return new Response("not-found", { status: 404 }); + }) as typeof fetch; + + const service = new DebridService(settings); + const events: Array<{ link: string; fileName: string }> = []; + const resolved = await service.resolveFilenames([linkFromPage, linkFromProvider], (link, fileName) => { + events.push({ link, fileName }); + }); + + expect(resolved.get(linkFromPage)).toBe("from-page.part1.rar"); + expect(resolved.has(linkFromProvider)).toBe(false); + expect(unrestrictCalls).toBe(0); + expect(events).toEqual(expect.arrayContaining([ + { link: linkFromPage, fileName: "from-page.part1.rar" } + ])); + }); + + it("does not unrestrict rapidgator links during filename scan after page lookup miss", async () => { + const settings = { + ...defaultSettings(), + token: "rd-token", + providerPrimary: "realdebrid" as const, + providerSecondary: "none" as const, + providerTertiary: "none" as const, + allDebridToken: "" + }; + + const link = "https://rapidgator.net/file/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; + let unrestrictCalls = 0; + + globalThis.fetch = (async (input: RequestInfo | URL): Promise => { + const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url; + if (url.includes("api.real-debrid.com/rest/1.0/unrestrict/link")) { + unrestrictCalls += 1; + return new Response(JSON.stringify({ error: "should-not-be-called" }), { + status: 500, + headers: { "Content-Type": "application/json" } + }); + } + if (url === link) { + return new Response("not found", { status: 404 }); + } + return new Response("not-found", { status: 404 }); + }) as typeof fetch; + + const service = new DebridService(settings); + const resolved = await service.resolveFilenames([link]); + expect(resolved.size).toBe(0); + expect(unrestrictCalls).toBe(0); + }); + + it("maps AllDebrid filename infos by index when response link is missing", async () => { + const settings = { + ...defaultSettings(), + token: "", + bestToken: "", + allDebridToken: "ad-token", + providerPrimary: "realdebrid" as const, + providerSecondary: "none" as const, + providerTertiary: "none" as const, + autoProviderFallback: true + }; + + const linkA = "https://rapidgator.net/file/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; + const linkB = "https://rapidgator.net/file/bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; + + globalThis.fetch = (async (input: RequestInfo | URL): Promise => { + const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url; + if (url.includes("api.alldebrid.com/v4/link/infos")) { + return new Response(JSON.stringify({ + status: "success", + data: { + infos: [ + { filename: "wrong-a.mkv" }, + { filename: "wrong-b.mkv" } + ] + } + }), { + status: 200, + headers: { "Content-Type": "application/json" } + }); + } + if (url === linkA || url === linkB) { + return new Response("no title", { status: 404 }); + } + return new Response("not-found", { status: 404 }); + }) as typeof fetch; + + const service = new DebridService(settings); + const resolved = await service.resolveFilenames([linkA, linkB]); + expect(resolved.get(linkA)).toBe("wrong-a.mkv"); + expect(resolved.get(linkB)).toBe("wrong-b.mkv"); + expect(resolved.size).toBe(2); + }); + + it("retries AllDebrid filename infos after transient server error", async () => { + const settings = { + ...defaultSettings(), + allDebridToken: "ad-token" + }; + + const link = "https://rapidgator.net/file/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; + let infoCalls = 0; + + globalThis.fetch = (async (input: RequestInfo | URL): Promise => { + const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url; + if (url.includes("api.alldebrid.com/v4/link/infos")) { + infoCalls += 1; + if (infoCalls === 1) { + return new Response("temporary error", { status: 500 }); + } + return new Response(JSON.stringify({ + status: "success", + data: { + infos: [ + { link, filename: "resolved-from-infos.mkv" } + ] + } + }), { + status: 200, + headers: { "Content-Type": "application/json" } + }); + } + return new Response("not-found", { status: 404 }); + }) as typeof fetch; + + const service = new DebridService(settings); + const resolved = await service.resolveFilenames([link]); + expect(resolved.get(link)).toBe("resolved-from-infos.mkv"); + expect(infoCalls).toBe(2); + }); + + it("retries AllDebrid filename infos when HTML challenge is returned", async () => { + const settings = { + ...defaultSettings(), + allDebridToken: "ad-token" + }; + + const link = "https://rapidgator.net/file/bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; + let infoCalls = 0; + let pageCalls = 0; + + globalThis.fetch = (async (input: RequestInfo | URL): Promise => { + const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url; + if (url.includes("api.alldebrid.com/v4/link/infos")) { + infoCalls += 1; + return new Response("cf challenge", { + status: 200, + headers: { "Content-Type": "text/html" } + }); + } + if (url === link) { + pageCalls += 1; + } + return new Response("not-found", { status: 404 }); + }) as typeof fetch; + + const service = new DebridService(settings); + const resolved = await service.resolveFilenames([link]); + expect(resolved.size).toBe(0); + expect(infoCalls).toBe(REQUEST_RETRIES); + expect(pageCalls).toBe(1); + }); +}); + +describe("normalizeResolvedFilename", () => { + it("strips HTML entities", () => { + expect(normalizeResolvedFilename("Show.S01E01.German.DL.720p.part01.rar")).toBe("Show.S01E01.German.DL.720p.part01.rar"); + expect(normalizeResolvedFilename("File&Name.part1.rar")).toBe("File&Name.part1.rar"); + expect(normalizeResolvedFilename("File"Name".part1.rar")).toBe('File"Name".part1.rar'); + }); + + it("strips HTML tags and collapses whitespace", () => { + const result = normalizeResolvedFilename("Show.S01E01.part01.rar"); + expect(result).toBe("Show.S01E01 .part01.rar"); + + const entityTagResult = normalizeResolvedFilename("File<Tag>.part1.rar"); + expect(entityTagResult).toBe("File .part1.rar"); + }); + + it("strips 'download file' prefix", () => { + expect(normalizeResolvedFilename("Download file Show.S01E01.part01.rar")).toBe("Show.S01E01.part01.rar"); + expect(normalizeResolvedFilename("download file Movie.2024.mkv")).toBe("Movie.2024.mkv"); + }); + + it("strips Rapidgator suffix", () => { + expect(normalizeResolvedFilename("Show.S01E01.part01.rar - Rapidgator")).toBe("Show.S01E01.part01.rar"); + expect(normalizeResolvedFilename("Movie.mkv | Rapidgator.net")).toBe("Movie.mkv"); + }); + + it("returns empty for opaque or non-filename values", () => { + expect(normalizeResolvedFilename("")).toBe(""); + expect(normalizeResolvedFilename("just some text")).toBe(""); + expect(normalizeResolvedFilename("e51f6809bb6ca615601f5ac5db433737")).toBe(""); + expect(normalizeResolvedFilename("download.bin")).toBe(""); + }); + + it("handles combined transforms", () => { + expect(normalizeResolvedFilename("Download file Show.S01E01.part01.rar - Rapidgator")) + .toBe("Show.S01E01.part01.rar"); + }); +}); + +describe("filenameFromRapidgatorUrlPath", () => { + it("extracts filename from standard rapidgator URL", () => { + expect(filenameFromRapidgatorUrlPath("https://rapidgator.net/file/abc123/Show.S01E01.part01.rar.html")) + .toBe("Show.S01E01.part01.rar"); + }); + + it("extracts filename without .html suffix", () => { + expect(filenameFromRapidgatorUrlPath("https://rapidgator.net/file/abc123/Movie.2024.mkv")) + .toBe("Movie.2024.mkv"); + }); + + it("returns empty for hash-only URL paths", () => { + expect(filenameFromRapidgatorUrlPath("https://rapidgator.net/file/e51f6809bb6ca615601f5ac5db433737")) + .toBe(""); + }); + + it("returns empty for invalid URLs", () => { + expect(filenameFromRapidgatorUrlPath("not-a-url")).toBe(""); + expect(filenameFromRapidgatorUrlPath("")).toBe(""); + }); + + it("handles URL-encoded path segments", () => { + expect(filenameFromRapidgatorUrlPath("https://rapidgator.net/file/id/Show%20Name.S01E01.part01.rar.html")) + .toBe("Show Name.S01E01.part01.rar"); + }); +}); + +describe("extractRapidgatorFilenameFromHtml", () => { + it("extracts filename from title tag", () => { + const html = "Download file Show.S01E01.German.DL.720p.part01.rar - Rapidgator"; + expect(extractRapidgatorFilenameFromHtml(html)).toBe("Show.S01E01.German.DL.720p.part01.rar"); + }); + + it("extracts filename from og:title meta tag", () => { + const html = ''; + expect(extractRapidgatorFilenameFromHtml(html)).toBe("Movie.2024.German.DL.1080p.mkv"); + }); + + it("extracts filename from reversed og:title attribute order", () => { + const html = ''; + expect(extractRapidgatorFilenameFromHtml(html)).toBe("Movie.2024.German.DL.1080p.mkv"); + }); + + it("returns empty for HTML without recognizable filenames", () => { + const html = "Rapidgator: Fast, Pair and UnlimitedNo file here"; + expect(extractRapidgatorFilenameFromHtml(html)).toBe(""); + }); + + it("returns empty for empty HTML", () => { + expect(extractRapidgatorFilenameFromHtml("")).toBe(""); + }); + + it("ignores broad body text that is not a labeled filename", () => { + const html = "Please download file now from mirror.mkv"; + expect(extractRapidgatorFilenameFromHtml(html)).toBe(""); + }); + + it("extracts from File name label in page body", () => { + const html = 'File name: Show.S02E03.720p.part01.rar'; + expect(extractRapidgatorFilenameFromHtml(html)).toBe("Show.S02E03.720p.part01.rar"); + }); +}); diff --git a/tests/debug-server-allowlist.test.ts b/tests/debug-server-allowlist.test.ts new file mode 100644 index 0000000..8fc4a7f --- /dev/null +++ b/tests/debug-server-allowlist.test.ts @@ -0,0 +1,146 @@ +import fs from "node:fs"; +import http from "node:http"; +import os from "node:os"; +import path from "node:path"; +import { once } from "node:events"; +import { afterEach, describe, expect, it } from "vitest"; + +import { + startDebugServer, + stopDebugServer, + restartDebugServer, + writeDebugServerConfig, + getDebugServerRuntimeStatus, + evaluateClientAllowed, + getPeerIp +} from "../src/main/debug-server"; +import type { DownloadManager } from "../src/main/download-manager"; + +const tempDirs: string[] = []; +const TOKEN = "allowlist-secret"; + +async function getFreePort(): Promise { + const probe = http.createServer(); + probe.listen(0, "127.0.0.1"); + await once(probe, "listening"); + const address = probe.address(); + if (!address || typeof address === "string") { + throw new Error("port probe failed"); + } + probe.close(); + await once(probe, "close"); + return address.port; +} + +async function waitForReady(url: string): Promise { + const deadline = Date.now() + 5000; + while (Date.now() < deadline) { + try { + const res = await fetch(url); + if (res.ok) { + return; + } + } catch { + } + await new Promise((resolve) => setTimeout(resolve, 40)); + } + throw new Error(`debug server not ready: ${url}`); +} + +async function startWithAllowlist(allowlist: string[], host = "0.0.0.0"): Promise<{ baseUrl: string }> { + const baseDir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-allow-")); + tempDirs.push(baseDir); + const port = await getFreePort(); + fs.writeFileSync(path.join(baseDir, "debug_token.txt"), TOKEN, "utf8"); + fs.writeFileSync(path.join(baseDir, "debug_port.txt"), String(port), "utf8"); + fs.writeFileSync(path.join(baseDir, "debug_host.txt"), host, "utf8"); + fs.writeFileSync(path.join(baseDir, "debug_allowlist.txt"), allowlist.join("\n"), "utf8"); + const manager = {} as unknown as DownloadManager; + startDebugServer(manager, baseDir); + const baseUrl = `http://127.0.0.1:${port}`; + await waitForReady(`${baseUrl}/health?token=${TOKEN}`); + return { baseUrl }; +} + +afterEach(() => { + stopDebugServer(); + while (tempDirs.length > 0) { + const dir = tempDirs.pop(); + if (!dir) { + continue; + } + try { + fs.rmSync(dir, { recursive: true, force: true }); + } catch { + } + } +}); + +describe("debug-server allowlist matcher (pure)", () => { + it("always allows loopback regardless of rules", () => { + expect(evaluateClientAllowed("127.0.0.1", [])).toBe(true); + expect(evaluateClientAllowed("::1", [])).toBe(true); + expect(evaluateClientAllowed("::ffff:127.0.0.1", ["8.8.8.8"])).toBe(true); + }); + + it("matches an exact allowlisted IP and rejects others", () => { + expect(evaluateClientAllowed("8.8.8.8", ["8.8.8.8"])).toBe(true); + expect(evaluateClientAllowed("9.9.9.9", ["8.8.8.8"])).toBe(false); + }); + + it("matches inside a CIDR and rejects outside it", () => { + expect(evaluateClientAllowed("10.0.0.42", ["10.0.0.0/24"])).toBe(true); + expect(evaluateClientAllowed("10.0.1.42", ["10.0.0.0/24"])).toBe(false); + }); + + it("fail-closed: empty rules reject every non-loopback client", () => { + expect(evaluateClientAllowed("203.0.113.7", [])).toBe(false); + expect(evaluateClientAllowed("8.8.8.8", [])).toBe(false); + }); + + it("derives the client IP from the socket peer, never from X-Forwarded-For", () => { + const forgedLoopback = { + socket: { remoteAddress: "8.8.8.8" }, + headers: { "x-forwarded-for": "127.0.0.1" } + } as unknown as http.IncomingMessage; + expect(getPeerIp(forgedLoopback)).toBe("8.8.8.8"); + expect(evaluateClientAllowed(getPeerIp(forgedLoopback), [])).toBe(false); + expect(evaluateClientAllowed(getPeerIp(forgedLoopback), ["9.9.9.9"])).toBe(false); + expect(evaluateClientAllowed(getPeerIp(forgedLoopback), ["8.8.8.8"])).toBe(true); + + const ipv6Mapped = { + socket: { remoteAddress: "::ffff:10.0.0.5" }, + headers: {} + } as unknown as http.IncomingMessage; + expect(getPeerIp(ipv6Mapped)).toBe("10.0.0.5"); + }); +}); + +describe("debug-server allowlist enforcement (wired)", () => { + it("allows a loopback connection and ignores a spoofed X-Forwarded-For", async () => { + const { baseUrl } = await startWithAllowlist(["8.8.8.8"]); + const plain = await fetch(`${baseUrl}/health?token=${TOKEN}`); + expect(plain.status).toBe(200); + const spoofed = await fetch(`${baseUrl}/health?token=${TOKEN}`, { + headers: { "X-Forwarded-For": "203.0.113.9" } + }); + expect(spoofed.status).toBe(200); + }); + + it("still enforces the token for loopback clients", async () => { + const { baseUrl } = await startWithAllowlist(["8.8.8.8"]); + const res = await fetch(`${baseUrl}/health`); + expect(res.status).toBe(401); + }); + + it("reloads the allowlist live via restartDebugServer", async () => { + const { baseUrl } = await startWithAllowlist(["8.8.8.8"]); + expect(getDebugServerRuntimeStatus().allowlistCount).toBe(1); + writeDebugServerConfig({ allowlist: ["9.9.9.9", "10.0.0.0/24"] }); + const status = await restartDebugServer(); + expect(status.running).toBe(true); + expect(status.allowlistCount).toBe(2); + await waitForReady(`${baseUrl}/health?token=${TOKEN}`); + expect((await fetch(`${baseUrl}/health?token=${TOKEN}`)).status).toBe(200); + }); +}); diff --git a/tests/debug-server.test.ts b/tests/debug-server.test.ts new file mode 100644 index 0000000..aaab78c --- /dev/null +++ b/tests/debug-server.test.ts @@ -0,0 +1,563 @@ +import fs from "node:fs"; +import http from "node:http"; +import os from "node:os"; +import path from "node:path"; +import { once } from "node:events"; +import AdmZip from "adm-zip"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +vi.mock("../src/main/windows-host-diagnostics", () => ({ + getWindowsHostDiagnostics: () => ({ + collectedAt: "2026-03-09T00:00:03.000Z", + supported: true, + platform: "win32", + crashControl: { + crashDumpEnabled: 3, + minidumpDir: "C:\\Windows\\Minidumps", + dumpFile: "C:\\Windows\\MEMORY.DMP", + overwrite: 1, + logEvent: 1, + autoReboot: 1 + }, + recentKernelPower: [ + { + timeCreated: "2026-03-09T00:00:04.000Z", + id: 41, + providerName: "Microsoft-Windows-Kernel-Power", + levelDisplayName: "Critical", + message: "unexpected restart", + bugcheckCode: "0", + bugcheckCodeHex: "", + reportId: "" + } + ], + recentWerKernel: [], + recentKernelDump: [], + recentAppCrashes: [], + recentMinidumps: [], + assessmentHints: ["watchdog hint"], + errors: [] + }) +})); + +import { defaultSettings } from "../src/main/constants"; +import { getAuditLogPath, initAuditLog, logAuditEvent, shutdownAuditLog } from "../src/main/audit-log"; +import { startDebugServer, stopDebugServer } from "../src/main/debug-server"; +import { ensureItemLog, initItemLogs, shutdownItemLogs } from "../src/main/item-log"; +import { configureLogger, getLogFilePath, logger } from "../src/main/logger"; +import { ensurePackageLog, initPackageLogs, shutdownPackageLogs } from "../src/main/package-log"; +import { getRenameLogPath, initRenameLog, logRenameEvent, shutdownRenameLog } from "../src/main/rename-log"; +import { getSessionLogPath, initSessionLog, shutdownSessionLog } from "../src/main/session-log"; +import { createStoragePaths, saveHistory, saveSettings } from "../src/main/storage"; +import { getTraceConfigPath, getTraceLogPath, initTraceLog, logTraceEvent, setTraceEnabled, shutdownTraceLog } from "../src/main/trace-log"; +import { getDebridLinkApiKeyIds } from "../src/shared/debrid-link-keys"; +import type { DownloadManager } from "../src/main/download-manager"; +import type { UiSnapshot } from "../src/shared/types"; + +const tempDirs: string[] = []; +const legacyManifestFile = ["debug_", "a", "i", "_manifest.json"].join(""); +const legacyManifestField = ["a", "i", "Manifest"].join(""); +const forbiddenSupportMarkers = [ + ["A", "I"].join(""), + ["assist", "ant"].join(""), + ["assist", "ants"].join(""), + ["ag", "ent"].join(""), + ["ag", "ents"].join(""), + ["Clau", "de"].join(""), + ["Anthro", "pic"].join(""), + ["Co", "dex"].join(""), + ["Open", "AI"].join(""), + ["M", "C", "P"].join(""), + ["K", "I"].join("") +]; + +async function getFreePort(): Promise { + const probe = http.createServer(); + probe.listen(0, "127.0.0.1"); + await once(probe, "listening"); + const address = probe.address(); + if (!address || typeof address === "string") { + throw new Error("port probe failed"); + } + probe.close(); + await once(probe, "close"); + return address.port; +} + +async function waitForReady(url: string): Promise { + const deadline = Date.now() + 5000; + while (Date.now() < deadline) { + try { + const response = await fetch(url); + if (response.ok) { + return; + } + } catch { + } + await new Promise((resolve) => setTimeout(resolve, 50)); + } + throw new Error(`debug server not ready: ${url}`); +} + +function buildSnapshot(baseDir: string): UiSnapshot { + const settings = { + ...defaultSettings(), + outputDir: path.join(baseDir, "downloads"), + extractDir: path.join(baseDir, "extract") + }; + + return { + settings, + session: { + version: 1, + packageOrder: ["pkg-1"], + packages: { + "pkg-1": { + id: "pkg-1", + name: "server-package", + outputDir: path.join(baseDir, "downloads", "server-package"), + extractDir: path.join(baseDir, "extract", "server-package"), + status: "downloading", + itemIds: ["item-1", "item-2"], + cancelled: false, + enabled: true, + priority: "normal", + postProcessLabel: "", + createdAt: Date.now() - 30_000, + updatedAt: Date.now() + } + }, + items: { + "item-1": { + id: "item-1", + packageId: "pkg-1", + url: "https://hoster.example/file-1", + provider: "realdebrid", + providerLabel: "Real-Debrid", + status: "downloading", + retries: 1, + speedBps: 8 * 1024 * 1024, + downloadedBytes: 64 * 1024 * 1024, + totalBytes: 256 * 1024 * 1024, + progressPercent: 25, + fileName: "episode.part1.rar", + targetPath: path.join(baseDir, "downloads", "server-package", "episode.part1.rar"), + resumable: true, + attempts: 1, + lastError: "", + fullStatus: "Download läuft (Real-Debrid)", + createdAt: Date.now() - 30_000, + updatedAt: Date.now() + }, + "item-2": { + id: "item-2", + packageId: "pkg-1", + url: "https://hoster.example/file-2", + provider: "realdebrid", + providerLabel: "Real-Debrid", + status: "failed", + retries: 3, + speedBps: 0, + downloadedBytes: 0, + totalBytes: null, + progressPercent: 0, + fileName: "episode.part2.rar", + targetPath: path.join(baseDir, "downloads", "server-package", "episode.part2.rar"), + resumable: false, + attempts: 3, + lastError: "hoster unavailable", + fullStatus: "Fehler: hoster unavailable", + createdAt: Date.now() - 30_000, + updatedAt: Date.now() + } + }, + runStartedAt: Date.now() - 30_000, + totalDownloadedBytes: 64 * 1024 * 1024, + summaryText: "", + reconnectUntil: 0, + reconnectReason: "", + paused: false, + running: true, + updatedAt: Date.now() + }, + summary: null, + stats: { + totalDownloaded: 64 * 1024 * 1024, + totalDownloadedAllTime: 128 * 1024 * 1024, + totalFilesSession: 0, + totalFilesAllTime: 0, + totalPackages: 1, + sessionStartedAt: Date.now() - 30_000, + appSessionStartedAt: Date.now() - 60_000, + sessionRuntimeMs: 60_000, + totalRuntimeMs: 3 * 60_000, + runtimeMeasuredAt: Date.now() + }, + speedText: "8.0 MB/s", + etaText: "ETA: 00:25", + canStart: false, + canStop: true, + canPause: true, + clipboardActive: false, + reconnectSeconds: 0, + packageSpeedBps: { + "pkg-1": 8 * 1024 * 1024 + } + }; +} + +async function createFixture() { + const baseDir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-debug-")); + tempDirs.push(baseDir); + const token = "debug-secret"; + const port = await getFreePort(); + const snapshot = buildSnapshot(baseDir); + const storagePaths = createStoragePaths(baseDir); + + fs.writeFileSync(path.join(baseDir, "debug_token.txt"), token, "utf8"); + fs.writeFileSync(path.join(baseDir, "debug_port.txt"), String(port), "utf8"); + fs.writeFileSync(path.join(baseDir, "debug_host.txt"), "0.0.0.0", "utf8"); + const debridLinkApiKeys = "key-a\nkey-b"; + const debridLinkKeyIds = getDebridLinkApiKeyIds(debridLinkApiKeys); + + saveSettings(storagePaths, { + ...snapshot.settings, + token: "rd-secret-token", + realDebridUseWebLogin: true, + debridLinkApiKeys, + debridLinkDisabledKeyIds: debridLinkKeyIds[1] ? [debridLinkKeyIds[1]] : [], + totalDownloadedAllTime: 128 * 1024 * 1024, + totalCompletedFilesAllTime: 12, + totalRuntimeAllTimeMs: 5 * 60_000 + }); + saveHistory(storagePaths, [ + { + id: "hist-1", + name: "server-package", + totalBytes: 123, + downloadedBytes: 123, + fileCount: 2, + provider: "realdebrid", + completedAt: Date.now() - 5_000, + durationSeconds: 42, + status: "completed", + outputDir: path.join(baseDir, "downloads", "server-package"), + urls: ["https://hoster.example/file-1"] + } + ]); + + configureLogger(baseDir); + fs.writeFileSync(getLogFilePath(), "2026-03-09T00:00:00.000Z [INFO] MAIN-LINE\n", "utf8"); + initAuditLog(baseDir); + const auditLogPath = getAuditLogPath(); + if (!auditLogPath) { + throw new Error("audit log path missing"); + } + logAuditEvent("INFO", "AUDIT-LINE", { scope: "settings" }); + + initRenameLog(baseDir); + logRenameEvent("INFO", "RENAME-LINE", { stage: "auto-rename", sourcePath: "C:\\extract\\old.mkv" }); + + initTraceLog(baseDir); + setTraceEnabled(true, "test-fixture"); + logTraceEvent("INFO", "support", "TRACE-EVENT", { scope: "fixture" }); + + initSessionLog(baseDir); + const sessionLogPath = getSessionLogPath(); + if (!sessionLogPath) { + throw new Error("session log path missing"); + } + fs.appendFileSync(sessionLogPath, "2026-03-09T00:00:01.000Z [INFO] SESSION-LINE\n", "utf8"); + logger.info("TRACE-MAIN-LINE"); + + initPackageLogs(baseDir); + initItemLogs(baseDir); + const packageLogPath = ensurePackageLog({ + packageId: "pkg-1", + name: "server-package", + outputDir: snapshot.session.packages["pkg-1"]!.outputDir, + extractDir: snapshot.session.packages["pkg-1"]!.extractDir + }); + if (!packageLogPath) { + throw new Error("package log path missing"); + } + fs.appendFileSync(packageLogPath, "2026-03-09T00:00:02.000Z [INFO] PACKAGE-LINE\n", "utf8"); + const itemLogPath = ensureItemLog({ + itemId: "item-2", + packageId: "pkg-1", + packageName: "server-package", + fileName: "episode.part2.rar", + targetPath: snapshot.session.items["item-2"]!.targetPath + }); + if (!itemLogPath) { + throw new Error("item log path missing"); + } + fs.appendFileSync(itemLogPath, "2026-03-09T00:00:03.000Z [ERROR] ITEM-LINE\n", "utf8"); + + const manager = { + getSnapshot: () => snapshot, + getPackageLogPath: (packageId: string) => packageId === "pkg-1" ? packageLogPath : null, + getItemLogPath: (itemId: string) => itemId === "item-2" ? itemLogPath : null + } as unknown as DownloadManager; + + startDebugServer(manager, baseDir); + const baseUrl = `http://127.0.0.1:${port}`; + await waitForReady(`${baseUrl}/health?token=${token}`); + await new Promise((resolve) => setTimeout(resolve, 300)); + + return { + baseUrl, + token, + baseDir + }; +} + +afterEach(() => { + stopDebugServer(); + shutdownSessionLog(); + shutdownPackageLogs(); + shutdownItemLogs(); + shutdownRenameLog(); + shutdownTraceLog(); + shutdownAuditLog(); + while (tempDirs.length > 0) { + const dir = tempDirs.pop(); + if (!dir) { + continue; + } + try { + fs.rmSync(dir, { recursive: true, force: true }); + } catch { + } + } +}); + +describe("debug-server", () => { + it("serves diagnostics with main, session, and package log tails", async () => { + const fixture = await createFixture(); + const response = await fetch(`${fixture.baseUrl}/diagnostics?token=${fixture.token}&package=server-package&lines=20`); + expect(response.ok).toBe(true); + const payload = await response.json() as Record; + + expect(payload.meta?.appVersion).toBeTruthy(); + expect(payload.meta?.debugServer?.host).toBe("0.0.0.0"); + expect(payload.status?.running).toBe(true); + expect(payload.host?.platform).toBe("win32"); + expect(payload.host?.recentKernelPower?.[0]?.id).toBe(41); + expect(payload.selectedPackage?.name).toBe("server-package"); + expect((payload.logs?.main?.lines || []).join("\n")).toContain("MAIN-LINE"); + expect((payload.logs?.audit?.lines || []).join("\n")).toContain("AUDIT-LINE"); + expect((payload.logs?.rename?.lines || []).join("\n")).toContain("RENAME-LINE"); + expect((payload.logs?.trace?.lines || []).join("\n")).toContain("TRACE-EVENT"); + expect((payload.logs?.session?.lines || []).join("\n")).toContain("SESSION-LINE"); + expect((payload.logs?.package?.lines || []).join("\n")).toContain("PACKAGE-LINE"); + expect(payload.accounts?.realDebrid?.configured).toBe(true); + expect(payload.history?.total).toBe(1); + }); + + it("writes a machine-readable support manifest into the runtime folder", async () => { + const fixture = await createFixture(); + const manifestPath = path.join(fixture.baseDir, "debug_support_manifest.json"); + expect(fs.existsSync(manifestPath)).toBe(true); + expect(fs.existsSync(path.join(fixture.baseDir, legacyManifestFile))).toBe(false); + + const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8")) as Record; + expect(manifest.purpose).toBe("Machine-readable manifest for support tooling and remote troubleshooting."); + expect(JSON.stringify(manifest)).not.toMatch(new RegExp(`\\b(?:${forbiddenSupportMarkers.join("|")})\\b`, "i")); + expect(JSON.stringify(manifest)).not.toContain(fixture.token); + expect(manifest.debugServer?.port).toBeGreaterThan(0); + expect(manifest.debugServer?.remoteBaseUrlTemplate).toContain(""); + expect(manifest.remoteAccessRequirements).toContain("A reachable server IP or DNS name."); + expect(manifest.setupCheckEndpoint).toBe("/debug/setup"); + expect(manifest.selfCheckEndpoint).toBe("/self-check"); + expect(manifest.runtimeFiles?.tokenFile).toContain("debug_token.txt"); + expect(manifest.endpoints?.some((entry: Record) => entry.path === "/diagnostics")).toBe(true); + expect(manifest.endpoints?.some((entry: Record) => entry.path === "/logs/main")).toBe(true); + + const metaResponse = await fetch(`${fixture.baseUrl}/meta?token=${fixture.token}`); + expect(metaResponse.ok).toBe(true); + const metaPayload = await metaResponse.json() as Record; + expect(metaPayload.supportFiles?.supportManifest).toBe(manifestPath); + expect(metaPayload.supportFiles?.[legacyManifestField]).toBeUndefined(); + expect(metaPayload.supportFiles?.traceConfig).toBe(getTraceConfigPath()); + expect(metaPayload.supportFiles?.traceLog).toBe(getTraceLogPath()); + expect(metaPayload.logPaths?.rename).toBe(getRenameLogPath()); + expect(metaPayload.supportChecks?.setup).toBe("/debug/setup"); + expect(metaPayload.supportChecks?.selfCheck).toBe("/self-check"); + }); + + it("serves a debug setup check with trace expiry details", async () => { + const fixture = await createFixture(); + const response = await fetch(`${fixture.baseUrl}/debug/setup?token=${fixture.token}`); + expect(response.ok).toBe(true); + const payload = await response.json() as Record; + + expect(payload.enabled).toBe(true); + expect(payload.status).toBe("ok"); + expect(payload.runtimeBaseDir).toBe(fixture.baseDir); + expect(payload.host).toBe("0.0.0.0"); + expect(payload.localOnly).toBe(false); + expect(payload.tokenConfigured).toBe(true); + expect(payload.supportManifestPresent).toBe(true); + expect(payload[`${legacyManifestField}Present`]).toBeUndefined(); + expect(payload.supportManifestPath).toBe(path.join(fixture.baseDir, "debug_support_manifest.json")); + expect(payload.traceEnabled).toBe(true); + expect(payload.traceAutoDisableAt).toBeTruthy(); + expect(payload.diskSpace?.runtime?.freeBytes).toBeGreaterThan(0); + expect(payload.diskSpace?.output?.freeBytes).toBeGreaterThan(0); + expect(payload.diskSpace?.extract?.freeBytes).toBeGreaterThan(0); + expect(payload.logSummary?.totalBytes).toBeGreaterThan(0); + expect(payload.logSummary?.rename?.bytes).toBeGreaterThan(0); + expect(payload.logSummary?.packageLogs?.fileCount).toBe(1); + expect(payload.logSummary?.itemLogs?.fileCount).toBe(1); + expect(payload.supportBundle?.estimatedBytes).toBeGreaterThan(0); + expect(payload.remoteUrlTemplates?.health).toContain(""); + expect(Array.isArray(payload.notes)).toBe(true); + }); + + it("serves the self-check alias", async () => { + const fixture = await createFixture(); + const response = await fetch(`${fixture.baseUrl}/self-check?token=${fixture.token}`); + expect(response.ok).toBe(true); + const payload = await response.json() as Record; + expect(payload.status).toBe("ok"); + expect(payload.supportBundle?.estimatedEntries).toBeGreaterThan(0); + }); + + it("writes the client IP into the debug trace log", async () => { + const fixture = await createFixture(); + const response = await fetch(`${fixture.baseUrl}/health?token=${fixture.token}`, { + headers: { + "X-Forwarded-For": "159.195.63.46" + } + }); + expect(response.ok).toBe(true); + + await new Promise((resolve) => setTimeout(resolve, 200)); + const traceLogPath = getTraceLogPath(); + expect(traceLogPath).toBeTruthy(); + const traceText = fs.readFileSync(traceLogPath!, "utf8"); + expect(traceText).toContain("clientIp=159.195.63.46"); + }); + + it("serves package details and package log by package query", async () => { + const fixture = await createFixture(); + + const packagesResponse = await fetch(`${fixture.baseUrl}/packages?token=${fixture.token}&package=server&includeItems=1`); + expect(packagesResponse.ok).toBe(true); + const packagesPayload = await packagesResponse.json() as Record; + expect(packagesPayload.count).toBe(1); + expect(packagesPayload.packages?.[0]?.items?.length).toBe(2); + + const logResponse = await fetch(`${fixture.baseUrl}/logs/package?token=${fixture.token}&package=server-package&lines=20`); + expect(logResponse.ok).toBe(true); + const logPayload = await logResponse.json() as Record; + expect(logPayload.package?.name).toBe("server-package"); + expect((logPayload.lines || []).join("\n")).toContain("PACKAGE-LINE"); + }); + + it("serves item log by item query", async () => { + const fixture = await createFixture(); + + const response = await fetch(`${fixture.baseUrl}/logs/item?token=${fixture.token}&item=episode.part2.rar&lines=20`); + expect(response.ok).toBe(true); + const payload = await response.json() as Record; + expect(payload.item?.id).toBe("item-2"); + expect(payload.item?.fileName).toBe("episode.part2.rar"); + expect((payload.lines || []).join("\n")).toContain("ITEM-LINE"); + }); + + it("serves host diagnostics separately", async () => { + const fixture = await createFixture(); + const response = await fetch(`${fixture.baseUrl}/host/diagnostics?token=${fixture.token}`); + expect(response.ok).toBe(true); + const payload = await response.json() as Record; + expect(payload.platform).toBe("win32"); + expect(payload.crashControl?.crashDumpEnabled).toBe(3); + expect(payload.assessmentHints?.[0]).toContain("watchdog"); + }); + + it("serves audit log, settings, accounts, stats, and history", async () => { + const fixture = await createFixture(); + + const auditResponse = await fetch(`${fixture.baseUrl}/logs/audit?token=${fixture.token}&lines=20`); + expect(auditResponse.ok).toBe(true); + const auditPayload = await auditResponse.json() as Record; + expect((auditPayload.lines || []).join("\n")).toContain("AUDIT-LINE"); + + const renameResponse = await fetch(`${fixture.baseUrl}/logs/rename?token=${fixture.token}&lines=20`); + expect(renameResponse.ok).toBe(true); + const renamePayload = await renameResponse.json() as Record; + expect((renamePayload.lines || []).join("\n")).toContain("RENAME-LINE"); + + const traceResponse = await fetch(`${fixture.baseUrl}/logs/trace?token=${fixture.token}&lines=50`); + expect(traceResponse.ok).toBe(true); + const tracePayload = await traceResponse.json() as Record; + expect((tracePayload.lines || []).join("\n")).toContain("TRACE-EVENT"); + expect((tracePayload.lines || []).join("\n")).toContain("TRACE-MAIN-LINE"); + + const traceConfigResponse = await fetch(`${fixture.baseUrl}/trace/config?token=${fixture.token}&enable=0¬e=test`); + expect(traceConfigResponse.ok).toBe(true); + const traceConfigPayload = await traceConfigResponse.json() as Record; + expect(traceConfigPayload.config?.enabled).toBe(false); + + const settingsResponse = await fetch(`${fixture.baseUrl}/settings?token=${fixture.token}`); + expect(settingsResponse.ok).toBe(true); + const settingsPayload = await settingsResponse.json() as Record; + expect(settingsPayload.accounts?.realDebrid?.configured).toBe(true); + expect(settingsPayload.extraction?.archivePasswordCount).toBe(0); + expect(JSON.stringify(settingsPayload)).not.toContain("rd-secret-token"); + expect(JSON.stringify(settingsPayload)).not.toContain("key-a"); + expect(JSON.stringify(settingsPayload)).not.toContain("key-b"); + + const accountsResponse = await fetch(`${fixture.baseUrl}/accounts?token=${fixture.token}`); + expect(accountsResponse.ok).toBe(true); + const accountsPayload = await accountsResponse.json() as Record; + expect(accountsPayload.debridLink?.keyCount).toBe(2); + expect(accountsPayload.debridLink?.disabledKeyCount).toBe(1); + + const statsResponse = await fetch(`${fixture.baseUrl}/stats?token=${fixture.token}`); + expect(statsResponse.ok).toBe(true); + const statsPayload = await statsResponse.json() as Record; + expect(statsPayload.session?.totalDownloaded).toBeGreaterThan(0); + expect(statsPayload.allTime?.totalDownloadedAllTime).toBeGreaterThan(0); + + const historyResponse = await fetch(`${fixture.baseUrl}/history?token=${fixture.token}&limit=10`); + expect(historyResponse.ok).toBe(true); + const historyPayload = await historyResponse.json() as Record; + expect(historyPayload.total).toBe(1); + expect(historyPayload.entries?.[0]?.name).toBe("server-package"); + expect(historyPayload.entries?.[0]?.urlCount).toBe(1); + }); + + it("downloads a support bundle zip", async () => { + const fixture = await createFixture(); + fs.writeFileSync(path.join(fixture.baseDir, legacyManifestFile), JSON.stringify({ purpose: "legacy" }), "utf8"); + const response = await fetch(`${fixture.baseUrl}/support/bundle?token=${fixture.token}`); + expect(response.ok).toBe(true); + expect(response.headers.get("content-type")).toContain("application/zip"); + + const buffer = Buffer.from(await response.arrayBuffer()); + const zip = new AdmZip(buffer); + const entries = zip.getEntries().map((entry) => entry.entryName); + expect(entries).toContain("overview/settings.json"); + expect(entries).toContain("overview/accounts.json"); + expect(entries).toContain("overview/debug-setup.json"); + expect(entries).toContain("overview/self-check.json"); + expect(entries).toContain("overview/trace-config.json"); + expect(entries).toContain("logs/audit.log"); + expect(entries).toContain("logs/rename.log"); + expect(entries).toContain("logs/trace.log"); + expect(entries).toContain("runtime/debug_support_manifest.json"); + expect(entries).toContain("overview/support-manifest.json"); + expect(entries).not.toContain(`runtime/${legacyManifestFile}`); + expect(entries).not.toContain(["overview/", "a", "i-manifest.json"].join("")); + expect(entries).not.toContain("runtime/debug_token.txt"); + }); + + it("rejects unauthenticated requests", async () => { + const fixture = await createFixture(); + const response = await fetch(`${fixture.baseUrl}/status`); + expect(response.status).toBe(401); + }); +}); diff --git a/tests/desktop-rename-log.test.ts b/tests/desktop-rename-log.test.ts new file mode 100644 index 0000000..a19601b --- /dev/null +++ b/tests/desktop-rename-log.test.ts @@ -0,0 +1,124 @@ +import { afterEach, describe, expect, it } from "vitest"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { + getDesktopRenameLogPath, + initDesktopRenameLog, + logDesktopRename, + shutdownDesktopRenameLog, + verifyRename +} from "../src/main/desktop-rename-log"; + +const createdTmpDirs: string[] = []; + +function tmpDesktop(): string { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-rename-log-")); + createdTmpDirs.push(dir); + return dir; +} + +afterEach(() => { + shutdownDesktopRenameLog(); + for (const dir of createdTmpDirs) { + try { + fs.rmSync(dir, { recursive: true, force: true }); + } catch { + } + } + createdTmpDirs.length = 0; +}); + +describe("desktop-rename-log", () => { + it("creates the Downloader-Log folder + session file on init and appends formatted lines", () => { + const desktop = tmpDesktop(); + initDesktopRenameLog(desktop); + + const logPath = getDesktopRenameLogPath(); + expect(logPath).toBeTruthy(); + expect(path.dirname(logPath as string).endsWith("Downloader-Log")).toBe(true); + expect(fs.existsSync(logPath as string)).toBe(true); + + logDesktopRename("INFO", "Test-Rename", { source: "a.mkv", requested: "b.mkv" }); + const content = fs.readFileSync(logPath as string, "utf8"); + expect(content).toContain("Rename-Session gestartet"); + expect(content).toContain("Test-Rename"); + expect(content).toContain("source=a.mkv"); + expect(content).toContain("requested=b.mkv"); + expect(content).toMatch(/\[INFO\]/); + }); + + it("self-heals: recreates the whole Downloader-Log FOLDER and file if it is deleted mid-session", () => { + const desktop = tmpDesktop(); + initDesktopRenameLog(desktop); + const logPath = getDesktopRenameLogPath() as string; + logDesktopRename("INFO", "ZeileA"); + + fs.rmSync(path.join(desktop, "Downloader-Log"), { recursive: true, force: true }); + expect(fs.existsSync(logPath)).toBe(false); + + logDesktopRename("INFO", "ZeileB"); + expect(fs.existsSync(path.join(desktop, "Downloader-Log"))).toBe(true); + expect(fs.existsSync(logPath)).toBe(true); + + const content = fs.readFileSync(logPath, "utf8"); + expect(content).toContain("Rename-Session gestartet"); + expect(content).toContain("ZeileB"); + }); + + it("is a silent no-op when initialized without a desktop path (never throws)", () => { + initDesktopRenameLog(""); + expect(getDesktopRenameLogPath()).toBeNull(); + expect(() => logDesktopRename("INFO", "egal")).not.toThrow(); + }); + + it("verifyRename: ok when the target exists under the exact name and the source is gone", () => { + const dir = tmpDesktop(); + const source = path.join(dir, "scn-xyz.part1.rar"); + const target = path.join(dir, "Movie.2024.German.1080p.part1.rar"); + fs.writeFileSync(target, "data"); + + const v = verifyRename(source, target); + expect(v.ok).toBe(true); + expect(v.level).toBe("INFO"); + expect(v.targetExists).toBe(true); + expect(v.onDiskName).toBe("Movie.2024.German.1080p.part1.rar"); + expect(v.nameMatches).toBe(true); + expect(v.sourceGone).toBe(true); + expect(v.targetSize).toBe(4); + }); + + it("verifyRename: FAILS when the target is missing although rename reported success", () => { + const dir = tmpDesktop(); + const v = verifyRename(path.join(dir, "src.rar"), path.join(dir, "never-created.rar")); + expect(v.ok).toBe(false); + expect(v.level).toBe("ERROR"); + expect(v.targetExists).toBe(false); + expect(v.reason).toMatch(/nicht gefunden/i); + }); + + it("verifyRename: FAILS (half-done move) when the source still exists next to the target", () => { + const dir = tmpDesktop(); + const source = path.join(dir, "src.rar"); + const target = path.join(dir, "dst.rar"); + fs.writeFileSync(source, "x"); + fs.writeFileSync(target, "x"); + + const v = verifyRename(source, target); + expect(v.ok).toBe(false); + expect(v.level).toBe("ERROR"); + expect(v.sourceGone).toBe(false); + expect(v.reason).toMatch(/Quelldatei existiert noch/i); + }); + + it("verifyRename: an in-place rename (same path) is ok and does not flag a lingering source", () => { + const dir = tmpDesktop(); + const p = path.join(dir, "file.mkv"); + fs.writeFileSync(p, "x"); + + const v = verifyRename(p, p); + expect(v.ok).toBe(true); + expect(v.targetExists).toBe(true); + expect(v.nameMatches).toBe(true); + }); +}); diff --git a/tests/download-completion.test.ts b/tests/download-completion.test.ts new file mode 100644 index 0000000..7b679fd --- /dev/null +++ b/tests/download-completion.test.ts @@ -0,0 +1,90 @@ +import { describe, expect, it } from "vitest"; +import { planDownloadCompletion, reconcileFinalizedSize, validateDownloadedFileCompletion } from "../src/main/download-completion"; + +describe("download-completion", () => { + describe("planDownloadCompletion", () => { + it("uses content-length when present", () => { + const plan = planDownloadCompletion({ + existingBytes: 0, responseStatus: 200, contentLength: 1000, + totalFromRange: null, knownTotal: null, correctedTotal: null + }); + expect(plan.source).toBe("content-length"); + expect(plan.expectedTotal).toBe(1000); + }); + it("falls back to stream-end when no size info is available", () => { + const plan = planDownloadCompletion({ + existingBytes: 0, responseStatus: 200, contentLength: 0, + totalFromRange: null, knownTotal: null, correctedTotal: null + }); + expect(plan.source).toBe("stream-end"); + expect(plan.expectedTotal).toBeNull(); + }); + }); + + describe("validateDownloadedFileCompletion", () => { + const streamEnd = { expectedTotal: null, source: "stream-end" as const, canFinishEarly: false }; + const contentLength = (n: number) => ({ expectedTotal: n, source: "content-length" as const, canFinishEarly: true }); + const providerMeta = (n: number) => ({ expectedTotal: n, source: "provider-metadata" as const, canFinishEarly: false }); + + it("rejects a 0-byte stream-end download (H3)", () => { + const result = validateDownloadedFileCompletion({ actualBytes: 0, plan: streamEnd }); + expect(result.ok).toBe(false); + expect(result.error).toContain("download_underflow"); + }); + + it("accepts a non-empty stream-end download", () => { + const result = validateDownloadedFileCompletion({ actualBytes: 5_000_000, plan: streamEnd }); + expect(result.ok).toBe(true); + expect(result.totalBytes).toBe(5_000_000); + }); + + it("rejects an underflowing content-length download", () => { + const result = validateDownloadedFileCompletion({ actualBytes: 400, plan: contentLength(1000), toleranceBytes: 0 }); + expect(result.ok).toBe(false); + }); + + it("accepts a complete content-length download", () => { + const result = validateDownloadedFileCompletion({ actualBytes: 1000, plan: contentLength(1000) }); + expect(result.ok).toBe(true); + }); + + it("rejects a 0-byte download even with known provider size", () => { + const result = validateDownloadedFileCompletion({ actualBytes: 0, plan: providerMeta(2000) }); + expect(result.ok).toBe(false); + }); + + it("accepts provider-metadata download and flags size mismatch", () => { + const result = validateDownloadedFileCompletion({ actualBytes: 1900, plan: providerMeta(2000), toleranceBytes: 0 }); + expect(result.ok).toBe(false); + }); + }); + + describe("reconcileFinalizedSize", () => { + it("keeps the streamed count for a pre-allocated file whose on-disk size is the zero-padding (corruption guard)", () => { + expect(reconcileFinalizedSize(300_000_000, 1_000_000_000, true)).toBe(300_000_000); + }); + + it("shrinks to the on-disk size when a pre-allocated file is genuinely short (real partial write)", () => { + expect(reconcileFinalizedSize(500, 300, true)).toBe(300); + }); + + it("reconciles in both directions for a non-pre-allocated file (stat is authoritative)", () => { + expect(reconcileFinalizedSize(300, 1000, false)).toBe(1000); + expect(reconcileFinalizedSize(1000, 300, false)).toBe(300); + }); + + it("returns the streamed count unchanged when the stat is invalid", () => { + expect(reconcileFinalizedSize(1234, Number.NaN, true)).toBe(1234); + expect(reconcileFinalizedSize(1234, -1, false)).toBe(1234); + }); + + it("is a no-op when on-disk size already equals the streamed count", () => { + expect(reconcileFinalizedSize(777, 777, true)).toBe(777); + expect(reconcileFinalizedSize(777, 777, false)).toBe(777); + }); + + it("does not block legitimate overshoot on a pre-allocated file (server sent more than pre-alloc)", () => { + expect(reconcileFinalizedSize(900, 900, true)).toBe(900); + }); + }); +}); diff --git a/tests/download-manager.test.ts b/tests/download-manager.test.ts new file mode 100644 index 0000000..8cf1df1 --- /dev/null +++ b/tests/download-manager.test.ts @@ -0,0 +1,12624 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import http from "node:http"; +import crypto from "node:crypto"; +import { EventEmitter, once } from "node:events"; +import AdmZip from "adm-zip"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { DownloadManager, buildAutoRenameBaseNameFromFoldersWithOptions, extractArchiveNameFromExtractorLogMessage, getAuthoritativeRealDebridTotal, resolveArchiveItemsFromList } from "../src/main/download-manager"; +import { planDownloadCompletion, validateDownloadedFileCompletion } from "../src/main/download-completion"; +import { defaultSettings } from "../src/main/constants"; +import { parseDebridLinkApiKeys } from "../src/shared/debrid-link-keys"; +import { getProviderUsageDayKey } from "../src/shared/provider-daily-limits"; +import { getItemLogPath, initItemLogs, shutdownItemLogs } from "../src/main/item-log"; +import { initPackageLogs, shutdownPackageLogs } from "../src/main/package-log"; +import { createStoragePaths, emptySession } from "../src/main/storage"; +import { primeDebridLinkRuntimeCooldownForTests, resetDebridLinkRuntimeStateForTests, primeMegaDebridRuntimeCooldownForTests, resetMegaDebridRuntimeStateForTests, primeMegaDebridInFlightForTests } from "../src/main/debrid"; +import { getMegaDebridAccountId } from "../src/shared/mega-debrid-accounts"; +import { getRenameLogPath, initRenameLog, shutdownRenameLog } from "../src/main/rename-log"; +import { UnrestrictedLink } from "../src/main/realdebrid"; + +const tempDirs: string[] = []; +const originalFetch = globalThis.fetch; + +describe("extractArchiveNameFromExtractorLogMessage", () => { + it("detects archive names from extractor log variants", () => { + expect(extractArchiveNameFromExtractorLogMessage("Extract-Backend Start: archive=scn-dhanbs7-S02E008.rar, mode=legacy")).toBe("scn-dhanbs7-S02E008.rar"); + expect(extractArchiveNameFromExtractorLogMessage("Entpacke Archiv: scn-dhanbs7-S02E008.rar -> C:\\target")).toBe("scn-dhanbs7-S02E008.rar"); + expect(extractArchiveNameFromExtractorLogMessage("Entpack-Fehler scn-dhanbs7-S02E008.rar [missing_parts]: Error: boom")).toBe("scn-dhanbs7-S02E008.rar"); + }); + + it("returns null when no archive name is present", () => { + expect(extractArchiveNameFromExtractorLogMessage("Post-Processing Entpacken Ende")).toBeNull(); + }); +}); + +describe("resolveArchiveItemsFromList", () => { + it("includes duplicate-suffixed archive copies in multipart matches", () => { + const items = [ + { id: "dup-1", fileName: "show.s01e26.part1.rar" }, + { id: "dup-2", fileName: "show.s01e26.part1.rar", targetPath: "C:\\Downloads\\show.s01e26.part1 (1).rar" }, + { id: "dup-3", fileName: "show.s01e26.part2.rar" } + ] as any[]; + + const resolved = resolveArchiveItemsFromList("show.s01e26.part1.rar", items); + + expect(resolved.map((item) => item.id)).toEqual(["dup-1", "dup-2", "dup-3"]); + }); +}); + +describe("download completion planning", () => { + it("does not allow early finish on provider metadata alone", () => { + expect(planDownloadCompletion({ + existingBytes: 0, + responseStatus: 200, + contentLength: 0, + totalFromRange: null, + knownTotal: 192 * 1024, + correctedTotal: null + })).toEqual({ + expectedTotal: 192 * 1024, + source: "provider-metadata", + canFinishEarly: false + }); + }); + + it("accepts provider metadata mismatches after a clean stream end", () => { + expect(validateDownloadedFileCompletion({ + actualBytes: 256 * 1024, + plan: { + expectedTotal: 192 * 1024, + source: "provider-metadata", + canFinishEarly: false + } + })).toEqual({ + ok: true, + totalBytes: 256 * 1024, + acceptedMetadataMismatch: true + }); + }); +}); + +describe("auto rename base selection", () => { + it("ignores raw episode-suffix folders like 4sf-amilllt...-s03e10 as scene targets", () => { + expect(buildAutoRenameBaseNameFromFoldersWithOptions( + [ + "4sf-amilllt.de.dl.web.7p-s03e10", + "A.Million.Little.Things.S03.GERMAN.DL.720p.WEB.H264-RWP", + "amilllt.de.dl.web.7p-s03e10" + ], + "A.Million.Little.Things.S03E10.Vertraue.mir.GERMAN.DL.720p.WEB.H264-4SF", + { forceEpisodeForSeasonFolder: true } + )).toBe("A.Million.Little.Things.S03E10.GERMAN.DL.720p.WEB.H264-RWP"); + }); + + it("ignores compact archive folder stems like scn-alco7-S03E18 as scene targets", () => { + expect(buildAutoRenameBaseNameFromFoldersWithOptions( + [ + "scn-alco7-S03E18", + "Alex.und.Co.S03.GERMAN.DL.720p.WEB.H264-SunDry" + ], + "alex.und.co.s03e18.720p.web.h264-sundry", + { forceEpisodeForSeasonFolder: true } + )).toBe("Alex.und.Co.S03E18.GERMAN.DL.720p.WEB.H264-SunDry"); + }); + + it("uses the episode's own scene group when a season folder mixes providers", () => { + expect(buildAutoRenameBaseNameFromFoldersWithOptions( + [ + "amilllt.web.de.7p-101", + "A.Million.Little.Things.S01.GERMAN.DUBBED.720p.WEB.h264-idTV\u2044GDR" + ], + "A.Million.Little.Things.S01E01.GERMAN.DUBBED.720p.WEB.h264-idTV_iNT", + { forceEpisodeForSeasonFolder: true } + )).toBe("A.Million.Little.Things.S01E01.GERMAN.DUBBED.720p.WEB.h264-idTV_iNT"); + }); + + it("keeps per-episode GDR suffixes instead of inheriting a mixed package suffix", () => { + expect(buildAutoRenameBaseNameFromFoldersWithOptions( + [ + "amilllt.web.de.7p-110", + "A.Million.Little.Things.S01.GERMAN.DUBBED.720p.WEB.h264-idTV\u2044GDR" + ], + "A.Million.Little.Things.S01E10.German.DL.Dubbed.720p.WEB.h264-GDR", + { forceEpisodeForSeasonFolder: true } + )).toBe("A.Million.Little.Things.S01E10.GERMAN.DUBBED.720p.WEB.h264-GDR"); + }); +}); + +async function waitFor(predicate: () => boolean, timeoutMs = 15000): Promise { + const started = Date.now(); + while (!predicate()) { + if (Date.now() - started > timeoutMs) { + throw new Error("waitFor timeout"); + } + await new Promise((resolve) => setTimeout(resolve, 60)); + } +} + +async function removeDirWithRetries(dir: string): Promise { + let lastError: unknown = null; + for (let attempt = 1; attempt <= 5; attempt += 1) { + try { + fs.rmSync(dir, { recursive: true, force: true }); + return; + } catch (error) { + lastError = error; + await new Promise((resolve) => setTimeout(resolve, attempt * 80)); + } + } + if (lastError) { + throw lastError; + } +} + +afterEach(async () => { + globalThis.fetch = originalFetch; + resetDebridLinkRuntimeStateForTests(); + resetMegaDebridRuntimeStateForTests(); + shutdownItemLogs(); + shutdownPackageLogs(); + shutdownRenameLog(); + for (const dir of tempDirs.splice(0)) { + await removeDirWithRetries(dir); + } +}); + +describe("download manager", () => { + it("clears the BestDebrid circuit breaker when its token changes", () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-best-token-")); + tempDirs.push(root); + const stateDir = path.join(root, "state"); + fs.mkdirSync(stateDir, { recursive: true }); + const settings = { ...defaultSettings(), bestToken: "old-token" }; + const manager = new DownloadManager(settings, emptySession(), createStoragePaths(stateDir)); + const failures = (manager as any).providerFailures as Map; + failures.set("bestdebrid", { count: 3, lastFailAt: 1, cooldownUntil: Date.now() + 60_000 }); + failures.set("bestdebrid:rapidgator.net", { count: 3, lastFailAt: 1, cooldownUntil: Date.now() + 60_000 }); + failures.set("realdebrid", { count: 3, lastFailAt: 1, cooldownUntil: Date.now() + 60_000 }); + + manager.setSettings({ ...settings, bestToken: "new-token" }); + + expect(failures.has("bestdebrid")).toBe(false); + expect(failures.has("bestdebrid:rapidgator.net")).toBe(false); + expect(failures.has("realdebrid")).toBe(true); + }); + + it("records history duration from the first actual package start", () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-history-")); + tempDirs.push(root); + const historyEntries: Array<{ durationSeconds: number; downloadedBytes: number }> = []; + const manager = new DownloadManager( + defaultSettings(), + emptySession(), + createStoragePaths(path.join(root, "state")), + { + onHistoryEntry: (entry) => { + historyEntries.push({ + durationSeconds: entry.durationSeconds, + downloadedBytes: entry.downloadedBytes + }); + } + } + ); + + const packageId = "history-pkg"; + const itemId = "history-item"; + const pkg = { + id: packageId, + name: "History Test", + outputDir: path.join(root, "downloads", "History Test"), + extractDir: path.join(root, "extract", "History Test"), + status: "completed", + itemIds: [itemId], + cancelled: false, + enabled: true, + priority: "normal", + createdAt: 1_000, + updatedAt: 61_000, + downloadStartedAt: 15_000, + downloadCompletedAt: 60_000 + }; + const item = { + id: itemId, + packageId, + url: "https://example.com/history.rar", + provider: "realdebrid", + status: "completed", + retries: 0, + speedBps: 0, + downloadedBytes: 90 * 1024 * 1024, + totalBytes: 90 * 1024 * 1024, + progressPercent: 100, + fileName: "history.rar", + targetPath: path.join(pkg.outputDir, "history.rar"), + resumable: true, + attempts: 1, + lastError: "", + fullStatus: "Fertig", + createdAt: 15_000, + updatedAt: 60_000 + }; + + (manager as any).recordPackageHistory(packageId, pkg, [item]); + + expect(historyEntries).toHaveLength(1); + expect(historyEntries[0]?.durationSeconds).toBe(45); + expect(historyEntries[0]?.downloadedBytes).toBe(90 * 1024 * 1024); + }); + + it("findFallbackProviderNotInCooldown routes around cooled providers and returns null only when all are cooled", () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-fallback-route-")); + tempDirs.push(root); + const manager = new DownloadManager( + { + ...defaultSettings(), + token: "rd-token", + debridLinkApiKeys: "dl-token", + providerOrder: ["realdebrid", "debridlink"], + providerPrimary: "realdebrid", + providerSecondary: "debridlink", + providerTertiary: "none", + autoProviderFallback: true + }, + emptySession(), + createStoragePaths(path.join(root, "state")) + ); + const item = { id: "fb-item", url: "https://hoster.example/file.bin", provider: "realdebrid" } as any; + + expect((manager as any).findFallbackProviderNotInCooldown(item)).toBe("realdebrid"); + + (manager as any).applyProviderBusyBackoff("realdebrid", 60000); + expect((manager as any).findFallbackProviderNotInCooldown(item)).toBe("debridlink"); + + (manager as any).applyProviderBusyBackoff("debridlink", 60000); + expect((manager as any).findFallbackProviderNotInCooldown(item)).toBeNull(); + }); + + it("fails an item terminally instead of looping forever when the shelve fires under a finite retryLimit", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-shelve-loop-")); + tempDirs.push(root); + const storagePaths = createStoragePaths(path.join(root, "state")); + initPackageLogs(storagePaths.baseDir); + initItemLogs(storagePaths.baseDir); + + const session = emptySession(); + const packageId = "shelve-pkg"; + const itemId = "shelve-item"; + const outputDir = path.join(root, "downloads", "Shelve"); + const extractDir = path.join(root, "extract", "Shelve"); + fs.mkdirSync(outputDir, { recursive: true }); + fs.mkdirSync(extractDir, { recursive: true }); + session.packageOrder = [packageId]; + session.packages[packageId] = { + id: packageId, name: "Shelve", outputDir, extractDir, + status: "downloading", itemIds: [itemId], cancelled: false, enabled: true, + createdAt: Date.now(), updatedAt: Date.now() + } as any; + session.items[itemId] = { + id: itemId, packageId, url: "https://hoster.example/flaky.bin", provider: "realdebrid", + status: "downloading", retries: 5, speedBps: 0, downloadedBytes: 0, totalBytes: null, + progressPercent: 0, fileName: "flaky.bin", targetPath: "", resumable: true, + attempts: 0, lastError: "", fullStatus: "", createdAt: Date.now(), updatedAt: Date.now() + } as any; + + const manager = new DownloadManager( + { ...defaultSettings(), token: "rd-token", retryLimit: 5, outputDir: path.join(root, "downloads"), extractDir: path.join(root, "extract") }, + session, + storagePaths + ); + (manager as any).retryStateByItem.set(itemId, { freshRetryUsed: true, resumeHardResetUsed: true, stallRetries: 0, genericErrorRetries: 15, unrestrictRetries: 0 }); + (manager as any).debridService.unrestrictLink = async () => { throw new Error("Download-Backend Fehler xyz"); }; + + const active = { itemId, packageId, abortController: new AbortController(), abortReason: "none", resumable: true, nonResumableCounted: false, blockedOnDiskWrite: false, blockedOnDiskSince: 0 }; + (manager as any).activeTasks.set(itemId, active); + + await (manager as any).processItem(active); + + expect(session.items[itemId].status).toBe("failed"); + expect(session.items[itemId].retries).toBeLessThan(20); + }); + + it("prefers the short Mega cooldown over the until-reset park when an aggregate error carries both tokens", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-mega-both-")); + tempDirs.push(root); + const storagePaths = createStoragePaths(path.join(root, "state")); + initPackageLogs(storagePaths.baseDir); + initItemLogs(storagePaths.baseDir); + + const session = emptySession(); + const packageId = "mega-both-pkg"; + const itemId = "mega-both-item"; + const outputDir = path.join(root, "downloads", "MegaBoth"); + const extractDir = path.join(root, "extract", "MegaBoth"); + fs.mkdirSync(outputDir, { recursive: true }); + fs.mkdirSync(extractDir, { recursive: true }); + session.packageOrder = [packageId]; + session.packages[packageId] = { + id: packageId, name: "MegaBoth", outputDir, extractDir, + status: "downloading", itemIds: [itemId], cancelled: false, enabled: true, + createdAt: Date.now(), updatedAt: Date.now() + } as any; + session.items[itemId] = { + id: itemId, packageId, url: "https://rapidgator.net/file/mega-both.rar", provider: "megadebrid-api", + status: "downloading", retries: 0, speedBps: 0, downloadedBytes: 0, totalBytes: null, + progressPercent: 0, fileName: "mega-both.rar", targetPath: "", resumable: true, + attempts: 0, lastError: "", fullStatus: "", createdAt: Date.now(), updatedAt: Date.now() + } as any; + + const manager = new DownloadManager( + { ...defaultSettings(), megaLogin: "u", megaPassword: "p", megaCredentials: "u:p", megaDebridApiEnabled: true, megaDebridWebEnabled: true, outputDir: path.join(root, "downloads"), extractDir: path.join(root, "extract") }, + session, + storagePaths + ); + (manager as any).retryStateByItem.set(itemId, { freshRetryUsed: true, resumeHardResetUsed: true, stallRetries: 0, genericErrorRetries: 0, unrestrictRetries: 0 }); + (manager as any).debridService.unrestrictLink = async () => { + throw new Error("Unrestrict fehlgeschlagen: Mega-Debrid API: mega_debrid_reset_park:86400000:Alle Accounts bis zum Tagesreset gesperrt | Mega-Debrid Web: mega_debrid_cooldown:30000:rate limit"); + }; + + const active = { itemId, packageId, abortController: new AbortController(), abortReason: "none", resumable: true, nonResumableCounted: false, blockedOnDiskWrite: false, blockedOnDiskSince: 0 }; + (manager as any).activeTasks.set(itemId, active); + + const before = Date.now(); + await (manager as any).processItem(active); + + const retryAfter = (manager as any).retryAfterByItem.get(itemId) as number; + expect(retryAfter).toBeGreaterThan(0); + const delayMs = retryAfter - before; + expect(delayMs).toBeLessThan(60000); + expect(session.items[itemId].fullStatus).toMatch(/Cooldown/i); + expect(session.items[itemId].fullStatus).not.toMatch(/Tagesreset/i); + }); + + it("keeps the quick post-process requeue once the final package items are finished", () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-postprocess-final-")); + tempDirs.push(root); + + const session = emptySession(); + const packageId = "postprocess-final-pkg"; + const firstItemId = "postprocess-final-item-1"; + const secondItemId = "postprocess-final-item-2"; + const createdAt = Date.now() - 20_000; + const packageOutputDir = path.join(root, "downloads", "PostProcess Final Round"); + const packageExtractDir = path.join(root, "extract", "PostProcess Final Round"); + fs.mkdirSync(packageOutputDir, { recursive: true }); + fs.mkdirSync(packageExtractDir, { recursive: true }); + fs.writeFileSync(path.join(packageOutputDir, "final-1.rar"), Buffer.alloc(100, 1)); + + session.packageOrder = [packageId]; + session.packages[packageId] = { + id: packageId, + name: "PostProcess Final Round", + outputDir: packageOutputDir, + extractDir: packageExtractDir, + status: "downloading", + itemIds: [firstItemId, secondItemId], + cancelled: false, + enabled: true, + createdAt, + updatedAt: createdAt + }; + session.items[firstItemId] = { + id: firstItemId, + packageId, + url: "https://example.com/final-1.rar", + provider: "realdebrid", + status: "completed", + retries: 0, + speedBps: 0, + downloadedBytes: 100, + totalBytes: 100, + progressPercent: 100, + fileName: "final-1.rar", + targetPath: path.join(packageOutputDir, "final-1.rar"), + resumable: true, + attempts: 1, + lastError: "", + fullStatus: "Entpackt - Done (<1s)", + createdAt, + updatedAt: createdAt + }; + session.items[secondItemId] = { + id: secondItemId, + packageId, + url: "https://example.com/final-2.rar", + provider: "realdebrid", + status: "downloading", + retries: 0, + speedBps: 0, + downloadedBytes: 90, + totalBytes: 100, + progressPercent: 90, + fileName: "final-2.rar", + targetPath: path.join(packageOutputDir, "final-2.rar"), + resumable: true, + attempts: 1, + lastError: "", + fullStatus: "Download läuft", + createdAt, + updatedAt: createdAt + }; + + const manager = new DownloadManager( + { + ...defaultSettings(), + token: "rd-token", + outputDir: path.join(root, "downloads"), + extractDir: path.join(root, "extract"), + autoExtract: true, + hybridExtract: true + }, + session, + createStoragePaths(path.join(root, "state")) + ); + + expect((manager as any).shouldCollapseQuickPostProcessRequeue(packageId)).toBe(true); + + const item = (manager as any).session.items[secondItemId]; + item.status = "completed"; + item.downloadedBytes = item.totalBytes; + item.progressPercent = 100; + item.fullStatus = "Entpacken - Ausstehend"; + item.updatedAt = Date.now(); + + expect((manager as any).session.items[firstItemId].status).toBe("completed"); + expect((manager as any).session.items[secondItemId].status).toBe("completed"); + expect((manager as any).session.packages[packageId].itemIds).toEqual([firstItemId, secondItemId]); + + expect((manager as any).shouldCollapseQuickPostProcessRequeue(packageId)).toBe(false); + }); + + it("honors maxParallelExtract for concurrent post-process slots", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-postprocess-slots-")); + tempDirs.push(root); + + const manager = new DownloadManager( + { + ...defaultSettings(), + token: "rd-token", + maxParallelExtract: 4 + }, + emptySession(), + createStoragePaths(path.join(root, "state")) + ); + + await (manager as any).acquirePostProcessSlot("pkg-1"); + await (manager as any).acquirePostProcessSlot("pkg-2"); + await (manager as any).acquirePostProcessSlot("pkg-3"); + await (manager as any).acquirePostProcessSlot("pkg-4"); + + expect((manager as any).packagePostProcessActive).toBe(4); + + let fifthResolved = false; + const fifth = (manager as any).acquirePostProcessSlot("pkg-5").then(() => { + fifthResolved = true; + }); + + await new Promise((resolve) => setTimeout(resolve, 30)); + expect(fifthResolved).toBe(false); + + (manager as any).releasePostProcessSlot(); + await fifth; + + expect(fifthResolved).toBe(true); + expect((manager as any).packagePostProcessActive).toBe(4); + }); + + it("extractNow only re-arms completed items that are not already extracted", () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-extract-now-")); + tempDirs.push(root); + + const session = emptySession(); + const packageId = "extract-now-pkg"; + const createdAt = Date.now() - 20_000; + const outputDir = path.join(root, "downloads", "Extract Now Test"); + const extractDir = path.join(root, "extract", "Extract Now Test"); + fs.mkdirSync(outputDir, { recursive: true }); + fs.mkdirSync(extractDir, { recursive: true }); + + const specs = [ + { id: "extract-now-item-1", fileName: "show.e01.rar", fullStatus: "Entpackt - Done (<1s)" }, + { id: "extract-now-item-2", fileName: "show.e02.rar", fullStatus: "Entpackt - Done (1.2s)" }, + { id: "extract-now-item-3", fileName: "show.e03.rar", fullStatus: "Entpacken - Ausstehend" } + ] as const; + + session.packageOrder = [packageId]; + session.packages[packageId] = { + id: packageId, + name: "Extract Now Test", + outputDir, + extractDir, + status: "completed", + itemIds: specs.map((spec) => spec.id), + cancelled: false, + enabled: true, + createdAt, + updatedAt: createdAt + }; + + for (const spec of specs) { + const targetPath = path.join(outputDir, spec.fileName); + fs.writeFileSync(targetPath, Buffer.alloc(128, 1)); + session.items[spec.id] = { + id: spec.id, + packageId, + url: `https://example.com/${spec.fileName}`, + provider: "realdebrid", + status: "completed", + retries: 0, + speedBps: 0, + downloadedBytes: 128, + totalBytes: 128, + progressPercent: 100, + fileName: spec.fileName, + targetPath, + resumable: true, + attempts: 1, + lastError: "", + fullStatus: spec.fullStatus, + createdAt, + updatedAt: createdAt + }; + } + + const manager = new DownloadManager( + { + ...defaultSettings(), + token: "rd-token", + outputDir: path.join(root, "downloads"), + extractDir: path.join(root, "extract"), + autoExtract: true, + hybridExtract: true + }, + session, + createStoragePaths(path.join(root, "state")) + ); + + manager.extractNow(packageId); + + expect((manager as any).session.items["extract-now-item-1"].fullStatus).toBe("Entpackt - Done (<1s)"); + expect((manager as any).session.items["extract-now-item-2"].fullStatus).toBe("Entpackt - Done (1.2s)"); + expect((manager as any).session.items["extract-now-item-3"].fullStatus).toBe("Entpacken - Ausstehend"); + expect((manager as any).session.packages[packageId].status).toBe("queued"); + }); + + it("merges duplicate-suffixed completed startup items back into the canonical queued item", () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-startup-dup-merge-")); + tempDirs.push(root); + + const session = emptySession(); + const packageId = "startup-dup-pkg"; + const originalItemId = "startup-dup-original"; + const duplicateItemId = "startup-dup-copy"; + const createdAt = Date.now() - 20_000; + const outputDir = path.join(root, "downloads", "Startup Duplicate Merge"); + const extractDir = path.join(root, "extract", "Startup Duplicate Merge"); + fs.mkdirSync(outputDir, { recursive: true }); + fs.mkdirSync(extractDir, { recursive: true }); + + const canonicalPath = path.join(outputDir, "episode.part1.rar"); + const duplicatePath = path.join(outputDir, "episode.part1 (1).rar"); + fs.writeFileSync(duplicatePath, Buffer.alloc(128, 7)); + + session.packageOrder = [packageId]; + session.packages[packageId] = { + id: packageId, + name: "Startup Duplicate Merge", + outputDir, + extractDir, + status: "failed", + itemIds: [originalItemId, duplicateItemId], + cancelled: false, + enabled: true, + priority: "normal", + createdAt, + updatedAt: createdAt + }; + session.items[originalItemId] = { + id: originalItemId, + packageId, + url: "https://example.com/episode.part1.rar", + provider: "realdebrid", + status: "queued", + retries: 0, + speedBps: 0, + downloadedBytes: 0, + totalBytes: 128, + progressPercent: 0, + fileName: "episode.part1.rar", + targetPath: canonicalPath, + resumable: true, + attempts: 0, + lastError: "", + fullStatus: "Wartet", + createdAt, + updatedAt: createdAt + }; + session.items[duplicateItemId] = { + id: duplicateItemId, + packageId, + url: "https://example.com/episode.part1.rar", + provider: "realdebrid", + status: "completed", + retries: 0, + speedBps: 0, + downloadedBytes: 128, + totalBytes: 128, + progressPercent: 100, + fileName: "episode.part1.rar", + targetPath: duplicatePath, + resumable: true, + attempts: 1, + lastError: "", + fullStatus: "Fertig (128 B)", + createdAt, + updatedAt: createdAt + 5_000 + }; + + const manager = new DownloadManager( + { + ...defaultSettings(), + token: "rd-token", + outputDir: path.join(root, "downloads"), + extractDir: path.join(root, "extract"), + autoExtract: false + }, + session, + createStoragePaths(path.join(root, "state")) + ); + + const current = (manager as any).session; + expect(current.packages[packageId].itemIds).toEqual([originalItemId]); + expect(current.items[duplicateItemId]).toBeUndefined(); + expect(current.items[originalItemId].status).toBe("completed"); + expect(current.items[originalItemId].fullStatus).toBe("Fertig (128 B)"); + expect(current.items[originalItemId].targetPath).toBe(canonicalPath); + expect(fs.existsSync(canonicalPath)).toBe(true); + expect(fs.existsSync(duplicatePath)).toBe(false); + }); + + it("does not replace a larger good canonical file with a smaller duplicate on startup dedup", () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-startup-dup-size-")); + tempDirs.push(root); + + const session = emptySession(); + const packageId = "dup-size-pkg"; + const originalItemId = "dup-size-original"; + const duplicateItemId = "dup-size-copy"; + const createdAt = Date.now() - 20_000; + const outputDir = path.join(root, "downloads", "Dup Size"); + const extractDir = path.join(root, "extract", "Dup Size"); + fs.mkdirSync(outputDir, { recursive: true }); + fs.mkdirSync(extractDir, { recursive: true }); + + const canonicalPath = path.join(outputDir, "movie.mkv"); + const duplicatePath = path.join(outputDir, "movie (1).mkv"); + fs.writeFileSync(canonicalPath, Buffer.alloc(1024, 5)); + fs.writeFileSync(duplicatePath, Buffer.alloc(256, 9)); + + session.packageOrder = [packageId]; + session.packages[packageId] = { + id: packageId, + name: "Dup Size", + outputDir, + extractDir, + status: "completed", + itemIds: [originalItemId, duplicateItemId], + cancelled: false, + enabled: true, + priority: "normal", + createdAt, + updatedAt: createdAt + }; + session.items[originalItemId] = { + id: originalItemId, + packageId, + url: "https://example.com/movie.mkv", + provider: "realdebrid", + status: "failed", + retries: 0, + speedBps: 0, + downloadedBytes: 0, + totalBytes: 1024, + progressPercent: 0, + fileName: "movie.mkv", + targetPath: canonicalPath, + resumable: true, + attempts: 1, + lastError: "Fehlgeschlagen", + fullStatus: "", + createdAt, + updatedAt: createdAt + 10_000 + }; + session.items[duplicateItemId] = { + id: duplicateItemId, + packageId, + url: "https://example.com/movie.mkv", + provider: "realdebrid", + status: "completed", + retries: 0, + speedBps: 0, + downloadedBytes: 256, + totalBytes: 256, + progressPercent: 100, + fileName: "movie.mkv", + targetPath: duplicatePath, + resumable: true, + attempts: 1, + lastError: "", + fullStatus: "Fertig", + createdAt, + updatedAt: createdAt + 5_000 + }; + + new DownloadManager( + { + ...defaultSettings(), + token: "rd-token", + outputDir: path.join(root, "downloads"), + extractDir: path.join(root, "extract"), + autoExtract: false + }, + session, + createStoragePaths(path.join(root, "state")) + ); + + expect(fs.existsSync(canonicalPath)).toBe(true); + expect(fs.statSync(canonicalPath).size).toBe(1024); + }); + + it("keeps a stronger extracted canonical startup state when removing stale duplicate copies", () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-startup-dup-keep-")); + tempDirs.push(root); + + const session = emptySession(); + const packageId = "startup-dup-keep-pkg"; + const originalItemId = "startup-dup-keep-original"; + const duplicateItemId = "startup-dup-keep-copy"; + const createdAt = Date.now() - 20_000; + const outputDir = path.join(root, "downloads", "Startup Duplicate Keep"); + const extractDir = path.join(root, "extract", "Startup Duplicate Keep"); + fs.mkdirSync(outputDir, { recursive: true }); + fs.mkdirSync(extractDir, { recursive: true }); + + const canonicalPath = path.join(outputDir, "episode.part1.rar"); + const duplicatePath = path.join(outputDir, "episode.part1 (1).rar"); + fs.writeFileSync(duplicatePath, Buffer.alloc(256, 9)); + + session.packageOrder = [packageId]; + session.packages[packageId] = { + id: packageId, + name: "Startup Duplicate Keep", + outputDir, + extractDir, + status: "completed", + itemIds: [originalItemId, duplicateItemId], + cancelled: false, + enabled: true, + priority: "normal", + createdAt, + updatedAt: createdAt + }; + session.items[originalItemId] = { + id: originalItemId, + packageId, + url: "https://example.com/episode.part1.rar", + provider: "realdebrid", + status: "completed", + retries: 0, + speedBps: 0, + downloadedBytes: 256, + totalBytes: 256, + progressPercent: 100, + fileName: "episode.part1.rar", + targetPath: canonicalPath, + resumable: true, + attempts: 1, + lastError: "", + fullStatus: "Entpackt - Done (1.0s)", + createdAt, + updatedAt: createdAt + 10_000 + }; + session.items[duplicateItemId] = { + id: duplicateItemId, + packageId, + url: "https://example.com/episode.part1.rar", + provider: "realdebrid", + status: "completed", + retries: 0, + speedBps: 0, + downloadedBytes: 256, + totalBytes: 256, + progressPercent: 100, + fileName: "episode.part1.rar", + targetPath: duplicatePath, + resumable: true, + attempts: 1, + lastError: "Checksum error", + fullStatus: "Entpack-Fehler: Checksum error", + createdAt, + updatedAt: createdAt + 5_000 + }; + + const manager = new DownloadManager( + { + ...defaultSettings(), + token: "rd-token", + outputDir: path.join(root, "downloads"), + extractDir: path.join(root, "extract"), + autoExtract: false + }, + session, + createStoragePaths(path.join(root, "state")) + ); + + const current = (manager as any).session; + expect(current.packages[packageId].itemIds).toEqual([originalItemId]); + expect(current.items[duplicateItemId]).toBeUndefined(); + expect(current.items[originalItemId].status).toBe("completed"); + expect(current.items[originalItemId].fullStatus).toBe("Entpackt - Done (1.0s)"); + expect(current.items[originalItemId].targetPath).toBe(canonicalPath); + expect(fs.existsSync(canonicalPath)).toBe(true); + expect(fs.existsSync(duplicatePath)).toBe(false); + }); + + function createCompletedArchiveSession(root: string, packageName: string, extractedFileName: string): { + session: ReturnType; + packageId: string; + itemId: string; + outputDir: string; + extractDir: string; + originalExtractedPath: string; + } { + const outputDir = path.join(root, "downloads", packageName); + const extractDir = path.join(root, "extract", packageName); + fs.mkdirSync(outputDir, { recursive: true }); + + const zip = new AdmZip(); + zip.addFile(extractedFileName, Buffer.from("video")); + const archivePath = path.join(outputDir, "episode.zip"); + zip.writeZip(archivePath); + const archiveSize = fs.statSync(archivePath).size; + + const session = emptySession(); + const packageId = `${packageName}-pkg`; + const itemId = `${packageName}-item`; + const createdAt = Date.now() - 20_000; + session.packageOrder = [packageId]; + session.packages[packageId] = { + id: packageId, + name: packageName, + outputDir, + extractDir, + status: "downloading", + itemIds: [itemId], + cancelled: false, + enabled: true, + createdAt, + updatedAt: createdAt + }; + session.items[itemId] = { + id: itemId, + packageId, + url: `https://dummy/${packageName}`, + provider: "realdebrid", + status: "completed", + retries: 0, + speedBps: 0, + downloadedBytes: archiveSize, + totalBytes: archiveSize, + progressPercent: 100, + fileName: "episode.zip", + targetPath: archivePath, + resumable: true, + attempts: 1, + lastError: "", + fullStatus: "Fertig (100 MB)", + createdAt, + updatedAt: createdAt + }; + + return { + session, + packageId, + itemId, + outputDir, + extractDir, + originalExtractedPath: path.join(extractDir, extractedFileName) + }; + } + + function createCompletedArchiveSessionFromArchive( + root: string, + packageName: string, + archiveEntries: Array<{ name: string; data: Buffer | string }> + ): { + session: ReturnType; + packageId: string; + itemId: string; + outputDir: string; + extractDir: string; + archivePath: string; + } { + const outputDir = path.join(root, "downloads", packageName); + const extractDir = path.join(root, "extract", packageName); + fs.mkdirSync(outputDir, { recursive: true }); + + const zip = new AdmZip(); + for (const entry of archiveEntries) { + zip.addFile(entry.name, typeof entry.data === "string" ? Buffer.from(entry.data) : entry.data); + } + const archivePath = path.join(outputDir, "episode.zip"); + zip.writeZip(archivePath); + const archiveSize = fs.statSync(archivePath).size; + + const session = emptySession(); + const packageId = `${packageName}-pkg`; + const itemId = `${packageName}-item`; + const createdAt = Date.now() - 20_000; + session.packageOrder = [packageId]; + session.packages[packageId] = { + id: packageId, + name: packageName, + outputDir, + extractDir, + status: "downloading", + itemIds: [itemId], + cancelled: false, + enabled: true, + createdAt, + updatedAt: createdAt + }; + session.items[itemId] = { + id: itemId, + packageId, + url: `https://dummy/${packageName}`, + provider: "realdebrid", + status: "completed", + retries: 0, + speedBps: 0, + downloadedBytes: archiveSize, + totalBytes: archiveSize, + progressPercent: 100, + fileName: "episode.zip", + targetPath: archivePath, + resumable: true, + attempts: 1, + lastError: "", + fullStatus: "Fertig (100 MB)", + createdAt, + updatedAt: createdAt + }; + + return { + session, + packageId, + itemId, + outputDir, + extractDir, + archivePath + }; + } + + it("retries interrupted streams and resumes download", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-")); + tempDirs.push(root); + const binary = Buffer.alloc(512 * 1024, 11); + let directCalls = 0; + + const server = http.createServer((req, res) => { + if ((req.url || "") !== "/direct") { + res.statusCode = 404; + res.end("not-found"); + return; + } + + directCalls += 1; + const range = String(req.headers.range || ""); + const match = range.match(/bytes=(\d+)-/i); + const start = match ? Number(match[1]) : 0; + + if (directCalls === 1 && start === 0) { + res.statusCode = 200; + res.setHeader("Accept-Ranges", "bytes"); + res.setHeader("Content-Length", String(binary.length)); + res.write(binary.subarray(0, Math.floor(binary.length / 2))); + res.socket?.destroy(); + return; + } + + const chunk = binary.subarray(start); + if (start > 0) { + res.statusCode = 206; + res.setHeader("Content-Range", `bytes ${start}-${binary.length - 1}/${binary.length}`); + } else { + res.statusCode = 200; + } + res.setHeader("Accept-Ranges", "bytes"); + res.setHeader("Content-Length", String(chunk.length)); + res.end(chunk); + }); + + server.listen(0, "127.0.0.1"); + await once(server, "listening"); + + const address = server.address(); + if (!address || typeof address === "string") { + throw new Error("server address unavailable"); + } + const directUrl = `http://127.0.0.1:${address.port}/direct`; + + globalThis.fetch = async (input: RequestInfo | URL, init?: RequestInit): Promise => { + const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url; + if (url.includes("/unrestrict/link")) { + return new Response( + JSON.stringify({ + download: directUrl, + filename: "episode.mkv", + filesize: binary.length + }), + { + status: 200, + headers: { "Content-Type": "application/json" } + } + ); + } + return originalFetch(input, init); + }; + + try { + const manager = new DownloadManager( + { + ...defaultSettings(), + token: "rd-token", + outputDir: path.join(root, "downloads"), + extractDir: path.join(root, "extract"), + autoExtract: false, + autoReconnect: false + }, + emptySession(), + createStoragePaths(path.join(root, "state")) + ); + + manager.addPackages([{ name: "retry", links: ["https://dummy/retry"] }]); + await manager.start(); + await waitFor(() => !manager.getSnapshot().session.running, 25000); + + const item = Object.values(manager.getSnapshot().session.items)[0]; + expect(item?.status).toBe("completed"); + expect(item?.retries).toBeGreaterThan(0); + expect(directCalls).toBeGreaterThan(1); + expect(fs.existsSync(item.targetPath)).toBe(true); + expect(fs.statSync(item.targetPath).size).toBe(binary.length); + } finally { + server.close(); + await once(server, "close"); + } + }); + + it("deletes the orphaned partial file when a downloading item is removed mid-stream", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-")); + tempDirs.push(root); + const binary = Buffer.alloc(512 * 1024, 7); + + let destroyHeld: () => void = () => {}; + const server = http.createServer((req, res) => { + if ((req.url || "") !== "/direct") { + res.statusCode = 404; + res.end("not-found"); + return; + } + res.statusCode = 200; + res.setHeader("Accept-Ranges", "bytes"); + res.setHeader("Content-Length", String(binary.length)); + res.write(binary.subarray(0, 64 * 1024)); + destroyHeld = () => { + try { res.socket?.destroy(); } catch { } + }; + }); + + server.listen(0, "127.0.0.1"); + await once(server, "listening"); + const address = server.address(); + if (!address || typeof address === "string") { + throw new Error("server address unavailable"); + } + const directUrl = `http://127.0.0.1:${address.port}/direct`; + + globalThis.fetch = async (input: RequestInfo | URL, init?: RequestInit): Promise => { + const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url; + if (url.includes("/unrestrict/link")) { + return new Response( + JSON.stringify({ download: directUrl, filename: "held.mkv", filesize: binary.length }), + { status: 200, headers: { "Content-Type": "application/json" } } + ); + } + return originalFetch(input, init); + }; + + try { + const manager = new DownloadManager( + { + ...defaultSettings(), + token: "rd-token", + outputDir: path.join(root, "downloads"), + extractDir: path.join(root, "extract"), + autoExtract: false, + autoReconnect: false, + retryLimit: 0 + }, + emptySession(), + createStoragePaths(path.join(root, "state")) + ); + + manager.addPackages([{ name: "held", links: ["https://dummy/held"] }]); + await manager.start(); + + let targetPath = ""; + await waitFor(() => { + const it = Object.values(manager.getSnapshot().session.items)[0]; + if (it && it.status === "downloading" && it.targetPath && fs.existsSync(it.targetPath)) { + targetPath = it.targetPath; + return true; + } + return false; + }, 20000); + + const itemId = Object.values(manager.getSnapshot().session.items)[0].id; + expect(fs.existsSync(targetPath)).toBe(true); + + manager.removeItem(itemId); + destroyHeld(); + + await waitFor(() => !fs.existsSync(targetPath), 20000); + expect(fs.existsSync(targetPath)).toBe(false); + } finally { + destroyHeld(); + server.close(); + await once(server, "close"); + } + }); + + it("rewinds resumed range after terminated streams so corrupted tail bytes are replaced", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-")); + tempDirs.push(root); + const binary = Buffer.alloc(3 * 1024 * 1024, 41); + const injectedErrorChunk = Buffer.from( + "{\"error\":\"Missed session \\\"resume-tail\\\" after 2000 ms\",\"success\":false}", + "utf8" + ); + const firstChunkBytes = 2 * 1024 * 1024; + const corruptedResumeStart = firstChunkBytes + injectedErrorChunk.length; + const starts: number[] = []; + let directCalls = 0; + + const server = http.createServer((req, res) => { + if ((req.url || "") !== "/resume-rewind") { + res.statusCode = 404; + res.end("not-found"); + return; + } + + directCalls += 1; + const range = String(req.headers.range || ""); + const match = range.match(/bytes=(\d+)-/i); + const start = match ? Number(match[1]) : 0; + starts.push(start); + + if (directCalls === 1 && start === 0) { + res.statusCode = 200; + res.setHeader("Accept-Ranges", "bytes"); + res.setHeader("Content-Length", String(binary.length)); + res.write(binary.subarray(0, firstChunkBytes)); + res.write(injectedErrorChunk); + setTimeout(() => { + res.socket?.destroy(); + }, 120); + return; + } + + const chunk = binary.subarray(start); + if (start > 0) { + res.statusCode = 206; + res.setHeader("Content-Range", `bytes ${start}-${binary.length - 1}/${binary.length}`); + } else { + res.statusCode = 200; + } + res.setHeader("Accept-Ranges", "bytes"); + res.setHeader("Content-Length", String(chunk.length)); + res.end(chunk); + }); + + server.listen(0, "127.0.0.1"); + await once(server, "listening"); + + const address = server.address(); + if (!address || typeof address === "string") { + throw new Error("server address unavailable"); + } + const directUrl = `http://127.0.0.1:${address.port}/resume-rewind`; + + globalThis.fetch = async (input: RequestInfo | URL, init?: RequestInit): Promise => { + const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url; + if (url.includes("/unrestrict/link")) { + return new Response( + JSON.stringify({ + download: directUrl, + filename: "resume-rewind.mkv", + filesize: binary.length + }), + { + status: 200, + headers: { "Content-Type": "application/json" } + } + ); + } + return originalFetch(input, init); + }; + + try { + const manager = new DownloadManager( + { + ...defaultSettings(), + token: "rd-token", + outputDir: path.join(root, "downloads"), + extractDir: path.join(root, "extract"), + autoExtract: false, + autoReconnect: false + }, + emptySession(), + createStoragePaths(path.join(root, "state")) + ); + + manager.addPackages([{ name: "resume-rewind", links: ["https://dummy/resume-rewind"] }]); + await manager.start(); + await waitFor(() => !manager.getSnapshot().session.running, 25000); + + const item = Object.values(manager.getSnapshot().session.items)[0]; + expect(item?.status).toBe("completed"); + expect(directCalls).toBeGreaterThanOrEqual(2); + expect(starts[0]).toBe(0); + expect(starts[1]).toBeGreaterThan(0); + expect(starts[1]).toBeLessThan(corruptedResumeStart); + expect(fs.readFileSync(item.targetPath).equals(binary)).toBe(true); + } finally { + server.close(); + await once(server, "close"); + } + }); + + it("does not embed a final-attempt garbage tail across a fresh-link resume (known-total content integrity)", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-range1-")); + tempDirs.push(root); + const binary = Buffer.alloc(1024 * 1024, 0); + for (let i = 0; i < binary.length; i += 1) { + binary[i] = (i * 31 + 7) & 0xff; + } + const firstChunkBytes = 128 * 1024; + const injectedErrorChunk = Buffer.from("{\"error\":\"Missed session after 2000 ms\",\"success\":false}", "utf8"); + let unrestrictCalls = 0; + + const serveRange = (req: http.IncomingMessage): { start: number } => { + const range = String(req.headers.range || ""); + const m = range.match(/bytes=(\d+)-/i); + return { start: m ? Number(m[1]) : 0 }; + }; + + const server = http.createServer((req, res) => { + const route = req.url || ""; + const { start } = serveRange(req); + if (route.startsWith("/corrupt")) { + if (start > 0) { + res.statusCode = 206; + res.setHeader("Content-Range", `bytes ${start}-${binary.length - 1}/${binary.length}`); + } else { + res.statusCode = 200; + } + res.setHeader("Accept-Ranges", "bytes"); + res.setHeader("Content-Length", String(binary.length - start)); + res.write(binary.subarray(start, Math.min(binary.length, start + firstChunkBytes))); + res.write(injectedErrorChunk); + setTimeout(() => { + try { res.socket?.destroy(); } catch { } + }, 60); + return; + } + if (route.startsWith("/clean")) { + const chunk = binary.subarray(start); + if (start > 0) { + res.statusCode = 206; + res.setHeader("Content-Range", `bytes ${start}-${binary.length - 1}/${binary.length}`); + } else { + res.statusCode = 200; + } + res.setHeader("Accept-Ranges", "bytes"); + res.setHeader("Content-Length", String(chunk.length)); + res.end(chunk); + return; + } + res.statusCode = 404; + res.end("not-found"); + }); + + server.listen(0, "127.0.0.1"); + await once(server, "listening"); + const address = server.address(); + if (!address || typeof address === "string") { + throw new Error("server address unavailable"); + } + const base = `http://127.0.0.1:${address.port}`; + + globalThis.fetch = async (input: RequestInfo | URL, init?: RequestInit): Promise => { + const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url; + if (url.includes("/unrestrict/link")) { + unrestrictCalls += 1; + const target = unrestrictCalls === 1 ? `${base}/corrupt` : `${base}/clean`; + return new Response( + JSON.stringify({ download: target, filename: "range1.mkv", filesize: binary.length }), + { status: 200, headers: { "Content-Type": "application/json" } } + ); + } + return originalFetch(input, init); + }; + + try { + const manager = new DownloadManager( + { + ...defaultSettings(), + token: "rd-token", + outputDir: path.join(root, "downloads"), + extractDir: path.join(root, "extract"), + autoExtract: false, + autoReconnect: false + }, + emptySession(), + createStoragePaths(path.join(root, "state")) + ); + + manager.addPackages([{ name: "range1", links: ["https://dummy/range1"] }]); + await manager.start(); + await waitFor(() => !manager.getSnapshot().session.running, 30000); + + const item = Object.values(manager.getSnapshot().session.items)[0]; + expect(item?.status).toBe("completed"); + expect(unrestrictCalls).toBeGreaterThanOrEqual(2); + const finalBytes = fs.readFileSync(item.targetPath); + expect(finalBytes.length).toBe(binary.length); + expect(finalBytes.equals(binary)).toBe(true); + } finally { + server.close(); + await once(server, "close"); + } + }); + + it("counts a file once in session and all-time totals across an integrity-fail retry (no telemetry double-count)", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-byteacct-")); + tempDirs.push(root); + const size = 512 * 1024; + const correct = Buffer.alloc(size, 0); + for (let i = 0; i < size; i += 1) { + correct[i] = (i * 17 + 3) & 0xff; + } + const wrong = Buffer.alloc(size, 0); + for (let i = 0; i < size; i += 1) { + wrong[i] = (i * 17 + 99) & 0xff; + } + const md5 = crypto.createHash("md5").update(correct).digest("hex"); + + const pkgDir = path.join(root, "downloads", "byteacct"); + fs.mkdirSync(pkgDir, { recursive: true }); + fs.writeFileSync(path.join(pkgDir, "byteacct.md5"), `${md5} *movie.mkv\n`, "utf8"); + + let fullServes = 0; + const server = http.createServer((req, res) => { + const range = String(req.headers.range || ""); + const m = range.match(/bytes=(\d+)-/i); + const start = m ? Number(m[1]) : 0; + if (start === 0) { + fullServes += 1; + } + const body = fullServes <= 1 ? wrong : correct; + const chunk = body.subarray(start); + if (start > 0) { + res.statusCode = 206; + res.setHeader("Content-Range", `bytes ${start}-${body.length - 1}/${body.length}`); + } else { + res.statusCode = 200; + } + res.setHeader("Accept-Ranges", "bytes"); + res.setHeader("Content-Length", String(chunk.length)); + res.end(chunk); + }); + + server.listen(0, "127.0.0.1"); + await once(server, "listening"); + const address = server.address(); + if (!address || typeof address === "string") { + throw new Error("server address unavailable"); + } + const base = `http://127.0.0.1:${address.port}`; + + globalThis.fetch = async (input: RequestInfo | URL, init?: RequestInit): Promise => { + const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url; + if (url.includes("/unrestrict/link")) { + return new Response( + JSON.stringify({ download: `${base}/file`, filename: "movie.mkv", filesize: size }), + { status: 200, headers: { "Content-Type": "application/json" } } + ); + } + return originalFetch(input, init); + }; + + try { + const manager = new DownloadManager( + { + ...defaultSettings(), + token: "rd-token", + outputDir: path.join(root, "downloads"), + extractDir: path.join(root, "extract"), + autoExtract: false, + autoReconnect: false, + enableIntegrityCheck: true, + totalDownloadedAllTime: 0 + }, + emptySession(), + createStoragePaths(path.join(root, "state")) + ); + + manager.addPackages([{ name: "byteacct", links: ["https://dummy/byteacct"] }]); + await manager.start(); + await waitFor(() => !manager.getSnapshot().session.running, 30000); + + const item = Object.values(manager.getSnapshot().session.items)[0]; + expect(item?.status).toBe("completed"); + expect(fullServes).toBeGreaterThanOrEqual(2); + + const internal = manager as unknown as { + session: { totalDownloadedBytes: number }; + settings: { totalDownloadedAllTime: number }; + }; + expect(internal.session.totalDownloadedBytes).toBe(size); + expect(internal.settings.totalDownloadedAllTime).toBe(size); + } finally { + server.close(); + await once(server, "close"); + } + }, 40000); + + it("requests a fresh direct link after repeated same-link download failures", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-")); + tempDirs.push(root); + const binary = Buffer.alloc(256 * 1024, 17); + let badCalls = 0; + let goodCalls = 0; + let unrestrictCalls = 0; + + const server = http.createServer((req, res) => { + const route = req.url || ""; + if (route === "/bad") { + badCalls += 1; + const range = String(req.headers.range || ""); + const match = range.match(/bytes=(\d+)-/i); + const start = match ? Number(match[1]) : 0; + const end = Math.min(binary.length, start + 64 * 1024); + const chunk = binary.subarray(start, end); + if (start > 0) { + res.statusCode = 206; + res.setHeader("Content-Range", `bytes ${start}-${binary.length - 1}/${binary.length}`); + } else { + res.statusCode = 200; + } + res.setHeader("Accept-Ranges", "bytes"); + res.setHeader("Content-Length", String(chunk.length)); + res.write(chunk); + res.socket?.destroy(); + return; + } + + if (route === "/good") { + goodCalls += 1; + const range = String(req.headers.range || ""); + const match = range.match(/bytes=(\d+)-/i); + const start = match ? Number(match[1]) : 0; + const chunk = binary.subarray(start); + if (start > 0) { + res.statusCode = 206; + res.setHeader("Content-Range", `bytes ${start}-${binary.length - 1}/${binary.length}`); + } else { + res.statusCode = 200; + } + res.setHeader("Accept-Ranges", "bytes"); + res.setHeader("Content-Length", String(chunk.length)); + res.end(chunk); + return; + } + + res.statusCode = 404; + res.end("not-found"); + }); + + server.listen(0, "127.0.0.1"); + await once(server, "listening"); + + const address = server.address(); + if (!address || typeof address === "string") { + throw new Error("server address unavailable"); + } + const badUrl = `http://127.0.0.1:${address.port}/bad`; + const goodUrl = `http://127.0.0.1:${address.port}/good`; + + globalThis.fetch = async (input: RequestInfo | URL, init?: RequestInit): Promise => { + const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url; + if (url.includes("/unrestrict/link")) { + unrestrictCalls += 1; + return new Response( + JSON.stringify({ + download: unrestrictCalls === 1 ? badUrl : goodUrl, + filename: "refresh-link.mkv", + filesize: binary.length + }), + { + status: 200, + headers: { "Content-Type": "application/json" } + } + ); + } + return originalFetch(input, init); + }; + + try { + const manager = new DownloadManager( + { + ...defaultSettings(), + token: "rd-token", + outputDir: path.join(root, "downloads"), + extractDir: path.join(root, "extract"), + autoExtract: false, + autoReconnect: false, + retryLimit: 0 + }, + emptySession(), + createStoragePaths(path.join(root, "state")) + ); + + manager.addPackages([{ name: "fresh-link", links: ["https://dummy/fresh-link"] }]); + await manager.start(); + await waitFor(() => !manager.getSnapshot().session.running, 12000); + + const item = Object.values(manager.getSnapshot().session.items)[0]; + expect(item?.status).toBe("completed"); + expect(item?.downloadedBytes).toBe(binary.length); + expect(unrestrictCalls).toBeGreaterThanOrEqual(2); + expect(badCalls).toBe(3); + expect(goodCalls).toBeGreaterThanOrEqual(1); + expect(fs.existsSync(item.targetPath)).toBe(true); + expect(fs.statSync(item.targetPath).size).toBe(binary.length); + } finally { + server.close(); + await once(server, "close"); + } + }); + + it("preserves partial files and requests a fresh direct link when resume gets HTTP 200", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-")); + tempDirs.push(root); + const binary = Buffer.alloc(256 * 1024, 21); + const pkgDir = path.join(root, "downloads", "resume-ignored"); + fs.mkdirSync(pkgDir, { recursive: true }); + const existingTargetPath = path.join(pkgDir, "resume-ignored.mkv"); + const partialSize = 96 * 1024; + fs.writeFileSync(existingTargetPath, binary.subarray(0, partialSize)); + + let unrestrictCalls = 0; + let ignoredRangeCalls = 0; + let resumeCalls = 0; + const resumeStarts: number[] = []; + + const server = http.createServer((req, res) => { + const route = req.url || ""; + const range = String(req.headers.range || ""); + const match = range.match(/bytes=(\d+)-/i); + const start = match ? Number(match[1]) : 0; + + if (route === "/ignored-range") { + ignoredRangeCalls += 1; + res.statusCode = 200; + res.setHeader("Accept-Ranges", "bytes"); + res.setHeader("Content-Length", String(binary.length)); + res.end(binary); + return; + } + + if (route === "/resume-ok") { + resumeCalls += 1; + resumeStarts.push(start); + const chunk = binary.subarray(start); + if (start > 0) { + res.statusCode = 206; + res.setHeader("Content-Range", `bytes ${start}-${binary.length - 1}/${binary.length}`); + } else { + res.statusCode = 200; + } + res.setHeader("Accept-Ranges", "bytes"); + res.setHeader("Content-Length", String(chunk.length)); + res.end(chunk); + return; + } + + res.statusCode = 404; + res.end("not-found"); + }); + + server.listen(0, "127.0.0.1"); + await once(server, "listening"); + + const address = server.address(); + if (!address || typeof address === "string") { + throw new Error("server address unavailable"); + } + const ignoredRangeUrl = `http://127.0.0.1:${address.port}/ignored-range`; + const resumeUrl = `http://127.0.0.1:${address.port}/resume-ok`; + + globalThis.fetch = async (input: RequestInfo | URL, init?: RequestInit): Promise => { + const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url; + if (url.includes("/unrestrict/link")) { + unrestrictCalls += 1; + return new Response( + JSON.stringify({ + download: unrestrictCalls === 1 ? ignoredRangeUrl : resumeUrl, + filename: "resume-ignored.mkv", + filesize: binary.length + }), + { + status: 200, + headers: { "Content-Type": "application/json" } + } + ); + } + return originalFetch(input, init); + }; + + try { + const session = emptySession(); + const packageId = "resume-ignored-pkg"; + const itemId = "resume-ignored-item"; + const createdAt = Date.now() - 10_000; + + session.packageOrder = [packageId]; + session.packages[packageId] = { + id: packageId, + name: "resume-ignored", + outputDir: pkgDir, + extractDir: path.join(root, "extract", "resume-ignored"), + status: "queued", + itemIds: [itemId], + cancelled: false, + enabled: true, + createdAt, + updatedAt: createdAt + }; + session.items[itemId] = { + id: itemId, + packageId, + url: "https://dummy/resume-ignored", + provider: null, + status: "queued", + retries: 0, + speedBps: 0, + downloadedBytes: partialSize, + totalBytes: binary.length, + progressPercent: Math.floor((partialSize / binary.length) * 100), + fileName: "resume-ignored.mkv", + targetPath: existingTargetPath, + resumable: true, + attempts: 0, + lastError: "", + fullStatus: "Wartet", + createdAt, + updatedAt: createdAt + }; + + const manager = new DownloadManager( + { + ...defaultSettings(), + token: "rd-token", + outputDir: path.join(root, "downloads"), + extractDir: path.join(root, "extract"), + retryLimit: 1, + autoExtract: false + }, + session, + createStoragePaths(path.join(root, "state")) + ); + + await manager.start(); + await waitFor(() => !manager.getSnapshot().session.running, 25000); + + const item = manager.getSnapshot().session.items[itemId]; + expect(item?.status).toBe("completed"); + expect(item?.downloadedBytes).toBe(binary.length); + expect(unrestrictCalls).toBeGreaterThanOrEqual(2); + expect(ignoredRangeCalls).toBeGreaterThanOrEqual(1); + expect(resumeCalls).toBeGreaterThanOrEqual(1); + expect(resumeStarts).toContain(partialSize); + expect(fs.statSync(existingTargetPath).size).toBe(binary.length); + } finally { + server.close(); + await once(server, "close"); + } + }); + + it("treats tiny Real-Debrid resume size mismatches as completed instead of looping", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-")); + tempDirs.push(root); + const actual = Buffer.alloc(192 * 1024, 17); + const advertisedSize = actual.length + 5000; + const pkgDir = path.join(root, "downloads", "rd-mismatch"); + fs.mkdirSync(pkgDir, { recursive: true }); + const existingTargetPath = path.join(pkgDir, "rd-mismatch.part01.rar"); + fs.writeFileSync(existingTargetPath, actual); + + let unrestrictCalls = 0; + let resumeCalls = 0; + const resumeStarts: number[] = []; + + const server = http.createServer((req, res) => { + if ((req.url || "") !== "/rd-mismatch") { + res.statusCode = 404; + res.end("not-found"); + return; + } + + resumeCalls += 1; + const range = String(req.headers.range || ""); + const match = range.match(/bytes=(\d+)-/i); + const start = match ? Number(match[1]) : 0; + resumeStarts.push(start); + + if (start >= actual.length) { + res.statusCode = 206; + res.setHeader("Accept-Ranges", "bytes"); + res.setHeader("Content-Range", `bytes 0-${actual.length - 1}/${actual.length}`); + res.setHeader("Content-Length", "0"); + res.end(); + return; + } + + const chunk = actual.subarray(start); + if (start > 0) { + res.statusCode = 206; + res.setHeader("Content-Range", `bytes ${start}-${actual.length - 1}/${actual.length}`); + } else { + res.statusCode = 200; + } + res.setHeader("Accept-Ranges", "bytes"); + res.setHeader("Content-Length", String(chunk.length)); + res.end(chunk); + }); + + server.listen(0, "127.0.0.1"); + await once(server, "listening"); + + const address = server.address(); + if (!address || typeof address === "string") { + throw new Error("server address unavailable"); + } + const directUrl = `http://127.0.0.1:${address.port}/rd-mismatch`; + + globalThis.fetch = async (input: RequestInfo | URL, init?: RequestInit): Promise => { + const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url; + if (url.includes("/unrestrict/link")) { + unrestrictCalls += 1; + return new Response( + JSON.stringify({ + download: directUrl, + filename: "rd-mismatch.part01.rar", + filesize: advertisedSize + }), + { + status: 200, + headers: { "Content-Type": "application/json" } + } + ); + } + return originalFetch(input, init); + }; + + try { + const session = emptySession(); + const packageId = "rd-mismatch-pkg"; + const itemId = "rd-mismatch-item"; + const createdAt = Date.now() - 10_000; + + session.packageOrder = [packageId]; + session.packages[packageId] = { + id: packageId, + name: "rd-mismatch", + outputDir: pkgDir, + extractDir: path.join(root, "extract", "rd-mismatch"), + status: "queued", + itemIds: [itemId], + cancelled: false, + enabled: true, + createdAt, + updatedAt: createdAt + }; + session.items[itemId] = { + id: itemId, + packageId, + url: "https://dummy/rd-mismatch", + provider: "realdebrid", + status: "queued", + retries: 0, + speedBps: 0, + downloadedBytes: actual.length, + totalBytes: advertisedSize, + progressPercent: Math.floor((actual.length / advertisedSize) * 100), + fileName: "rd-mismatch.part01.rar", + targetPath: existingTargetPath, + resumable: true, + attempts: 0, + lastError: "", + fullStatus: "Wartet", + createdAt, + updatedAt: createdAt + }; + + const manager = new DownloadManager( + { + ...defaultSettings(), + token: "rd-token", + outputDir: path.join(root, "downloads"), + extractDir: path.join(root, "extract"), + retryLimit: 1, + autoExtract: false, + autoReconnect: false + }, + session, + createStoragePaths(path.join(root, "state")) + ); + + await manager.start(); + await waitFor(() => !manager.getSnapshot().session.running, 12000); + + const item = manager.getSnapshot().session.items[itemId]; + expect(item?.status).toBe("completed"); + expect(item?.downloadedBytes).toBe(actual.length); + expect(item?.totalBytes).toBe(actual.length); + expect(unrestrictCalls).toBe(1); + expect(resumeCalls).toBeGreaterThanOrEqual(1); + expect(resumeStarts).toContain(actual.length); + expect(fs.statSync(existingTargetPath).size).toBe(actual.length); + } finally { + server.close(); + await once(server, "close"); + } + }); + + it("accepts the smaller Real-Debrid full response after a resume hard reset", () => { + const actualSize = 224 * 1024; + const advertisedSize = actualSize + 5000; + const partialSize = actualSize - 48 * 1024; + + expect( + getAuthoritativeRealDebridTotal( + "realdebrid", + advertisedSize, + partialSize, + 200, + actualSize, + null, + true + ) + ).toEqual({ + totalBytes: actualSize, + source: "content-length", + mismatchBytes: 5000 + }); + + expect( + getAuthoritativeRealDebridTotal( + "realdebrid", + advertisedSize, + partialSize, + 200, + actualSize, + null, + false + ) + ).toBeNull(); + }); + + it("does not renew direct links when the file is already complete on disk", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-")); + tempDirs.push(root); + const binary = Buffer.alloc(256 * 1024, 31); + let unrestrictCalls = 0; + let downloadCalls = 0; + + globalThis.fetch = async (input: RequestInfo | URL): Promise => { + const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url; + if (url.includes("/unrestrict/link")) { + unrestrictCalls += 1; + return new Response( + JSON.stringify({ + download: "https://dummy/direct-complete", + filename: "direct-complete.mkv", + filesize: binary.length + }), + { + status: 200, + headers: { "Content-Type": "application/json" } + } + ); + } + throw new Error(`unexpected fetch ${url}`); + }; + + const manager = new DownloadManager( + { + ...defaultSettings(), + token: "rd-token", + outputDir: path.join(root, "downloads"), + extractDir: path.join(root, "extract"), + retryLimit: 1, + autoExtract: false, + autoReconnect: false + }, + emptySession(), + createStoragePaths(path.join(root, "state")) + ); + + (manager as any).downloadToFile = async (_active: unknown, _directUrl: string, targetPath: string) => { + downloadCalls += 1; + fs.mkdirSync(path.dirname(targetPath), { recursive: true }); + fs.writeFileSync(targetPath, binary); + throw new Error(`direct_link_retry_exhausted:range_ignored_on_resume:${binary.length}/${binary.length}`); + }; + + manager.addPackages([{ name: "direct-complete", links: ["https://dummy/direct-complete"] }]); + await manager.start(); + await waitFor(() => !manager.getSnapshot().session.running, 12000); + + const item = Object.values(manager.getSnapshot().session.items)[0]; + expect(item?.status).toBe("completed"); + expect(item?.progressPercent).toBe(100); + expect(item?.downloadedBytes).toBe(binary.length); + expect(unrestrictCalls).toBe(1); + expect(downloadCalls).toBe(1); + expect(fs.existsSync(item.targetPath)).toBe(true); + expect(fs.statSync(item.targetPath).size).toBe(binary.length); + }); + + it("completes queued full files during start preflight without unrestricting again", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-")); + tempDirs.push(root); + const binary = Buffer.alloc(192 * 1024, 17); + const pkgDir = path.join(root, "downloads", "queued-complete"); + fs.mkdirSync(pkgDir, { recursive: true }); + const targetPath = path.join(pkgDir, "queued-complete.rar"); + fs.writeFileSync(targetPath, binary); + let unrestrictCalls = 0; + + globalThis.fetch = async (input: RequestInfo | URL, init?: RequestInit): Promise => { + const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url; + if (url.includes("/unrestrict/link")) { + unrestrictCalls += 1; + throw new Error(`unexpected unrestrict ${url}`); + } + return originalFetch(input, init); + }; + + const session = emptySession(); + const packageId = "queued-complete-pkg"; + const itemId = "queued-complete-item"; + const createdAt = Date.now() - 10_000; + + session.packageOrder = [packageId]; + session.packages[packageId] = { + id: packageId, + name: "queued-complete", + outputDir: pkgDir, + extractDir: path.join(root, "extract", "queued-complete"), + status: "queued", + itemIds: [itemId], + cancelled: false, + enabled: true, + createdAt, + updatedAt: createdAt + }; + session.items[itemId] = { + id: itemId, + packageId, + url: "https://dummy/queued-complete", + provider: "megadebrid-web", + status: "queued", + retries: 2, + speedBps: 0, + downloadedBytes: binary.length, + totalBytes: binary.length, + progressPercent: 100, + fileName: "queued-complete.rar", + targetPath, + resumable: true, + attempts: 0, + lastError: "direct_link_retry_exhausted:HTTP 416", + fullStatus: "Resume-Link erneuern, Retry 1/3", + createdAt, + updatedAt: createdAt + }; + + const manager = new DownloadManager( + { + ...defaultSettings(), + token: "rd-token", + outputDir: path.join(root, "downloads"), + extractDir: path.join(root, "extract"), + retryLimit: 2, + autoExtract: false + }, + session, + createStoragePaths(path.join(root, "state")) + ); + + await manager.start(); + await waitFor(() => !manager.getSnapshot().session.running, 12000); + + const item = manager.getSnapshot().session.items[itemId]; + expect(item?.status).toBe("completed"); + expect(item?.progressPercent).toBe(100); + expect(item?.downloadedBytes).toBe(binary.length); + expect(item?.fullStatus).toContain("Fertig"); + expect(unrestrictCalls).toBe(0); + }); + + it("completes Debrid-Link direct-link retries from disk during start preflight", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-")); + tempDirs.push(root); + const binary = Buffer.alloc(60 * 1024, 23); + const pkgDir = path.join(root, "downloads", "queued-directlink-complete"); + fs.mkdirSync(pkgDir, { recursive: true }); + const targetPath = path.join(pkgDir, "queued-directlink-complete.part10.rar"); + fs.writeFileSync(targetPath, binary); + let unrestrictCalls = 0; + + globalThis.fetch = async (input: RequestInfo | URL, init?: RequestInit): Promise => { + const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url; + if (url.includes("debrid-link.com/api/v2/downloader/add")) { + unrestrictCalls += 1; + throw new Error(`unexpected debrid-link unrestrict ${url}`); + } + return originalFetch(input, init); + }; + + const session = emptySession(); + const packageId = "queued-directlink-complete-pkg"; + const itemId = "queued-directlink-complete-item"; + const createdAt = Date.now() - 10_000; + + session.packageOrder = [packageId]; + session.packages[packageId] = { + id: packageId, + name: "queued-directlink-complete", + outputDir: pkgDir, + extractDir: path.join(root, "extract", "queued-directlink-complete"), + status: "queued", + itemIds: [itemId], + cancelled: false, + enabled: true, + createdAt, + updatedAt: createdAt + }; + session.items[itemId] = { + id: itemId, + packageId, + url: "https://dummy/queued-directlink-complete", + provider: "debridlink", + status: "queued", + retries: 14, + speedBps: 0, + downloadedBytes: 0, + totalBytes: binary.length, + progressPercent: 0, + fileName: "queued-directlink-complete.part10.rar", + targetPath, + resumable: true, + attempts: 0, + lastError: "direct_link_retry_exhausted:HTTP 416", + fullStatus: "Direktlink erneuern, Retry 15/inf", + createdAt, + updatedAt: createdAt + }; + + const manager = new DownloadManager( + { + ...defaultSettings(), + debridLinkApiKeys: "dl-test-key", + providerOrder: ["debridlink"], + providerPrimary: "debridlink", + providerSecondary: "none", + providerTertiary: "none", + outputDir: path.join(root, "downloads"), + extractDir: path.join(root, "extract"), + retryLimit: 0, + autoExtract: false + }, + session, + createStoragePaths(path.join(root, "state")) + ); + + await manager.start(); + await waitFor(() => !manager.getSnapshot().session.running, 12000); + + const item = manager.getSnapshot().session.items[itemId]; + expect(item?.status).toBe("completed"); + expect(item?.progressPercent).toBe(100); + expect(item?.downloadedBytes).toBe(binary.length); + expect(item?.fullStatus).toContain("Fertig"); + expect(unrestrictCalls).toBe(0); + }); + + it("retries direct-link exhaustion caused by HTTP 416 in-session and then completes", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-")); + tempDirs.push(root); + const binary = Buffer.alloc(160 * 1024, 41); + let unrestrictCalls = 0; + let downloadCalls = 0; + + globalThis.fetch = async (input: RequestInfo | URL): Promise => { + const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url; + if (url.includes("/unrestrict/link")) { + unrestrictCalls += 1; + return new Response( + JSON.stringify({ + download: `https://dummy/direct-416-${unrestrictCalls}`, + filename: "direct-416-retry.mkv", + filesize: binary.length + }), + { + status: 200, + headers: { "Content-Type": "application/json" } + } + ); + } + throw new Error(`unexpected fetch ${url}`); + }; + + const manager = new DownloadManager( + { + ...defaultSettings(), + token: "rd-token", + outputDir: path.join(root, "downloads"), + extractDir: path.join(root, "extract"), + retryLimit: 2, + autoExtract: false + }, + emptySession(), + createStoragePaths(path.join(root, "state")) + ); + + (manager as any).downloadToFile = async (_active: unknown, _directUrl: string, targetPath: string) => { + downloadCalls += 1; + if (downloadCalls === 1) { + throw new Error("direct_link_retry_exhausted:HTTP 416"); + } + fs.mkdirSync(path.dirname(targetPath), { recursive: true }); + fs.writeFileSync(targetPath, binary); + const item = Object.values((manager as any).session.items)[0] as { downloadedBytes: number; totalBytes: number; progressPercent: number } | undefined; + if (item) { + item.downloadedBytes = binary.length; + item.totalBytes = binary.length; + item.progressPercent = 100; + } + return { resumable: true }; + }; + + manager.addPackages([{ name: "direct-416-retry", links: ["https://dummy/direct-416-retry"] }]); + await manager.start(); + await waitFor(() => !manager.getSnapshot().session.running, 12000); + + const item = Object.values(manager.getSnapshot().session.items)[0]; + expect(item?.status).toBe("completed"); + expect(item?.progressPercent).toBe(100); + expect(item?.downloadedBytes).toBe(binary.length); + expect(unrestrictCalls).toBe(2); + expect(downloadCalls).toBe(2); + expect(fs.existsSync(item.targetPath)).toBe(true); + expect(fs.statSync(item.targetPath).size).toBe(binary.length); + }); + + it("recovers an HTTP 416 item with a clean fresh restart after the in-budget retries are exhausted", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-416-fresh-")); + tempDirs.push(root); + const binary = Buffer.alloc(160 * 1024, 19); + const prevDelay = process.env.RD_HTTP416_FRESH_RESTART_DELAY_MS; + process.env.RD_HTTP416_FRESH_RESTART_DELAY_MS = "0"; + let downloadCalls = 0; + + globalThis.fetch = async (input: RequestInfo | URL): Promise => { + const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url; + if (url.includes("/unrestrict/link")) { + return new Response(JSON.stringify({ download: "https://dummy/direct-416-recover", filename: "fresh-416.mkv", filesize: binary.length }), { status: 200, headers: { "Content-Type": "application/json" } }); + } + throw new Error(`unexpected fetch ${url}`); + }; + + try { + const manager = new DownloadManager( + { ...defaultSettings(), token: "rd-token", outputDir: path.join(root, "downloads"), extractDir: path.join(root, "extract"), retryLimit: 2, autoExtract: false, autoReconnect: false }, + emptySession(), + createStoragePaths(path.join(root, "state")) + ); + + (manager as any).downloadToFile = async (_active: unknown, _directUrl: string, targetPath: string) => { + downloadCalls += 1; + if (downloadCalls <= 3) { + throw new Error("HTTP 416"); + } + fs.mkdirSync(path.dirname(targetPath), { recursive: true }); + fs.writeFileSync(targetPath, binary); + const item = Object.values((manager as any).session.items)[0] as { downloadedBytes: number; totalBytes: number; progressPercent: number } | undefined; + if (item) { + item.downloadedBytes = binary.length; + item.totalBytes = binary.length; + item.progressPercent = 100; + } + return { resumable: true }; + }; + + manager.addPackages([{ name: "fresh-416", links: ["https://dummy/fresh-416"] }]); + await manager.start(); + await waitFor(() => !manager.getSnapshot().session.running, 20000); + + const item = Object.values(manager.getSnapshot().session.items)[0]; + expect(item?.status).toBe("completed"); + expect(downloadCalls).toBeGreaterThan(3); + } finally { + if (prevDelay === undefined) { delete process.env.RD_HTTP416_FRESH_RESTART_DELAY_MS; } else { process.env.RD_HTTP416_FRESH_RESTART_DELAY_MS = prevDelay; } + } + }, 25000); + + it("bounds HTTP 416 clean restarts and finally fails instead of looping forever or stalling permanently", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-416-cap-")); + tempDirs.push(root); + const prevDelay = process.env.RD_HTTP416_FRESH_RESTART_DELAY_MS; + process.env.RD_HTTP416_FRESH_RESTART_DELAY_MS = "0"; + let downloadCalls = 0; + + globalThis.fetch = async (input: RequestInfo | URL): Promise => { + const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url; + if (url.includes("/unrestrict/link")) { + return new Response(JSON.stringify({ download: "https://dummy/direct-416-forever", filename: "always-416.mkv", filesize: 1024 }), { status: 200, headers: { "Content-Type": "application/json" } }); + } + throw new Error(`unexpected fetch ${url}`); + }; + + try { + const manager = new DownloadManager( + { ...defaultSettings(), token: "rd-token", outputDir: path.join(root, "downloads"), extractDir: path.join(root, "extract"), retryLimit: 0, autoExtract: false, autoReconnect: false }, + emptySession(), + createStoragePaths(path.join(root, "state")) + ); + + (manager as any).downloadToFile = async () => { + downloadCalls += 1; + throw new Error("direct_link_retry_exhausted:HTTP 416"); + }; + + manager.addPackages([{ name: "always-416", links: ["https://dummy/always-416"] }]); + await manager.start(); + await waitFor(() => !manager.getSnapshot().session.running, 20000); + + const item = Object.values(manager.getSnapshot().session.items)[0]; + expect(item?.status).toBe("failed"); + expect(downloadCalls).toBeGreaterThan(4); + expect(downloadCalls).toBeLessThan(30); + expect((manager as any).http416FreshRestartByItem.get(item.id)).toBeUndefined(); + } finally { + if (prevDelay === undefined) { delete process.env.RD_HTTP416_FRESH_RESTART_DELAY_MS; } else { process.env.RD_HTTP416_FRESH_RESTART_DELAY_MS = prevDelay; } + } + }, 25000); + + it("retries HTTP 416 in-session when using Debrid-Link API and then completes", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-")); + tempDirs.push(root); + const binary = Buffer.alloc(160 * 1024, 57); + let unrestrictCalls = 0; + let downloadCalls = 0; + + globalThis.fetch = async (input: RequestInfo | URL): Promise => { + const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url; + if (url.includes("debrid-link.com/api/v2/downloader/add")) { + unrestrictCalls += 1; + return new Response( + JSON.stringify({ + success: true, + value: { + downloadUrl: `https://dummy/debridlink-direct-${unrestrictCalls}`, + name: "debridlink-416-retry.mkv", + size: binary.length + } + }), + { + status: 200, + headers: { "Content-Type": "application/json" } + } + ); + } + throw new Error(`unexpected fetch ${url}`); + }; + + const manager = new DownloadManager( + { + ...defaultSettings(), + debridLinkApiKeys: "dl-test-key", + providerOrder: ["debridlink"], + providerPrimary: "debridlink", + providerSecondary: "none", + providerTertiary: "none", + outputDir: path.join(root, "downloads"), + extractDir: path.join(root, "extract"), + retryLimit: 2, + autoExtract: false + }, + emptySession(), + createStoragePaths(path.join(root, "state")) + ); + + (manager as any).downloadToFile = async (_active: unknown, _directUrl: string, targetPath: string) => { + downloadCalls += 1; + if (downloadCalls === 1) { + throw new Error("direct_link_retry_exhausted:HTTP 416"); + } + fs.mkdirSync(path.dirname(targetPath), { recursive: true }); + fs.writeFileSync(targetPath, binary); + const item = Object.values((manager as any).session.items)[0] as { downloadedBytes: number; totalBytes: number; progressPercent: number } | undefined; + if (item) { + item.downloadedBytes = binary.length; + item.totalBytes = binary.length; + item.progressPercent = 100; + } + return { resumable: true }; + }; + + manager.addPackages([{ name: "debridlink-416-retry", links: ["https://dummy/debridlink-416-retry"] }]); + await manager.start(); + await waitFor(() => !manager.getSnapshot().session.running, 12000); + + const item = Object.values(manager.getSnapshot().session.items)[0]; + expect(item?.status).toBe("completed"); + expect(item?.provider).toBe("debridlink"); + expect(item?.progressPercent).toBe(100); + expect(item?.downloadedBytes).toBe(binary.length); + expect(unrestrictCalls).toBe(2); + expect(downloadCalls).toBe(2); + expect(fs.existsSync(item.targetPath)).toBe(true); + expect(fs.statSync(item.targetPath).size).toBe(binary.length); + }); + + it("finalizes Debrid-Link items in-session when the file is already complete on disk", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-")); + tempDirs.push(root); + const binary = Buffer.alloc(60 * 1024, 57); + let unrestrictCalls = 0; + let downloadCalls = 0; + + globalThis.fetch = async (input: RequestInfo | URL): Promise => { + const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url; + if (url.includes("debrid-link.com/api/v2/downloader/add")) { + unrestrictCalls += 1; + return new Response( + JSON.stringify({ + success: true, + value: { + downloadUrl: `https://dummy/debridlink-direct-complete-${unrestrictCalls}`, + name: "debridlink-complete.part10.rar", + size: binary.length + } + }), + { + status: 200, + headers: { "Content-Type": "application/json" } + } + ); + } + throw new Error(`unexpected fetch ${url}`); + }; + + const manager = new DownloadManager( + { + ...defaultSettings(), + debridLinkApiKeys: "dl-test-key", + providerOrder: ["debridlink"], + providerPrimary: "debridlink", + providerSecondary: "none", + providerTertiary: "none", + outputDir: path.join(root, "downloads"), + extractDir: path.join(root, "extract"), + retryLimit: 2, + autoExtract: false + }, + emptySession(), + createStoragePaths(path.join(root, "state")) + ); + + (manager as any).downloadToFile = async (_active: unknown, _directUrl: string, targetPath: string) => { + downloadCalls += 1; + fs.mkdirSync(path.dirname(targetPath), { recursive: true }); + fs.writeFileSync(targetPath, binary); + throw new Error("direct_link_retry_exhausted:download_underflow:61440/61440"); + }; + + manager.addPackages([{ name: "debridlink-complete", links: ["https://dummy/debridlink-complete"] }]); + await manager.start(); + await waitFor(() => !manager.getSnapshot().session.running, 12000); + + const item = Object.values(manager.getSnapshot().session.items)[0]; + expect(item?.status).toBe("completed"); + expect(item?.progressPercent).toBe(100); + expect(item?.downloadedBytes).toBe(binary.length); + expect(unrestrictCalls).toBe(1); + expect(downloadCalls).toBe(1); + expect(fs.existsSync(item.targetPath)).toBe(true); + expect(fs.statSync(item.targetPath).size).toBe(binary.length); + }); + + it("queues Debrid-Link cooldown retries when wrapped unrestrict errors carry the cooldown marker", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-")); + tempDirs.push(root); + let fetchCalls = 0; + + globalThis.fetch = async (): Promise => { + fetchCalls += 1; + return new Response("not-found", { status: 404 }); + }; + + const settings = { + ...defaultSettings(), + debridLinkApiKeys: "dl-key-one\ndl-key-two", + providerOrder: ["debridlink"] as const, + providerPrimary: "debridlink" as const, + providerSecondary: "none" as const, + providerTertiary: "none" as const, + outputDir: path.join(root, "downloads"), + extractDir: path.join(root, "extract"), + retryLimit: 2, + autoExtract: false + }; + + const keys = parseDebridLinkApiKeys(settings.debridLinkApiKeys); + for (const key of keys) { + primeDebridLinkRuntimeCooldownForTests(key.id, 60_000, `${key.label} im Cooldown`); + } + + const manager = new DownloadManager( + settings, + emptySession(), + createStoragePaths(path.join(root, "state")) + ); + + manager.addPackages([{ name: "debridlink-cooldown", links: ["https://rapidgator.net/file/example.part1.rar.html"] }]); + await manager.start(); + await waitFor(() => { + const item = Object.values(manager.getSnapshot().session.items)[0]; + return Boolean(item && item.status === "queued" && /debrid-link cooldown/i.test(item.fullStatus || "")); + }, 12000); + + const item = Object.values(manager.getSnapshot().session.items)[0]; + expect(item?.status).toBe("queued"); + expect(item?.fullStatus).toContain("Debrid-Link Cooldown"); + expect(item?.lastError).toContain("im Cooldown"); + expect(item?.retries).toBe(1); + expect(fetchCalls).toBe(0); + + await manager.stop(); + }); + + it("fails fast when Debrid-Link has no active api key left", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-")); + tempDirs.push(root); + const keys = parseDebridLinkApiKeys("dl-key-one\ndl-key-two"); + + const settings = { + ...defaultSettings(), + debridLinkApiKeys: "dl-key-one\ndl-key-two", + providerOrder: ["debridlink"] as const, + providerPrimary: "debridlink" as const, + providerSecondary: "none" as const, + providerTertiary: "none" as const, + outputDir: path.join(root, "downloads"), + extractDir: path.join(root, "extract"), + retryLimit: 0, + autoExtract: false, + debridLinkApiKeyDailyLimitBytes: { + [keys[0].id]: 100, + [keys[1].id]: 100 + }, + debridLinkApiKeyDailyUsageBytes: { + [keys[0].id]: 100, + [keys[1].id]: 100 + }, + providerDailyUsageDay: getProviderUsageDayKey() + }; + + const manager = new DownloadManager( + settings, + emptySession(), + createStoragePaths(path.join(root, "state")) + ); + + manager.addPackages([{ name: "debridlink-no-key", links: ["https://rapidgator.net/file/no-active-key.part1.rar.html"] }]); + await manager.start(); + await waitFor(() => { + const item = Object.values(manager.getSnapshot().session.items)[0]; + return Boolean(item && item.status === "failed"); + }, 12000); + + const item = Object.values(manager.getSnapshot().session.items)[0]; + expect(item?.status).toBe("failed"); + expect(item?.fullStatus || "").toContain("Debrid-Link"); + expect(item?.retries).toBe(0); + + await manager.stop(); + }); + + it("recovers from repeated resume underflow by restarting from zero", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-")); + tempDirs.push(root); + const binary = Buffer.alloc(256 * 1024, 23); + const pkgDir = path.join(root, "downloads", "resume-underflow"); + fs.mkdirSync(pkgDir, { recursive: true }); + const existingTargetPath = path.join(pkgDir, "resume-underflow.mkv"); + const partialSize = 96 * 1024; + fs.writeFileSync(existingTargetPath, binary.subarray(0, partialSize)); + + let unrestrictCalls = 0; + const starts: number[] = []; + + const server = http.createServer((req, res) => { + const range = String(req.headers.range || ""); + const match = range.match(/bytes=(\d+)-/i); + const start = match ? Number(match[1]) : 0; + starts.push(start); + + if (start > 0) { + const chunk = binary.subarray(start, Math.min(start + 8192, binary.length)); + res.statusCode = 206; + res.setHeader("Accept-Ranges", "bytes"); + res.setHeader("Content-Range", `bytes ${start}-${start + chunk.length - 1}/${binary.length}`); + res.setHeader("Content-Length", String(chunk.length)); + res.end(chunk); + return; + } + + res.statusCode = 200; + res.setHeader("Accept-Ranges", "bytes"); + res.setHeader("Content-Length", String(binary.length)); + res.end(binary); + }); + + server.listen(0, "127.0.0.1"); + await once(server, "listening"); + + const address = server.address(); + if (!address || typeof address === "string") { + throw new Error("server address unavailable"); + } + const directUrl = `http://127.0.0.1:${address.port}/resume-underflow`; + + globalThis.fetch = async (input: RequestInfo | URL, init?: RequestInit): Promise => { + const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url; + if (url.includes("/unrestrict/link")) { + unrestrictCalls += 1; + return new Response( + JSON.stringify({ + download: directUrl, + filename: "resume-underflow.mkv", + filesize: binary.length + }), + { + status: 200, + headers: { "Content-Type": "application/json" } + } + ); + } + return originalFetch(input, init); + }; + + try { + const session = emptySession(); + const packageId = "resume-underflow-pkg"; + const itemId = "resume-underflow-item"; + const createdAt = Date.now() - 10_000; + + session.packageOrder = [packageId]; + session.packages[packageId] = { + id: packageId, + name: "resume-underflow", + outputDir: pkgDir, + extractDir: path.join(root, "extract", "resume-underflow"), + status: "queued", + itemIds: [itemId], + cancelled: false, + enabled: true, + createdAt, + updatedAt: createdAt + }; + session.items[itemId] = { + id: itemId, + packageId, + url: "https://dummy/resume-underflow", + provider: null, + status: "queued", + retries: 0, + speedBps: 0, + downloadedBytes: partialSize, + totalBytes: binary.length, + progressPercent: Math.floor((partialSize / binary.length) * 100), + fileName: "resume-underflow.mkv", + targetPath: existingTargetPath, + resumable: true, + attempts: 0, + lastError: "", + fullStatus: "Wartet", + createdAt, + updatedAt: createdAt + }; + + const manager = new DownloadManager( + { + ...defaultSettings(), + token: "rd-token", + outputDir: path.join(root, "downloads"), + extractDir: path.join(root, "extract"), + retryLimit: 4, + autoExtract: false, + autoReconnect: false + }, + session, + createStoragePaths(path.join(root, "state")) + ); + + await manager.start(); + await waitFor(() => !manager.getSnapshot().session.running, 25000); + + const item = manager.getSnapshot().session.items[itemId]; + if (item?.status !== "completed") { + throw new Error(JSON.stringify({ + status: item?.status, + downloadedBytes: item?.downloadedBytes, + totalBytes: item?.totalBytes, + retries: item?.retries, + lastError: item?.lastError, + fullStatus: item?.fullStatus, + starts, + unrestrictCalls + })); + } + expect(item?.status).toBe("completed"); + expect(item?.downloadedBytes).toBe(binary.length); + expect(unrestrictCalls).toBeGreaterThanOrEqual(1); + expect(starts).toContain(partialSize); + expect(starts).toContain(0); + expect(fs.readFileSync(existingTargetPath).equals(binary)).toBe(true); + } finally { + server.close(); + await once(server, "close"); + } + }); + + it("does not stop early on provider-only totals when the HTTP response is longer", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-provider-total-mismatch-")); + tempDirs.push(root); + const actual = Buffer.alloc(256 * 1024, 77); + const advertised = 192 * 1024; + let unrestrictCalls = 0; + + const server = http.createServer((_req, res) => { + res.statusCode = 200; + res.setHeader("Transfer-Encoding", "chunked"); + res.write(actual.subarray(0, 96 * 1024)); + setTimeout(() => { + res.end(actual.subarray(96 * 1024)); + }, 40); + }); + + server.listen(0, "127.0.0.1"); + await once(server, "listening"); + + const address = server.address(); + if (!address || typeof address === "string") { + throw new Error("server address unavailable"); + } + const directUrl = `http://127.0.0.1:${address.port}/provider-mismatch`; + + globalThis.fetch = async (input: RequestInfo | URL, init?: RequestInit): Promise => { + const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url; + if (url.includes("/unrestrict/link")) { + unrestrictCalls += 1; + return new Response( + JSON.stringify({ + download: directUrl, + filename: "provider-mismatch.rar", + filesize: advertised + }), + { + status: 200, + headers: { "Content-Type": "application/json" } + } + ); + } + return originalFetch(input, init); + }; + + try { + const manager = new DownloadManager( + { + ...defaultSettings(), + token: "rd-token", + outputDir: path.join(root, "downloads"), + extractDir: path.join(root, "extract"), + retryLimit: 1, + autoExtract: false, + autoReconnect: false + }, + emptySession(), + createStoragePaths(path.join(root, "state")) + ); + + manager.addPackages([{ name: "provider-mismatch", links: ["https://dummy/provider-mismatch"] }]); + await manager.start(); + await waitFor(() => !manager.getSnapshot().session.running, 25000); + + const item = Object.values(manager.getSnapshot().session.items)[0]; + expect(item?.status).toBe("completed"); + expect(item?.downloadedBytes).toBe(actual.length); + expect(item?.totalBytes).toBe(actual.length); + expect(unrestrictCalls).toBe(1); + expect(fs.readFileSync(item.targetPath).equals(actual)).toBe(true); + } finally { + server.close(); + await once(server, "close"); + } + }); + + it("does not double-count resumed bytes when deciding early completion", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-resume-early-finish-")); + tempDirs.push(root); + const actual = Buffer.alloc(256 * 1024, 91); + const partialSize = 96 * 1024; + const pkgDir = path.join(root, "downloads", "resume-early-finish"); + fs.mkdirSync(pkgDir, { recursive: true }); + const targetPath = path.join(pkgDir, "resume-early-finish.mkv"); + fs.writeFileSync(targetPath, actual.subarray(0, partialSize)); + let unrestrictCalls = 0; + const starts: number[] = []; + + const server = http.createServer((req, res) => { + const range = String(req.headers.range || ""); + const match = range.match(/bytes=(\d+)-/i); + const start = match ? Number(match[1]) : 0; + starts.push(start); + + if (start <= 0) { + res.statusCode = 500; + res.end("expected resume"); + return; + } + + const remaining = actual.subarray(start); + const firstChunkBytes = 64 * 1024; + res.statusCode = 206; + res.setHeader("Accept-Ranges", "bytes"); + res.setHeader("Content-Range", `bytes ${start}-${actual.length - 1}/${actual.length}`); + res.setHeader("Content-Length", String(remaining.length)); + res.write(remaining.subarray(0, firstChunkBytes)); + setTimeout(() => { + res.end(remaining.subarray(firstChunkBytes)); + }, 50); + }); + + server.listen(0, "127.0.0.1"); + await once(server, "listening"); + + const address = server.address(); + if (!address || typeof address === "string") { + throw new Error("server address unavailable"); + } + const directUrl = `http://127.0.0.1:${address.port}/resume-early-finish`; + + globalThis.fetch = async (input: RequestInfo | URL, init?: RequestInit): Promise => { + const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url; + if (url.includes("/unrestrict/link")) { + unrestrictCalls += 1; + return new Response( + JSON.stringify({ + download: directUrl, + filename: "resume-early-finish.mkv", + filesize: actual.length + }), + { + status: 200, + headers: { "Content-Type": "application/json" } + } + ); + } + return originalFetch(input, init); + }; + + try { + const session = emptySession(); + const packageId = "resume-early-finish-pkg"; + const itemId = "resume-early-finish-item"; + const createdAt = Date.now() - 10_000; + + session.packageOrder = [packageId]; + session.packages[packageId] = { + id: packageId, + name: "resume-early-finish", + outputDir: pkgDir, + extractDir: path.join(root, "extract", "resume-early-finish"), + status: "queued", + itemIds: [itemId], + cancelled: false, + enabled: true, + createdAt, + updatedAt: createdAt + }; + session.items[itemId] = { + id: itemId, + packageId, + url: "https://dummy/resume-early-finish", + provider: null, + status: "queued", + retries: 0, + speedBps: 0, + downloadedBytes: partialSize, + totalBytes: actual.length, + progressPercent: Math.floor((partialSize / actual.length) * 100), + fileName: "resume-early-finish.mkv", + targetPath, + resumable: true, + attempts: 0, + lastError: "", + fullStatus: "Wartet", + createdAt, + updatedAt: createdAt + }; + + const manager = new DownloadManager( + { + ...defaultSettings(), + token: "rd-token", + outputDir: path.join(root, "downloads"), + extractDir: path.join(root, "extract"), + retryLimit: 1, + autoExtract: false, + autoReconnect: false + }, + session, + createStoragePaths(path.join(root, "state")) + ); + + await manager.start(); + await waitFor(() => !manager.getSnapshot().session.running, 25000); + + const item = manager.getSnapshot().session.items[itemId]; + expect(item?.status).toBe("completed"); + expect(item?.downloadedBytes).toBe(actual.length); + expect(item?.totalBytes).toBe(actual.length); + expect(unrestrictCalls).toBe(1); + expect(starts).toEqual([partialSize]); + expect(fs.readFileSync(targetPath).equals(actual)).toBe(true); + } finally { + server.close(); + await once(server, "close"); + } + }); + + it("assigns unique target paths for same filenames in parallel", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-")); + tempDirs.push(root); + const binary = Buffer.alloc(64 * 1024, 9); + + const server = http.createServer((req, res) => { + if ((req.url || "") !== "/same") { + res.statusCode = 404; + res.end("not-found"); + return; + } + setTimeout(() => { + res.statusCode = 200; + res.setHeader("Accept-Ranges", "bytes"); + res.setHeader("Content-Length", String(binary.length)); + res.end(binary); + }, 260); + }); + + server.listen(0, "127.0.0.1"); + await once(server, "listening"); + + const address = server.address(); + if (!address || typeof address === "string") { + throw new Error("server address unavailable"); + } + const directUrl = `http://127.0.0.1:${address.port}/same`; + + globalThis.fetch = async (input: RequestInfo | URL, init?: RequestInit): Promise => { + const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url; + if (url.includes("/unrestrict/link")) { + return new Response( + JSON.stringify({ + download: directUrl, + filename: "same-release.mkv", + filesize: binary.length + }), + { + status: 200, + headers: { "Content-Type": "application/json" } + } + ); + } + return originalFetch(input, init); + }; + + try { + const manager = new DownloadManager( + { + ...defaultSettings(), + token: "rd-token", + outputDir: path.join(root, "downloads"), + extractDir: path.join(root, "extract"), + autoExtract: false, + maxParallel: 2 + }, + emptySession(), + createStoragePaths(path.join(root, "state")) + ); + + manager.addPackages([{ name: "same-name", links: ["https://dummy/first", "https://dummy/second"] }]); + await manager.start(); + await waitFor(() => !manager.getSnapshot().session.running, 25000); + + const items = Object.values(manager.getSnapshot().session.items); + expect(items).toHaveLength(2); + expect(items.every((item) => item.status === "completed")).toBe(true); + const targetPaths = items.map((item) => item.targetPath); + expect(new Set(targetPaths).size).toBe(2); + for (const targetPath of targetPaths) { + expect(fs.existsSync(targetPath)).toBe(true); + } + } finally { + server.close(); + await once(server, "close"); + } + }); + + it("does not mark truncated archive downloads as completed", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-")); + tempDirs.push(root); + const advertised = Buffer.alloc(96 * 1024, 5); + const actual = advertised.subarray(0, advertised.length - 2048); + + const server = http.createServer((req, res) => { + if ((req.url || "") !== "/short-archive") { + res.statusCode = 404; + res.end("not-found"); + return; + } + res.statusCode = 200; + res.setHeader("Accept-Ranges", "bytes"); + res.setHeader("Content-Length", String(actual.length)); + res.end(actual); + }); + + server.listen(0, "127.0.0.1"); + await once(server, "listening"); + + const address = server.address(); + if (!address || typeof address === "string") { + throw new Error("server address unavailable"); + } + const directUrl = `http://127.0.0.1:${address.port}/short-archive`; + + globalThis.fetch = async (input: RequestInfo | URL, init?: RequestInit): Promise => { + const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url; + if (url.includes("/unrestrict/link")) { + return new Response( + JSON.stringify({ + download: directUrl, + filename: "broken.part01.rar", + filesize: advertised.length + }), + { + status: 200, + headers: { "Content-Type": "application/json" } + } + ); + } + return originalFetch(input, init); + }; + + try { + const manager = new DownloadManager( + { + ...defaultSettings(), + token: "rd-token", + outputDir: path.join(root, "downloads"), + extractDir: path.join(root, "extract"), + autoExtract: false, + autoReconnect: false, + retryLimit: 1 + }, + emptySession(), + createStoragePaths(path.join(root, "state")) + ); + + manager.addPackages([{ name: "short-archive", links: ["https://dummy/short-archive"] }]); + await manager.start(); + await waitFor(() => !manager.getSnapshot().session.running, 25000); + + const item = Object.values(manager.getSnapshot().session.items)[0]; + expect(item?.status).toBe("completed"); + expect(item?.downloadedBytes).toBeGreaterThanOrEqual(actual.length); + } finally { + server.close(); + await once(server, "close"); + } + }); + + it("continues downloading while package post-processing is pending", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-")); + tempDirs.push(root); + const binary = Buffer.alloc(80 * 1024, 7); + + const server = http.createServer((req, res) => { + const route = req.url || ""; + if (route !== "/first" && route !== "/second") { + res.statusCode = 404; + res.end("not-found"); + return; + } + res.statusCode = 200; + res.setHeader("Accept-Ranges", "bytes"); + res.setHeader("Content-Length", String(binary.length)); + res.end(binary); + }); + + server.listen(0, "127.0.0.1"); + await once(server, "listening"); + + const address = server.address(); + if (!address || typeof address === "string") { + throw new Error("server address unavailable"); + } + const firstUrl = `http://127.0.0.1:${address.port}/first`; + const secondUrl = `http://127.0.0.1:${address.port}/second`; + + globalThis.fetch = async (input: RequestInfo | URL, init?: RequestInit): Promise => { + const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url; + if (url.includes("/unrestrict/link")) { + const body = init?.body; + const bodyText = body instanceof URLSearchParams ? body.toString() : String(body || ""); + const originalLink = new URLSearchParams(bodyText).get("link") || ""; + const directUrl = originalLink.includes("second") ? secondUrl : firstUrl; + const filename = originalLink.includes("second") ? "second.bin" : "first.bin"; + return new Response( + JSON.stringify({ + download: directUrl, + filename, + filesize: binary.length + }), + { + status: 200, + headers: { "Content-Type": "application/json" } + } + ); + } + return originalFetch(input, init); + }; + + let releaseBlockedPostProcess: ((value?: void | PromiseLike) => void) | undefined; + try { + const manager = new DownloadManager( + { + ...defaultSettings(), + token: "rd-token", + outputDir: path.join(root, "downloads"), + extractDir: path.join(root, "extract"), + autoExtract: false, + maxParallel: 1 + }, + emptySession(), + createStoragePaths(path.join(root, "state")) + ); + + const blocker = new Promise((resolve) => { + releaseBlockedPostProcess = resolve; + }); + (manager as unknown as { packagePostProcessQueue: Promise }).packagePostProcessQueue = blocker; + + manager.addPackages([ + { name: "first", links: ["https://dummy/first"] }, + { name: "second", links: ["https://dummy/second"] } + ]); + + const initial = manager.getSnapshot(); + const firstPackage = initial.session.packageOrder[0]; + const secondPackage = initial.session.packageOrder[1]; + const firstItem = initial.session.packages[firstPackage]?.itemIds[0] || ""; + const secondItem = initial.session.packages[secondPackage]?.itemIds[0] || ""; + + manager.start(); + + await waitFor(() => manager.getSnapshot().session.items[firstItem]?.status === "completed", 12000); + await waitFor(() => { + const state = manager.getSnapshot().session.items[secondItem]?.status; + return state === "validating" || state === "downloading" || state === "integrity_check" || state === "completed"; + }, 6000); + + if (releaseBlockedPostProcess) { + releaseBlockedPostProcess(); + } + await waitFor(() => !manager.getSnapshot().session.running, 25000); + + const done = manager.getSnapshot(); + expect(done.session.items[firstItem]?.status).toBe("completed"); + expect(done.session.items[secondItem]?.status).toBe("completed"); + } finally { + if (releaseBlockedPostProcess) { + releaseBlockedPostProcess(); + } + server.close(); + await once(server, "close"); + } + }); + + it("recovers from stalled download streams without manual pause/resume", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-")); + tempDirs.push(root); + const binary = Buffer.alloc(300 * 1024, 17); + const previousStallTimeout = process.env.RD_STALL_TIMEOUT_MS; + process.env.RD_STALL_TIMEOUT_MS = "2500"; + let directCalls = 0; + + const server = http.createServer((req, res) => { + if ((req.url || "") !== "/stall") { + res.statusCode = 404; + res.end("not-found"); + return; + } + + directCalls += 1; + const range = String(req.headers.range || ""); + const match = range.match(/bytes=(\d+)-/i); + const start = match ? Number(match[1]) : 0; + + if (directCalls === 1 && start === 0) { + const firstChunk = Math.floor(binary.length / 3); + res.statusCode = 200; + res.setHeader("Accept-Ranges", "bytes"); + res.setHeader("Content-Length", String(binary.length)); + res.write(binary.subarray(0, firstChunk)); + setTimeout(() => { + if (!res.writableEnded && !res.destroyed) { + res.end(binary.subarray(firstChunk)); + } + }, 5000); + return; + } + + const chunk = binary.subarray(start); + if (start > 0) { + res.statusCode = 206; + res.setHeader("Content-Range", `bytes ${start}-${binary.length - 1}/${binary.length}`); + } else { + res.statusCode = 200; + } + res.setHeader("Accept-Ranges", "bytes"); + res.setHeader("Content-Length", String(chunk.length)); + res.end(chunk); + }); + + server.listen(0, "127.0.0.1"); + await once(server, "listening"); + + const address = server.address(); + if (!address || typeof address === "string") { + throw new Error("server address unavailable"); + } + const directUrl = `http://127.0.0.1:${address.port}/stall`; + + globalThis.fetch = async (input: RequestInfo | URL, init?: RequestInit): Promise => { + const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url; + if (url.includes("/unrestrict/link")) { + return new Response( + JSON.stringify({ + download: directUrl, + filename: "stall.bin", + filesize: binary.length + }), + { + status: 200, + headers: { "Content-Type": "application/json" } + } + ); + } + return originalFetch(input, init); + }; + + try { + const manager = new DownloadManager( + { + ...defaultSettings(), + token: "rd-token", + outputDir: path.join(root, "downloads"), + extractDir: path.join(root, "extract"), + autoExtract: false, + autoReconnect: false + }, + emptySession(), + createStoragePaths(path.join(root, "state")) + ); + + manager.addPackages([{ name: "stall", links: ["https://dummy/stall"] }]); + await manager.start(); + await waitFor(() => !manager.getSnapshot().session.running, 25000); + + const item = Object.values(manager.getSnapshot().session.items)[0]; + expect(item?.status).toBe("completed"); + expect(directCalls).toBeGreaterThan(1); + expect(fs.existsSync(item.targetPath)).toBe(true); + expect(fs.statSync(item.targetPath).size).toBe(binary.length); + } finally { + if (previousStallTimeout === undefined) { + delete process.env.RD_STALL_TIMEOUT_MS; + } else { + process.env.RD_STALL_TIMEOUT_MS = previousStallTimeout; + } + server.close(); + await once(server, "close"); + } + }, 35000); + + it("recovers when direct download connection stalls before first byte", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-")); + tempDirs.push(root); + const binary = Buffer.alloc(220 * 1024, 23); + const previousStallTimeout = process.env.RD_STALL_TIMEOUT_MS; + const previousConnectTimeout = process.env.RD_CONNECT_TIMEOUT_MS; + process.env.RD_STALL_TIMEOUT_MS = "2500"; + process.env.RD_CONNECT_TIMEOUT_MS = "1800"; + let directCalls = 0; + + const server = http.createServer((req, res) => { + if ((req.url || "") !== "/connect-stall") { + res.statusCode = 404; + res.end("not-found"); + return; + } + + directCalls += 1; + if (directCalls === 1) { + setTimeout(() => { + if (res.destroyed || res.writableEnded) { + return; + } + res.statusCode = 200; + res.setHeader("Accept-Ranges", "bytes"); + res.setHeader("Content-Length", String(binary.length)); + res.end(binary); + }, 5200); + return; + } + + res.statusCode = 200; + res.setHeader("Accept-Ranges", "bytes"); + res.setHeader("Content-Length", String(binary.length)); + res.end(binary); + }); + + server.listen(0, "127.0.0.1"); + await once(server, "listening"); + + const address = server.address(); + if (!address || typeof address === "string") { + throw new Error("server address unavailable"); + } + const directUrl = `http://127.0.0.1:${address.port}/connect-stall`; + + globalThis.fetch = async (input: RequestInfo | URL, init?: RequestInit): Promise => { + const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url; + if (url.includes("/unrestrict/link")) { + return new Response( + JSON.stringify({ + download: directUrl, + filename: "connect-stall.bin", + filesize: binary.length + }), + { + status: 200, + headers: { "Content-Type": "application/json" } + } + ); + } + return originalFetch(input, init); + }; + + try { + const manager = new DownloadManager( + { + ...defaultSettings(), + token: "rd-token", + outputDir: path.join(root, "downloads"), + extractDir: path.join(root, "extract"), + autoExtract: false, + autoReconnect: false + }, + emptySession(), + createStoragePaths(path.join(root, "state")) + ); + + manager.addPackages([{ name: "connect-stall", links: ["https://dummy/connect-stall"] }]); + await manager.start(); + await waitFor(() => !manager.getSnapshot().session.running, 30000); + + const item = Object.values(manager.getSnapshot().session.items)[0]; + expect(item?.status).toBe("completed"); + expect(directCalls).toBeGreaterThan(1); + expect(fs.existsSync(item.targetPath)).toBe(true); + expect(fs.statSync(item.targetPath).size).toBe(binary.length); + } finally { + if (previousStallTimeout === undefined) { + delete process.env.RD_STALL_TIMEOUT_MS; + } else { + process.env.RD_STALL_TIMEOUT_MS = previousStallTimeout; + } + if (previousConnectTimeout === undefined) { + delete process.env.RD_CONNECT_TIMEOUT_MS; + } else { + process.env.RD_CONNECT_TIMEOUT_MS = previousConnectTimeout; + } + server.close(); + await once(server, "close"); + } + }, 35000); + + it("recovers when direct download stalls before first response bytes", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-")); + tempDirs.push(root); + const binary = Buffer.alloc(180 * 1024, 12); + const previousStallTimeout = process.env.RD_STALL_TIMEOUT_MS; + const previousConnectTimeout = process.env.RD_CONNECT_TIMEOUT_MS; + process.env.RD_STALL_TIMEOUT_MS = "2500"; + process.env.RD_CONNECT_TIMEOUT_MS = "2000"; + let directCalls = 0; + + const server = http.createServer((req, res) => { + if ((req.url || "") !== "/stall-connect") { + res.statusCode = 404; + res.end("not-found"); + return; + } + + directCalls += 1; + if (directCalls === 1) { + setTimeout(() => { + if (res.writableEnded || res.destroyed || res.headersSent) { + return; + } + res.statusCode = 200; + res.setHeader("Accept-Ranges", "bytes"); + res.setHeader("Content-Length", String(binary.length)); + res.end(binary); + }, 5000); + return; + } + + res.statusCode = 200; + res.setHeader("Accept-Ranges", "bytes"); + res.setHeader("Content-Length", String(binary.length)); + res.end(binary); + }); + + server.listen(0, "127.0.0.1"); + await once(server, "listening"); + + const address = server.address(); + if (!address || typeof address === "string") { + throw new Error("server address unavailable"); + } + const directUrl = `http://127.0.0.1:${address.port}/stall-connect`; + + globalThis.fetch = async (input: RequestInfo | URL, init?: RequestInit): Promise => { + const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url; + if (url.includes("/unrestrict/link")) { + return new Response( + JSON.stringify({ + download: directUrl, + filename: "stall-connect.bin", + filesize: binary.length + }), + { + status: 200, + headers: { "Content-Type": "application/json" } + } + ); + } + return originalFetch(input, init); + }; + + try { + const manager = new DownloadManager( + { + ...defaultSettings(), + token: "rd-token", + outputDir: path.join(root, "downloads"), + extractDir: path.join(root, "extract"), + autoExtract: false, + autoReconnect: false + }, + emptySession(), + createStoragePaths(path.join(root, "state")) + ); + + manager.addPackages([{ name: "stall-connect", links: ["https://dummy/stall-connect"] }]); + await manager.start(); + await waitFor(() => !manager.getSnapshot().session.running, 30000); + + const item = Object.values(manager.getSnapshot().session.items)[0]; + expect(item?.status).toBe("completed"); + expect(directCalls).toBeGreaterThan(1); + } finally { + if (previousStallTimeout === undefined) { + delete process.env.RD_STALL_TIMEOUT_MS; + } else { + process.env.RD_STALL_TIMEOUT_MS = previousStallTimeout; + } + if (previousConnectTimeout === undefined) { + delete process.env.RD_CONNECT_TIMEOUT_MS; + } else { + process.env.RD_CONNECT_TIMEOUT_MS = previousConnectTimeout; + } + server.close(); + await once(server, "close"); + } + }, 35000); + + it("recovers via global watchdog when stream hangs without reader timeout", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-")); + tempDirs.push(root); + const binary = Buffer.alloc(240 * 1024, 31); + const previousStallTimeout = process.env.RD_STALL_TIMEOUT_MS; + const previousConnectTimeout = process.env.RD_CONNECT_TIMEOUT_MS; + const previousGlobalWatchdog = process.env.RD_GLOBAL_STALL_TIMEOUT_MS; + process.env.RD_STALL_TIMEOUT_MS = "120000"; + process.env.RD_CONNECT_TIMEOUT_MS = "120000"; + process.env.RD_GLOBAL_STALL_TIMEOUT_MS = "2500"; + let directCalls = 0; + + const server = http.createServer((req, res) => { + if ((req.url || "") !== "/watchdog-stall") { + res.statusCode = 404; + res.end("not-found"); + return; + } + + directCalls += 1; + if (directCalls === 1) { + const firstChunk = Math.floor(binary.length / 3); + res.statusCode = 200; + res.setHeader("Accept-Ranges", "bytes"); + res.setHeader("Content-Length", String(binary.length)); + res.write(binary.subarray(0, firstChunk)); + return; + } + + res.statusCode = 200; + res.setHeader("Accept-Ranges", "bytes"); + res.setHeader("Content-Length", String(binary.length)); + res.end(binary); + }); + + server.listen(0, "127.0.0.1"); + await once(server, "listening"); + + const address = server.address(); + if (!address || typeof address === "string") { + throw new Error("server address unavailable"); + } + const directUrl = `http://127.0.0.1:${address.port}/watchdog-stall`; + + globalThis.fetch = async (input: RequestInfo | URL, init?: RequestInit): Promise => { + const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url; + if (url.includes("/unrestrict/link")) { + return new Response( + JSON.stringify({ + download: directUrl, + filename: "watchdog-stall.bin", + filesize: binary.length + }), + { + status: 200, + headers: { "Content-Type": "application/json" } + } + ); + } + return originalFetch(input, init); + }; + + try { + const manager = new DownloadManager( + { + ...defaultSettings(), + token: "rd-token", + outputDir: path.join(root, "downloads"), + extractDir: path.join(root, "extract"), + autoExtract: false, + autoReconnect: false + }, + emptySession(), + createStoragePaths(path.join(root, "state")) + ); + + manager.addPackages([{ name: "watchdog-stall", links: ["https://dummy/watchdog-stall"] }]); + await manager.start(); + await waitFor(() => !manager.getSnapshot().session.running, 30000); + + const item = Object.values(manager.getSnapshot().session.items)[0]; + expect(item?.status).toBe("completed"); + expect(directCalls).toBeGreaterThan(1); + } finally { + if (previousStallTimeout === undefined) { + delete process.env.RD_STALL_TIMEOUT_MS; + } else { + process.env.RD_STALL_TIMEOUT_MS = previousStallTimeout; + } + if (previousConnectTimeout === undefined) { + delete process.env.RD_CONNECT_TIMEOUT_MS; + } else { + process.env.RD_CONNECT_TIMEOUT_MS = previousConnectTimeout; + } + if (previousGlobalWatchdog === undefined) { + delete process.env.RD_GLOBAL_STALL_TIMEOUT_MS; + } else { + process.env.RD_GLOBAL_STALL_TIMEOUT_MS = previousGlobalWatchdog; + } + server.close(); + await once(server, "close"); + } + }, 35000); + + it("recovers when write stream backpressure never drains", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-")); + tempDirs.push(root); + const binary = Buffer.alloc(180 * 1024, 19); + const previousStallTimeout = process.env.RD_STALL_TIMEOUT_MS; + process.env.RD_STALL_TIMEOUT_MS = "2200"; + let directCalls = 0; + + const server = http.createServer((req, res) => { + if ((req.url || "") !== "/drain-stall") { + res.statusCode = 404; + res.end("not-found"); + return; + } + directCalls += 1; + res.statusCode = 200; + res.setHeader("Accept-Ranges", "bytes"); + res.setHeader("Content-Length", String(binary.length)); + res.end(binary); + }); + + server.listen(0, "127.0.0.1"); + await once(server, "listening"); + + const address = server.address(); + if (!address || typeof address === "string") { + throw new Error("server address unavailable"); + } + const directUrl = `http://127.0.0.1:${address.port}/drain-stall`; + + globalThis.fetch = async (input: RequestInfo | URL, init?: RequestInit): Promise => { + const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url; + if (url.includes("/unrestrict/link")) { + return new Response( + JSON.stringify({ + download: directUrl, + filename: "drain-stall.bin", + filesize: binary.length + }), + { + status: 200, + headers: { "Content-Type": "application/json" } + } + ); + } + return originalFetch(input, init); + }; + + const originalCreateWriteStream = fs.createWriteStream; + let writeStreamCalls = 0; + const fsMutable = fs as unknown as { createWriteStream: typeof fs.createWriteStream }; + fsMutable.createWriteStream = ((...args: Parameters) => { + writeStreamCalls += 1; + if (writeStreamCalls === 1) { + class HangingWriteStream extends EventEmitter { + public closed = false; + + public destroyed = false; + + public write(): boolean { + return false; + } + + public end(): void { + this.closed = true; + this.emit("close"); + } + } + return new HangingWriteStream() as unknown as ReturnType; + } + return originalCreateWriteStream(...args); + }) as typeof fs.createWriteStream; + + try { + const manager = new DownloadManager( + { + ...defaultSettings(), + token: "rd-token", + outputDir: path.join(root, "downloads"), + extractDir: path.join(root, "extract"), + autoExtract: false, + autoReconnect: false + }, + emptySession(), + createStoragePaths(path.join(root, "state")) + ); + + manager.addPackages([{ name: "drain-stall", links: ["https://dummy/drain-stall"] }]); + manager.start(); + await waitFor(() => !manager.getSnapshot().session.running, 40000); + + const item = Object.values(manager.getSnapshot().session.items)[0]; + expect(item?.status).toBe("completed"); + expect(item?.retries).toBeGreaterThan(0); + expect(directCalls).toBeGreaterThan(1); + expect(fs.existsSync(item.targetPath)).toBe(true); + expect(fs.statSync(item.targetPath).size).toBe(binary.length); + } finally { + fsMutable.createWriteStream = originalCreateWriteStream; + if (previousStallTimeout === undefined) { + delete process.env.RD_STALL_TIMEOUT_MS; + } else { + process.env.RD_STALL_TIMEOUT_MS = previousStallTimeout; + } + server.close(); + await once(server, "close"); + } + }, 45000); + + it("uses content-disposition filename when provider filename is opaque", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-")); + tempDirs.push(root); + const binary = Buffer.alloc(96 * 1024, 13); + const expectedName = "Banshee.S04E01.German.DL.720p.part01.rar"; + + const server = http.createServer((req, res) => { + if ((req.url || "") !== "/content-name") { + res.statusCode = 404; + res.end("not-found"); + return; + } + res.statusCode = 200; + res.setHeader("Accept-Ranges", "bytes"); + res.setHeader("Content-Length", String(binary.length)); + res.setHeader("Content-Disposition", `attachment; filename="${expectedName}"`); + res.end(binary); + }); + + server.listen(0, "127.0.0.1"); + await once(server, "listening"); + + const address = server.address(); + if (!address || typeof address === "string") { + throw new Error("server address unavailable"); + } + const directUrl = `http://127.0.0.1:${address.port}/content-name`; + + globalThis.fetch = async (input: RequestInfo | URL, init?: RequestInit): Promise => { + const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url; + if (url.includes("/unrestrict/link")) { + return new Response( + JSON.stringify({ + download: directUrl, + filename: "6f09df2984fe01378537c7cd8d7fa7ce", + filesize: binary.length + }), + { + status: 200, + headers: { "Content-Type": "application/json" } + } + ); + } + return originalFetch(input, init); + }; + + try { + const manager = new DownloadManager( + { + ...defaultSettings(), + token: "rd-token", + outputDir: path.join(root, "downloads"), + extractDir: path.join(root, "extract"), + retryLimit: 1, + autoExtract: false + }, + emptySession(), + createStoragePaths(path.join(root, "state")) + ); + + manager.addPackages([{ name: "content-name", links: ["https://rapidgator.net/file/6f09df2984fe01378537c7cd8d7fa7ce"] }]); + await manager.start(); + await waitFor(() => !manager.getSnapshot().session.running, 25000); + + const item = Object.values(manager.getSnapshot().session.items)[0]; + expect(item?.status).toBe("completed"); + expect(item?.fileName).toBe(expectedName); + expect(path.basename(item?.targetPath || "")).toBe(expectedName); + } finally { + server.close(); + await once(server, "close"); + } + }, 15000); + + it("reuses stored partial target path when queued item resumes", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-")); + tempDirs.push(root); + const binary = Buffer.alloc(256 * 1024, 7); + const partialSize = 64 * 1024; + const pkgDir = path.join(root, "downloads", "resume"); + fs.mkdirSync(pkgDir, { recursive: true }); + const existingTargetPath = path.join(pkgDir, "resume.mkv"); + fs.writeFileSync(existingTargetPath, binary.subarray(0, partialSize)); + let sawResumeRange = false; + + const server = http.createServer((req, res) => { + if ((req.url || "") !== "/resume") { + res.statusCode = 404; + res.end("not-found"); + return; + } + + const range = String(req.headers.range || ""); + const match = range.match(/bytes=(\d+)-/i); + const start = match ? Number(match[1]) : 0; + if (start === partialSize) { + sawResumeRange = true; + } + const chunk = binary.subarray(start); + + if (start > 0) { + res.statusCode = 206; + res.setHeader("Content-Range", `bytes ${start}-${binary.length - 1}/${binary.length}`); + } else { + res.statusCode = 200; + } + res.setHeader("Accept-Ranges", "bytes"); + res.setHeader("Content-Length", String(chunk.length)); + res.end(chunk); + }); + + server.listen(0, "127.0.0.1"); + await once(server, "listening"); + + const address = server.address(); + if (!address || typeof address === "string") { + throw new Error("server address unavailable"); + } + const directUrl = `http://127.0.0.1:${address.port}/resume`; + + globalThis.fetch = async (input: RequestInfo | URL, init?: RequestInit): Promise => { + const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url; + if (url.includes("/unrestrict/link")) { + return new Response( + JSON.stringify({ + download: directUrl, + filename: "resume.mkv", + filesize: binary.length + }), + { + status: 200, + headers: { "Content-Type": "application/json" } + } + ); + } + return originalFetch(input, init); + }; + + try { + const session = emptySession(); + const packageId = "resume-pkg"; + const itemId = "resume-item"; + const createdAt = Date.now() - 10_000; + + session.packageOrder = [packageId]; + session.packages[packageId] = { + id: packageId, + name: "resume", + outputDir: pkgDir, + extractDir: path.join(root, "extract", "resume"), + status: "queued", + itemIds: [itemId], + cancelled: false, + enabled: true, + createdAt, + updatedAt: createdAt + }; + session.items[itemId] = { + id: itemId, + packageId, + url: "https://dummy/resume", + provider: null, + status: "queued", + retries: 0, + speedBps: 0, + downloadedBytes: partialSize, + totalBytes: binary.length, + progressPercent: Math.floor((partialSize / binary.length) * 100), + fileName: "resume.mkv", + targetPath: existingTargetPath, + resumable: true, + attempts: 0, + lastError: "", + fullStatus: "Wartet", + createdAt, + updatedAt: createdAt + }; + + const manager = new DownloadManager( + { + ...defaultSettings(), + token: "rd-token", + outputDir: path.join(root, "downloads"), + extractDir: path.join(root, "extract"), + retryLimit: 1, + autoExtract: false + }, + session, + createStoragePaths(path.join(root, "state")) + ); + + await manager.start(); + await waitFor(() => !manager.getSnapshot().session.running, 25000); + + const item = manager.getSnapshot().session.items[itemId]; + expect(item?.status).toBe("completed"); + expect(item?.targetPath).toBe(existingTargetPath); + expect(sawResumeRange).toBe(true); + const fileSize = fs.statSync(existingTargetPath).size; + expect(fileSize).toBeGreaterThanOrEqual(binary.length - 4096); + expect(fileSize).toBeLessThanOrEqual(binary.length); + } finally { + server.close(); + await once(server, "close"); + } + }); + + it("treats HTTP 416 on full range as completed resume", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-")); + tempDirs.push(root); + const binary = Buffer.alloc(128 * 1024, 2); + const pkgDir = path.join(root, "downloads", "range-complete"); + fs.mkdirSync(pkgDir, { recursive: true }); + const existingTargetPath = path.join(pkgDir, "complete.mkv"); + fs.writeFileSync(existingTargetPath, binary); + let saw416 = false; + + const server = http.createServer((req, res) => { + if ((req.url || "") !== "/complete") { + res.statusCode = 404; + res.end("not-found"); + return; + } + const range = String(req.headers.range || ""); + const match = range.match(/bytes=(\d+)-/i); + const start = match ? Number(match[1]) : 0; + if (start >= binary.length) { + saw416 = true; + res.statusCode = 416; + res.setHeader("Content-Range", `bytes */${binary.length}`); + res.end(""); + return; + } + + const chunk = binary.subarray(start); + if (start > 0) { + res.statusCode = 206; + res.setHeader("Content-Range", `bytes ${start}-${binary.length - 1}/${binary.length}`); + } else { + res.statusCode = 200; + } + res.setHeader("Accept-Ranges", "bytes"); + res.setHeader("Content-Length", String(chunk.length)); + res.end(chunk); + }); + + server.listen(0, "127.0.0.1"); + await once(server, "listening"); + + const address = server.address(); + if (!address || typeof address === "string") { + throw new Error("server address unavailable"); + } + const directUrl = `http://127.0.0.1:${address.port}/complete`; + + globalThis.fetch = async (input: RequestInfo | URL, init?: RequestInit): Promise => { + const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url; + if (url.includes("/unrestrict/link")) { + return new Response( + JSON.stringify({ + download: directUrl, + filename: "complete.mkv", + filesize: binary.length + }), + { + status: 200, + headers: { "Content-Type": "application/json" } + } + ); + } + return originalFetch(input, init); + }; + + try { + const session = emptySession(); + const packageId = "complete-pkg"; + const itemId = "complete-item"; + const createdAt = Date.now() - 10_000; + + session.packageOrder = [packageId]; + session.packages[packageId] = { + id: packageId, + name: "range-complete", + outputDir: pkgDir, + extractDir: path.join(root, "extract", "range-complete"), + status: "queued", + itemIds: [itemId], + cancelled: false, + enabled: true, + createdAt, + updatedAt: createdAt + }; + session.items[itemId] = { + id: itemId, + packageId, + url: "https://dummy/complete", + provider: null, + status: "queued", + retries: 0, + speedBps: 0, + downloadedBytes: binary.length, + totalBytes: binary.length, + progressPercent: 100, + fileName: "complete.mkv", + targetPath: existingTargetPath, + resumable: true, + attempts: 0, + lastError: "", + fullStatus: "Wartet", + createdAt, + updatedAt: createdAt + }; + + const manager = new DownloadManager( + { + ...defaultSettings(), + token: "rd-token", + outputDir: path.join(root, "downloads"), + extractDir: path.join(root, "extract"), + retryLimit: 1, + autoExtract: false + }, + session, + createStoragePaths(path.join(root, "state")) + ); + + await manager.start(); + await waitFor(() => !manager.getSnapshot().session.running, 25000); + + const item = manager.getSnapshot().session.items[itemId]; + expect(saw416).toBe(true); + expect(item?.status).toBe("completed"); + expect(item?.targetPath).toBe(existingTargetPath); + expect(item?.downloadedBytes).toBe(binary.length); + expect(fs.statSync(existingTargetPath).size).toBe(binary.length); + } finally { + server.close(); + await once(server, "close"); + } + }); + + it("refreshes Debrid-Link API direct links immediately after HTTP 416 instead of retrying the same link", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-")); + tempDirs.push(root); + const binary = Buffer.alloc(256 * 1024, 29); + const pkgDir = path.join(root, "downloads", "debridlink-range-reset"); + fs.mkdirSync(pkgDir, { recursive: true }); + const existingTargetPath = path.join(pkgDir, "debridlink-range-reset.mkv"); + const partialSize = 96 * 1024; + fs.writeFileSync(existingTargetPath, binary.subarray(0, partialSize)); + + let unrestrictCalls = 0; + let badCalls = 0; + let goodCalls = 0; + + const server = http.createServer((req, res) => { + const route = req.url || ""; + const range = String(req.headers.range || ""); + const match = range.match(/bytes=(\d+)-/i); + const start = match ? Number(match[1]) : 0; + + if (route === "/bad-416") { + badCalls += 1; + res.statusCode = 416; + res.setHeader("Content-Range", `bytes */${partialSize - 1024}`); + res.end(""); + return; + } + + if (route === "/good") { + goodCalls += 1; + const chunk = binary.subarray(start); + if (start > 0) { + res.statusCode = 206; + res.setHeader("Content-Range", `bytes ${start}-${binary.length - 1}/${binary.length}`); + } else { + res.statusCode = 200; + } + res.setHeader("Accept-Ranges", "bytes"); + res.setHeader("Content-Length", String(chunk.length)); + res.end(chunk); + return; + } + + res.statusCode = 404; + res.end("not-found"); + }); + + server.listen(0, "127.0.0.1"); + await once(server, "listening"); + + const address = server.address(); + if (!address || typeof address === "string") { + throw new Error("server address unavailable"); + } + const badUrl = `http://127.0.0.1:${address.port}/bad-416`; + const goodUrl = `http://127.0.0.1:${address.port}/good`; + + globalThis.fetch = async (input: RequestInfo | URL, init?: RequestInit): Promise => { + const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url; + if (url.includes("debrid-link.com/api/v2/downloader/add")) { + unrestrictCalls += 1; + return new Response( + JSON.stringify({ + success: true, + value: { + downloadUrl: unrestrictCalls === 1 ? badUrl : goodUrl, + name: "debridlink-range-reset.mkv", + size: binary.length + } + }), + { + status: 200, + headers: { "Content-Type": "application/json" } + } + ); + } + return originalFetch(input, init); + }; + + try { + const session = emptySession(); + const packageId = "debridlink-range-reset-pkg"; + const itemId = "debridlink-range-reset-item"; + const createdAt = Date.now() - 10_000; + + session.packageOrder = [packageId]; + session.packages[packageId] = { + id: packageId, + name: "debridlink-range-reset", + outputDir: pkgDir, + extractDir: path.join(root, "extract", "debridlink-range-reset"), + status: "queued", + itemIds: [itemId], + cancelled: false, + enabled: true, + createdAt, + updatedAt: createdAt + }; + session.items[itemId] = { + id: itemId, + packageId, + url: "https://dummy/debridlink-range-reset", + provider: "debridlink", + status: "queued", + retries: 0, + speedBps: 0, + downloadedBytes: partialSize, + totalBytes: binary.length, + progressPercent: Math.floor((partialSize / binary.length) * 100), + fileName: "debridlink-range-reset.mkv", + targetPath: existingTargetPath, + resumable: true, + attempts: 0, + lastError: "", + fullStatus: "Wartet", + createdAt, + updatedAt: createdAt + }; + + const manager = new DownloadManager( + { + ...defaultSettings(), + debridLinkApiKeys: "dl-test-key", + providerOrder: ["debridlink"], + providerPrimary: "debridlink", + providerSecondary: "none", + providerTertiary: "none", + outputDir: path.join(root, "downloads"), + extractDir: path.join(root, "extract"), + retryLimit: 2, + autoExtract: false + }, + session, + createStoragePaths(path.join(root, "state")) + ); + + await manager.start(); + await waitFor(() => !manager.getSnapshot().session.running, 25000); + + const item = manager.getSnapshot().session.items[itemId]; + expect(item?.status).toBe("completed"); + expect(item?.provider).toBe("debridlink"); + expect(item?.downloadedBytes).toBeGreaterThanOrEqual(partialSize); + expect(item?.downloadedBytes).toBeLessThanOrEqual(binary.length); + expect(unrestrictCalls).toBeGreaterThanOrEqual(1); + expect(badCalls).toBeGreaterThanOrEqual(1); + const fileSize = fs.statSync(existingTargetPath).size; + expect(fileSize).toBeGreaterThanOrEqual(partialSize); + expect(fileSize).toBeLessThanOrEqual(binary.length); + } finally { + server.close(); + await once(server, "close"); + } + }); + + it("recovers from HTTP 416 by restarting download from zero", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-")); + tempDirs.push(root); + const binary = Buffer.alloc(96 * 1024, 3); + const pkgDir = path.join(root, "downloads", "range-reset"); + fs.mkdirSync(pkgDir, { recursive: true }); + const existingTargetPath = path.join(pkgDir, "reset.mkv"); + const partialSize = 64 * 1024; + fs.writeFileSync(existingTargetPath, binary.subarray(0, partialSize)); + + let saw416 = false; + let fullRestarted = false; + let requestCount = 0; + + const server = http.createServer((req, res) => { + if ((req.url || "") !== "/range-reset") { + res.statusCode = 404; + res.end("not-found"); + return; + } + + requestCount += 1; + const range = String(req.headers.range || ""); + const match = range.match(/bytes=(\d+)-/i); + const start = match ? Number(match[1]) : 0; + + if (requestCount === 1 && start === partialSize) { + saw416 = true; + res.statusCode = 416; + res.setHeader("Content-Range", "bytes */32768"); + res.end(""); + return; + } + + if (start === 0) { + fullRestarted = true; + } + const chunk = binary.subarray(start); + if (start > 0) { + res.statusCode = 206; + res.setHeader("Content-Range", `bytes ${start}-${binary.length - 1}/${binary.length}`); + } else { + res.statusCode = 200; + } + res.setHeader("Accept-Ranges", "bytes"); + res.setHeader("Content-Length", String(chunk.length)); + res.end(chunk); + }); + + server.listen(0, "127.0.0.1"); + await once(server, "listening"); + + const address = server.address(); + if (!address || typeof address === "string") { + throw new Error("server address unavailable"); + } + const directUrl = `http://127.0.0.1:${address.port}/range-reset`; + + globalThis.fetch = async (input: RequestInfo | URL, init?: RequestInit): Promise => { + const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url; + if (url.includes("/unrestrict/link")) { + return new Response( + JSON.stringify({ + download: directUrl, + filename: "reset.mkv", + filesize: binary.length + }), + { + status: 200, + headers: { "Content-Type": "application/json" } + } + ); + } + return originalFetch(input, init); + }; + + try { + const session = emptySession(); + const packageId = "range-reset-pkg"; + const itemId = "range-reset-item"; + const createdAt = Date.now() - 10_000; + + session.packageOrder = [packageId]; + session.packages[packageId] = { + id: packageId, + name: "range-reset", + outputDir: pkgDir, + extractDir: path.join(root, "extract", "range-reset"), + status: "queued", + itemIds: [itemId], + cancelled: false, + enabled: true, + createdAt, + updatedAt: createdAt + }; + session.items[itemId] = { + id: itemId, + packageId, + url: "https://dummy/range-reset", + provider: null, + status: "queued", + retries: 0, + speedBps: 0, + downloadedBytes: partialSize, + totalBytes: binary.length, + progressPercent: Math.floor((partialSize / binary.length) * 100), + fileName: "reset.mkv", + targetPath: existingTargetPath, + resumable: true, + attempts: 0, + lastError: "", + fullStatus: "Wartet", + createdAt, + updatedAt: createdAt + }; + + const manager = new DownloadManager( + { + ...defaultSettings(), + token: "rd-token", + outputDir: path.join(root, "downloads"), + extractDir: path.join(root, "extract"), + retryLimit: 1, + autoExtract: false + }, + session, + createStoragePaths(path.join(root, "state")) + ); + + await manager.start(); + await waitFor(() => !manager.getSnapshot().session.running, 25000); + + const item = manager.getSnapshot().session.items[itemId]; + expect(item?.status).toBe("completed"); + expect(saw416).toBe(true); + expect(fullRestarted).toBe(true); + expect(fs.statSync(existingTargetPath).size).toBe(binary.length); + } finally { + server.close(); + await once(server, "close"); + } + }); + + it("counts retries and resets stale 100% progress on persistent HTTP 416", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-")); + tempDirs.push(root); + const staleBinary = Buffer.alloc(64 * 1024, 9); + const pkgDir = path.join(root, "downloads", "range-416-fail"); + fs.mkdirSync(pkgDir, { recursive: true }); + const existingTargetPath = path.join(pkgDir, "broken.part3.rar"); + fs.writeFileSync(existingTargetPath, staleBinary); + let directCalls = 0; + + const server = http.createServer((req, res) => { + if ((req.url || "") !== "/range-416-fail") { + res.statusCode = 404; + res.end("not-found"); + return; + } + directCalls += 1; + res.statusCode = 416; + res.setHeader("Content-Range", "bytes */32768"); + res.end(""); + }); + + server.listen(0, "127.0.0.1"); + await once(server, "listening"); + + const address = server.address(); + if (!address || typeof address === "string") { + throw new Error("server address unavailable"); + } + const directUrl = `http://127.0.0.1:${address.port}/range-416-fail`; + + globalThis.fetch = async (input: RequestInfo | URL, init?: RequestInit): Promise => { + const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url; + if (url.includes("/unrestrict/link")) { + return new Response( + JSON.stringify({ + download: directUrl, + filename: "broken.part3.rar", + filesize: 32768 + }), + { + status: 200, + headers: { "Content-Type": "application/json" } + } + ); + } + return originalFetch(input, init); + }; + + try { + const session = emptySession(); + const packageId = "range-416-fail-pkg"; + const itemId = "range-416-fail-item"; + const createdAt = Date.now() - 10_000; + + session.packageOrder = [packageId]; + session.packages[packageId] = { + id: packageId, + name: "range-416-fail", + outputDir: pkgDir, + extractDir: path.join(root, "extract", "range-416-fail"), + status: "queued", + itemIds: [itemId], + cancelled: false, + enabled: true, + createdAt, + updatedAt: createdAt + }; + session.items[itemId] = { + id: itemId, + packageId, + url: "https://dummy/range-416-fail", + provider: null, + status: "queued", + retries: 0, + speedBps: 0, + downloadedBytes: staleBinary.length, + totalBytes: staleBinary.length, + progressPercent: 100, + fileName: "broken.part3.rar", + targetPath: existingTargetPath, + resumable: true, + attempts: 0, + lastError: "", + fullStatus: "Wartet", + createdAt, + updatedAt: createdAt + }; + + const manager = new DownloadManager( + { + ...defaultSettings(), + token: "rd-token", + outputDir: path.join(root, "downloads"), + extractDir: path.join(root, "extract"), + autoExtract: false + }, + session, + createStoragePaths(path.join(root, "state")) + ); + + await manager.start(); + await waitFor(() => !manager.getSnapshot().session.running, 45000); + + const item = manager.getSnapshot().session.items[itemId]; + expect(item?.status).toBe("failed"); + expect(item?.retries).toBeGreaterThan(0); + expect(item?.progressPercent).toBe(0); + expect(item?.downloadedBytes).toBe(0); + expect(item?.lastError).toContain("416"); + expect(directCalls).toBeGreaterThanOrEqual(3); + } finally { + server.close(); + await once(server, "close"); + } + }, 70000); + + it("retries non-retriable HTTP statuses and eventually succeeds", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-")); + tempDirs.push(root); + const binary = Buffer.alloc(96 * 1024, 5); + let directCalls = 0; + + const server = http.createServer((req, res) => { + if ((req.url || "") !== "/status-retry") { + res.statusCode = 404; + res.end("not-found"); + return; + } + directCalls += 1; + if (directCalls <= 2) { + res.statusCode = 403; + res.end("forbidden"); + return; + } + res.statusCode = 200; + res.setHeader("Accept-Ranges", "bytes"); + res.setHeader("Content-Length", String(binary.length)); + res.end(binary); + }); + + server.listen(0, "127.0.0.1"); + await once(server, "listening"); + + const address = server.address(); + if (!address || typeof address === "string") { + throw new Error("server address unavailable"); + } + const directUrl = `http://127.0.0.1:${address.port}/status-retry`; + + globalThis.fetch = async (input: RequestInfo | URL, init?: RequestInit): Promise => { + const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url; + if (url.includes("/unrestrict/link")) { + return new Response( + JSON.stringify({ + download: directUrl, + filename: "status-retry.mkv", + filesize: binary.length + }), + { + status: 200, + headers: { "Content-Type": "application/json" } + } + ); + } + return originalFetch(input, init); + }; + + try { + const manager = new DownloadManager( + { + ...defaultSettings(), + token: "rd-token", + outputDir: path.join(root, "downloads"), + extractDir: path.join(root, "extract"), + autoExtract: false + }, + emptySession(), + createStoragePaths(path.join(root, "state")) + ); + + manager.addPackages([{ name: "status-retry", links: ["https://dummy/status-retry"] }]); + await manager.start(); + await waitFor(() => !manager.getSnapshot().session.running, 30000); + + const item = Object.values(manager.getSnapshot().session.items)[0]; + expect(item?.status).toBe("completed"); + expect(directCalls).toBeGreaterThanOrEqual(3); + } finally { + server.close(); + await once(server, "close"); + } + }); + + it("normalizes stale running state on startup", () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-")); + tempDirs.push(root); + + const session = emptySession(); + session.running = true; + session.paused = true; + session.reconnectUntil = Date.now() + 30_000; + session.reconnectReason = "HTTP 429"; + const packageId = "stale-pkg"; + const itemId = "stale-item"; + const createdAt = Date.now() - 20_000; + session.packageOrder = [packageId]; + session.packages[packageId] = { + id: packageId, + name: "stale", + outputDir: path.join(root, "downloads", "stale"), + extractDir: path.join(root, "extract", "stale"), + status: "reconnect_wait", + itemIds: [itemId], + cancelled: false, + enabled: true, + createdAt, + updatedAt: createdAt + }; + session.items[itemId] = { + id: itemId, + packageId, + url: "https://dummy/stale", + provider: "realdebrid", + status: "paused", + retries: 0, + speedBps: 100, + downloadedBytes: 123, + totalBytes: 456, + progressPercent: 26, + fileName: "stale.mkv", + targetPath: path.join(root, "downloads", "stale", "stale.mkv"), + resumable: true, + attempts: 1, + lastError: "", + fullStatus: "Pausiert", + createdAt, + updatedAt: createdAt + }; + + const manager = new DownloadManager( + { + ...defaultSettings(), + token: "rd-token", + outputDir: path.join(root, "downloads"), + extractDir: path.join(root, "extract"), + autoExtract: false + }, + session, + createStoragePaths(path.join(root, "state")) + ); + + const snapshot = manager.getSnapshot(); + expect(snapshot.session.running).toBe(false); + expect(snapshot.session.paused).toBe(false); + expect(snapshot.session.reconnectUntil).toBe(0); + expect(snapshot.session.reconnectReason).toBe(""); + expect(snapshot.session.items[itemId]?.status).toBe("queued"); + expect(snapshot.session.items[itemId]?.speedBps).toBe(0); + expect(snapshot.session.packages[packageId]?.status).toBe("queued"); + expect(snapshot.canStart).toBe(true); + }); + + it("requeues failed HTTP 416 items automatically on startup", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-")); + tempDirs.push(root); + + const session = emptySession(); + const packageId = "retry-416-pkg"; + const itemId = "retry-416-item"; + const createdAt = Date.now() - 20_000; + const outputDir = path.join(root, "downloads", "retry-416"); + const targetPath = path.join(outputDir, "broken.part03.rar"); + fs.mkdirSync(outputDir, { recursive: true }); + fs.writeFileSync(targetPath, Buffer.alloc(12 * 1024, 1)); + + session.packageOrder = [packageId]; + session.packages[packageId] = { + id: packageId, + name: "retry-416", + outputDir, + extractDir: path.join(root, "extract", "retry-416"), + status: "failed", + itemIds: [itemId], + cancelled: false, + enabled: true, + createdAt, + updatedAt: createdAt + }; + session.items[itemId] = { + id: itemId, + packageId, + url: "https://dummy/retry-416", + provider: "megadebrid", + status: "failed", + retries: 4, + speedBps: 0, + downloadedBytes: 12 * 1024, + totalBytes: 8 * 1024, + progressPercent: 100, + fileName: "broken.part03.rar", + targetPath, + resumable: true, + attempts: 3, + lastError: "Error: HTTP 416", + fullStatus: "Fehler: Error: HTTP 416", + createdAt, + updatedAt: createdAt + }; + + const manager = new DownloadManager( + { + ...defaultSettings(), + token: "rd-token", + outputDir: path.join(root, "downloads"), + extractDir: path.join(root, "extract"), + autoExtract: false + }, + session, + createStoragePaths(path.join(root, "state")) + ); + + await waitFor(() => manager.getSnapshot().session.items[itemId]?.status === "queued", 12000); + + const snapshot = manager.getSnapshot(); + const item = snapshot.session.items[itemId]; + expect(item?.status).toBe("queued"); + expect(item?.attempts).toBe(0); + expect(item?.downloadedBytes).toBe(0); + expect(item?.progressPercent).toBe(0); + expect(item?.fullStatus).toContain("Auto-Retry"); + expect(snapshot.session.packages[packageId]?.status).toBe("queued"); + expect(fs.existsSync(targetPath)).toBe(false); + }); + + it("requeues completed zero-byte archive items automatically on startup", () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-")); + tempDirs.push(root); + + const session = emptySession(); + const packageId = "zero-byte-pkg"; + const itemId = "zero-byte-item"; + const createdAt = Date.now() - 20_000; + const outputDir = path.join(root, "downloads", "zero-byte"); + const targetPath = path.join(outputDir, "archive.part01.rar"); + fs.mkdirSync(outputDir, { recursive: true }); + fs.writeFileSync(targetPath, Buffer.alloc(0)); + + session.packageOrder = [packageId]; + session.packages[packageId] = { + id: packageId, + name: "zero-byte", + outputDir, + extractDir: path.join(root, "extract", "zero-byte"), + status: "completed", + itemIds: [itemId], + cancelled: false, + enabled: true, + createdAt, + updatedAt: createdAt + }; + session.items[itemId] = { + id: itemId, + packageId, + url: "https://dummy/zero-byte", + provider: "megadebrid", + status: "completed", + retries: 0, + speedBps: 0, + downloadedBytes: 0, + totalBytes: null, + progressPercent: 100, + fileName: "archive.part01.rar", + targetPath, + resumable: true, + attempts: 1, + lastError: "", + fullStatus: "Fertig (0 B)", + createdAt, + updatedAt: createdAt + }; + + const manager = new DownloadManager( + { + ...defaultSettings(), + token: "rd-token", + outputDir: path.join(root, "downloads"), + extractDir: path.join(root, "extract"), + autoExtract: false + }, + session, + createStoragePaths(path.join(root, "state")) + ); + + const snapshot = manager.getSnapshot(); + const item = snapshot.session.items[itemId]; + expect(item?.status).toBe("queued"); + expect(item?.downloadedBytes).toBe(0); + expect(item?.progressPercent).toBe(0); + expect(item?.fullStatus).toContain("0B-Datei"); + expect(snapshot.session.packages[packageId]?.status).toBe("queued"); + expect(fs.existsSync(targetPath)).toBe(false); + }); + + it("requeues preallocated completed archive items automatically on startup", () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-")); + tempDirs.push(root); + + const session = emptySession(); + const packageId = "prealloc-pkg"; + const itemId = "prealloc-item"; + const createdAt = Date.now() - 20_000; + const outputDir = path.join(root, "downloads", "prealloc"); + const targetPath = path.join(outputDir, "archive.part01.rar"); + fs.mkdirSync(outputDir, { recursive: true }); + fs.writeFileSync(targetPath, Buffer.alloc(8192)); + + session.packageOrder = [packageId]; + session.packages[packageId] = { + id: packageId, + name: "prealloc", + outputDir, + extractDir: path.join(root, "extract", "prealloc"), + status: "completed", + itemIds: [itemId], + cancelled: false, + enabled: true, + createdAt, + updatedAt: createdAt + }; + session.items[itemId] = { + id: itemId, + packageId, + url: "https://dummy/prealloc", + provider: "megadebrid", + status: "completed", + retries: 0, + speedBps: 0, + downloadedBytes: 1024, + totalBytes: 8192, + progressPercent: 100, + fileName: "archive.part01.rar", + targetPath, + resumable: true, + attempts: 1, + lastError: "", + fullStatus: "Fertig (8 KB)", + createdAt, + updatedAt: createdAt + }; + + const manager = new DownloadManager( + { + ...defaultSettings(), + token: "rd-token", + outputDir: path.join(root, "downloads"), + extractDir: path.join(root, "extract"), + autoExtract: false + }, + session, + createStoragePaths(path.join(root, "state")) + ); + + const snapshot = manager.getSnapshot(); + const item = snapshot.session.items[itemId]; + expect(item?.status).toBe("queued"); + expect(item?.fullStatus).toContain("pre-alloc"); + expect(snapshot.session.packages[packageId]?.status).toBe("queued"); + }); + + it("does not recover queued pre-allocated archive leftovers as completed during post-processing", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-")); + tempDirs.push(root); + + const session = emptySession(); + const packageId = "postproc-prealloc-pkg"; + const itemId = "postproc-prealloc-item"; + const createdAt = Date.now() - 20_000; + const outputDir = path.join(root, "downloads", "postproc-prealloc"); + const targetPath = path.join(outputDir, "postproc-prealloc.part01.rar"); + const totalBytes = 2 * 1024 * 1024; + fs.mkdirSync(outputDir, { recursive: true }); + fs.writeFileSync(targetPath, Buffer.alloc(totalBytes, 0)); + + session.packageOrder = [packageId]; + session.packages[packageId] = { + id: packageId, + name: "postproc-prealloc", + outputDir, + extractDir: path.join(root, "extract", "postproc-prealloc"), + status: "queued", + itemIds: [itemId], + cancelled: false, + enabled: true, + createdAt, + updatedAt: createdAt + }; + session.items[itemId] = { + id: itemId, + packageId, + url: "https://dummy/postproc-prealloc", + provider: "realdebrid", + status: "queued", + retries: 0, + speedBps: 0, + downloadedBytes: 0, + totalBytes, + progressPercent: 0, + fileName: "postproc-prealloc.part01.rar", + targetPath, + resumable: true, + attempts: 1, + lastError: "", + fullStatus: "Wartet", + createdAt, + updatedAt: createdAt + }; + + const manager = new DownloadManager( + { + ...defaultSettings(), + token: "rd-token", + outputDir: path.join(root, "downloads"), + extractDir: path.join(root, "extract"), + autoExtract: false + }, + session, + createStoragePaths(path.join(root, "state")) + ); + + await (manager as any).handlePackagePostProcessing(packageId); + const snapshot = manager.getSnapshot(); + const item = snapshot.session.items[itemId]; + expect(item?.status).toBe("queued"); + expect(item?.fullStatus).toContain("pre-alloc"); + expect(item?.downloadedBytes).toBe(0); + expect(item?.progressPercent).toBe(0); + expect(fs.existsSync(targetPath)).toBe(false); + }); + + it("requeues near-total non-binary pre-allocated leftovers during post-processing", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-")); + tempDirs.push(root); + + const session = emptySession(); + const packageId = "postproc-near-total-prealloc-pkg"; + const itemId = "postproc-near-total-prealloc-item"; + const createdAt = Date.now() - 20_000; + const outputDir = path.join(root, "downloads", "postproc-near-total-prealloc"); + const targetPath = path.join(outputDir, "postproc-near-total-prealloc.data"); + const totalBytes = 2 * 1024 * 1024; + fs.mkdirSync(outputDir, { recursive: true }); + fs.writeFileSync(targetPath, Buffer.alloc(totalBytes - 1, 0)); + + session.packageOrder = [packageId]; + session.packages[packageId] = { + id: packageId, + name: "postproc-near-total-prealloc", + outputDir, + extractDir: path.join(root, "extract", "postproc-near-total-prealloc"), + status: "queued", + itemIds: [itemId], + cancelled: false, + enabled: true, + createdAt, + updatedAt: createdAt + }; + session.items[itemId] = { + id: itemId, + packageId, + url: "https://dummy/postproc-near-total-prealloc", + provider: "realdebrid", + status: "queued", + retries: 0, + speedBps: 0, + downloadedBytes: 0, + totalBytes, + progressPercent: 0, + fileName: "postproc-near-total-prealloc.data", + targetPath, + resumable: true, + attempts: 1, + lastError: "", + fullStatus: "Wartet", + createdAt, + updatedAt: createdAt + }; + + const manager = new DownloadManager( + { + ...defaultSettings(), + token: "rd-token", + outputDir: path.join(root, "downloads"), + extractDir: path.join(root, "extract"), + autoExtract: false + }, + session, + createStoragePaths(path.join(root, "state")) + ); + + await (manager as any).handlePackagePostProcessing(packageId); + const snapshot = manager.getSnapshot(); + const item = snapshot.session.items[itemId]; + expect(item?.status).toBe("queued"); + expect(item?.fullStatus).toContain("pre-alloc"); + expect(item?.downloadedBytes).toBe(0); + expect(item?.progressPercent).toBe(0); + expect(fs.existsSync(targetPath)).toBe(false); + }); + + it("requeues completed archive parts after auto-recovery extraction failures", () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-")); + tempDirs.push(root); + + const session = emptySession(); + const packageId = "crc-pkg"; + const createdAt = Date.now() - 10_000; + const outputDir = path.join(root, "downloads", "crc"); + const extractDir = path.join(root, "extract", "crc"); + fs.mkdirSync(outputDir, { recursive: true }); + + const archiveNames = ["show.s01e01.part1.rar", "show.s01e01.part2.rar"]; + const itemIds = archiveNames.map((_, index) => `crc-item-${index}`); + + session.packageOrder = [packageId]; + session.packages[packageId] = { + id: packageId, + name: "crc", + outputDir, + extractDir, + status: "extracting", + itemIds, + cancelled: false, + enabled: true, + createdAt, + updatedAt: createdAt + }; + + for (const [index, archiveName] of archiveNames.entries()) { + const targetPath = path.join(outputDir, archiveName); + const partBytes = Buffer.alloc(4096, 0x41 + index); + fs.writeFileSync(targetPath, partBytes); + session.items[itemIds[index]!] = { + id: itemIds[index]!, + packageId, + url: `https://dummy/${archiveName}`, + provider: "realdebrid", + status: "completed", + retries: 0, + speedBps: 0, + downloadedBytes: partBytes.length, + totalBytes: partBytes.length, + progressPercent: 100, + fileName: archiveName, + targetPath, + resumable: true, + attempts: 1, + lastError: "", + fullStatus: "Entpacken - Ausstehend", + createdAt, + updatedAt: createdAt + }; + } + + const manager = new DownloadManager( + { + ...defaultSettings(), + token: "rd-token", + outputDir: path.join(root, "downloads"), + extractDir: path.join(root, "extract"), + autoExtract: true + }, + session, + createStoragePaths(path.join(root, "state")) + ); + + const changed = (manager as any).autoRecoverArchiveCrcFailure( + session.packages[packageId], + itemIds.map((itemId) => session.items[itemId]!), + { + archiveName: "show.s01e01.part1.rar", + errorText: "Checksum error in the encrypted file", + category: "crc_error", + suggestRedownload: true, + jvmFailureReason: "Can not open the file as archive" + }, + "hybrid" + ); + + expect(changed).toBe(2); + for (const itemId of itemIds) { + const item = session.items[itemId]!; + expect(item.status).toBe("queued"); + expect(item.targetPath).toBe(""); + expect(item.downloadedBytes).toBe(0); + expect(item.attempts).toBe(0); + expect(item.fullStatus).toContain("Auto-Recovery"); + } + expect(fs.existsSync(path.join(outputDir, archiveNames[0]!))).toBe(false); + expect(fs.existsSync(path.join(outputDir, archiveNames[1]!))).toBe(false); + expect(session.packages[packageId]?.status).toBe("queued"); + }); + + it("requeues archive parts on CRC error when file has invalid archive signature (corrupt content)", () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-")); + tempDirs.push(root); + + const session = emptySession(); + const packageId = "crc-corrupt-sig-pkg"; + const createdAt = Date.now() - 10_000; + const outputDir = path.join(root, "downloads", "crc-corrupt-sig"); + const extractDir = path.join(root, "extract", "crc-corrupt-sig"); + fs.mkdirSync(outputDir, { recursive: true }); + + const archiveNames = ["show.s01e01.part1.rar", "show.s01e01.part2.rar"]; + const itemIds = archiveNames.map((_, index) => `crc-corrupt-sig-item-${index}`); + const archiveSize = 64 * 1024; + + session.packageOrder = [packageId]; + session.packages[packageId] = { + id: packageId, + name: "crc-corrupt-sig", + outputDir, + extractDir, + status: "extracting", + itemIds, + cancelled: false, + enabled: true, + createdAt, + updatedAt: createdAt + }; + + for (const [index, archiveName] of archiveNames.entries()) { + const targetPath = path.join(outputDir, archiveName); + fs.writeFileSync(targetPath, Buffer.alloc(archiveSize, 0xAA)); + session.items[itemIds[index]!] = { + id: itemIds[index]!, + packageId, + url: `https://dummy/${archiveName}`, + provider: "realdebrid", + status: "completed", + retries: 0, + speedBps: 0, + downloadedBytes: archiveSize, + totalBytes: archiveSize, + progressPercent: 100, + fileName: archiveName, + targetPath, + resumable: true, + attempts: 1, + lastError: "", + fullStatus: "Entpacken - Ausstehend", + createdAt, + updatedAt: createdAt + }; + } + + const manager = new DownloadManager( + { + ...defaultSettings(), + token: "rd-token", + outputDir: path.join(root, "downloads"), + extractDir: path.join(root, "extract"), + autoExtract: true + }, + session, + createStoragePaths(path.join(root, "state")) + ); + + const changed = (manager as any).autoRecoverArchiveCrcFailure( + session.packages[packageId], + itemIds.map((itemId) => session.items[itemId]!), + { + archiveName: "show.s01e01.part1.rar", + errorText: "Checksum error in the encrypted file", + category: "crc_error", + suggestRedownload: true, + jvmFailureReason: "Can not open the file as archive" + }, + "hybrid" + ); + + expect(changed).toBe(2); + for (const itemId of itemIds) { + const item = session.items[itemId]!; + expect(item.status).toBe("queued"); + expect(item.targetPath).toBe(""); + expect(item.downloadedBytes).toBe(0); + expect(item.fullStatus).toContain("Auto-Recovery"); + } + expect(fs.existsSync(path.join(outputDir, archiveNames[0]!))).toBe(false); + expect(fs.existsSync(path.join(outputDir, archiveNames[1]!))).toBe(false); + }); + + it("does not requeue archive parts on CRC error when file has valid RAR signature (wrong password)", () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-")); + tempDirs.push(root); + + const session = emptySession(); + const packageId = "crc-valid-sig-pkg"; + const createdAt = Date.now() - 10_000; + const outputDir = path.join(root, "downloads", "crc-valid-sig"); + const extractDir = path.join(root, "extract", "crc-valid-sig"); + fs.mkdirSync(outputDir, { recursive: true }); + + const archiveNames = ["show.s01e01.part1.rar", "show.s01e01.part2.rar"]; + const itemIds = archiveNames.map((_, index) => `crc-valid-sig-item-${index}`); + const archiveSize = 64 * 1024; + + session.packageOrder = [packageId]; + session.packages[packageId] = { + id: packageId, + name: "crc-valid-sig", + outputDir, + extractDir, + status: "extracting", + itemIds, + cancelled: false, + enabled: true, + createdAt, + updatedAt: createdAt + }; + + for (const [index, archiveName] of archiveNames.entries()) { + const targetPath = path.join(outputDir, archiveName); + const content = Buffer.alloc(archiveSize, 0); + Buffer.from([0x52, 0x61, 0x72, 0x21, 0x1a, 0x07, 0x01, 0x00]).copy(content); + fs.writeFileSync(targetPath, content); + session.items[itemIds[index]!] = { + id: itemIds[index]!, + packageId, + url: `https://dummy/${archiveName}`, + provider: "realdebrid", + status: "completed", + retries: 0, + speedBps: 0, + downloadedBytes: archiveSize, + totalBytes: archiveSize, + progressPercent: 100, + fileName: archiveName, + targetPath, + resumable: true, + attempts: 1, + lastError: "", + fullStatus: "Entpacken - Ausstehend", + createdAt, + updatedAt: createdAt + }; + } + + const manager = new DownloadManager( + { + ...defaultSettings(), + token: "rd-token", + outputDir: path.join(root, "downloads"), + extractDir: path.join(root, "extract"), + autoExtract: true + }, + session, + createStoragePaths(path.join(root, "state")) + ); + + const changed = (manager as any).autoRecoverArchiveCrcFailure( + session.packages[packageId], + itemIds.map((itemId) => session.items[itemId]!), + { + archiveName: "show.s01e01.part1.rar", + errorText: "Checksum error in the encrypted file", + category: "crc_error", + suggestRedownload: true, + jvmFailureReason: "Can not open the file as archive" + }, + "hybrid" + ); + + expect(changed).toBe(0); + for (const itemId of itemIds) { + const item = session.items[itemId]!; + expect(item.status).toBe("completed"); + expect(item.targetPath).toContain(".rar"); + expect(item.downloadedBytes).toBe(archiveSize); + } + }); + + it("does not treat rev files as ready archive parts during disk fallback", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-")); + tempDirs.push(root); + + const session = emptySession(); + const packageId = "disk-fallback-rev-pkg"; + const itemIds = ["disk-fallback-rev-1", "disk-fallback-rev-2"]; + const createdAt = Date.now() - 10_000; + const outputDir = path.join(root, "downloads", "disk-fallback-rev"); + const extractDir = path.join(root, "extract", "disk-fallback-rev"); + const part1Path = path.join(outputDir, "show.s01e01.part1.rar"); + fs.mkdirSync(outputDir, { recursive: true }); + fs.writeFileSync(part1Path, Buffer.alloc(64 * 1024, 1)); + fs.writeFileSync(path.join(outputDir, "show.s01e01.rev"), Buffer.alloc(32 * 1024, 2)); + + session.packageOrder = [packageId]; + session.packages[packageId] = { + id: packageId, + name: "disk-fallback-rev", + outputDir, + extractDir, + status: "downloading", + itemIds, + cancelled: false, + enabled: true, + createdAt, + updatedAt: createdAt + }; + session.items[itemIds[0]] = { + id: itemIds[0], + packageId, + url: "https://dummy/show.s01e01.part1.rar", + provider: "realdebrid", + status: "completed", + retries: 0, + speedBps: 0, + downloadedBytes: 64 * 1024, + totalBytes: 64 * 1024, + progressPercent: 100, + fileName: "show.s01e01.part1.rar", + targetPath: part1Path, + resumable: true, + attempts: 1, + lastError: "", + fullStatus: "Entpacken - Ausstehend", + createdAt, + updatedAt: createdAt + }; + session.items[itemIds[1]] = { + id: itemIds[1], + packageId, + url: "https://dummy/show.s01e01.part2.rar", + provider: "realdebrid", + status: "queued", + retries: 0, + speedBps: 0, + downloadedBytes: 0, + totalBytes: 64 * 1024, + progressPercent: 0, + fileName: "show.s01e01.part2.rar", + targetPath: "", + resumable: true, + attempts: 0, + lastError: "", + fullStatus: "Wartet", + createdAt, + updatedAt: createdAt + }; + + const manager = new DownloadManager( + { + ...defaultSettings(), + token: "rd-token", + outputDir: path.join(root, "downloads"), + extractDir: path.join(root, "extract"), + autoExtract: true + }, + session, + createStoragePaths(path.join(root, "state")) + ); + + const ready = await (manager as any).findReadyArchiveSets(session.packages[packageId]); + expect(Array.from(ready)).toHaveLength(0); + }); + + it("allows disk fallback when queued archive parts are fully present on disk", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-")); + tempDirs.push(root); + + const session = emptySession(); + const packageId = "disk-fallback-ready-pkg"; + const itemIds = ["disk-fallback-ready-1", "disk-fallback-ready-2"]; + const createdAt = Date.now() - 10_000; + const outputDir = path.join(root, "downloads", "disk-fallback-ready"); + const extractDir = path.join(root, "extract", "disk-fallback-ready"); + const part1Path = path.join(outputDir, "show.s01e01.part1.rar"); + const part2Path = path.join(outputDir, "show.s01e01.part2.rar"); + const archiveSize = 64 * 1024; + fs.mkdirSync(outputDir, { recursive: true }); + fs.writeFileSync(part1Path, Buffer.alloc(archiveSize, 1)); + fs.writeFileSync(part2Path, Buffer.alloc(archiveSize, 2)); + + session.packageOrder = [packageId]; + session.packages[packageId] = { + id: packageId, + name: "disk-fallback-ready", + outputDir, + extractDir, + status: "downloading", + itemIds, + cancelled: false, + enabled: true, + createdAt, + updatedAt: createdAt + }; + session.items[itemIds[0]] = { + id: itemIds[0], + packageId, + url: "https://dummy/show.s01e01.part1.rar", + provider: "realdebrid", + status: "completed", + retries: 0, + speedBps: 0, + downloadedBytes: archiveSize, + totalBytes: archiveSize, + progressPercent: 100, + fileName: "show.s01e01.part1.rar", + targetPath: part1Path, + resumable: true, + attempts: 1, + lastError: "", + fullStatus: "Entpacken - Ausstehend", + createdAt, + updatedAt: createdAt + }; + session.items[itemIds[1]] = { + id: itemIds[1], + packageId, + url: "https://dummy/show.s01e01.part2.rar", + provider: "realdebrid", + status: "queued", + retries: 0, + speedBps: 0, + downloadedBytes: archiveSize, + totalBytes: archiveSize, + progressPercent: 100, + fileName: "show.s01e01.part2.rar", + targetPath: "", + resumable: true, + attempts: 0, + lastError: "", + fullStatus: "Wartet", + createdAt, + updatedAt: createdAt + }; + + const manager = new DownloadManager( + { + ...defaultSettings(), + token: "rd-token", + outputDir: path.join(root, "downloads"), + extractDir: path.join(root, "extract"), + autoExtract: true + }, + session, + createStoragePaths(path.join(root, "state")) + ); + + const ready = await (manager as any).findReadyArchiveSets(session.packages[packageId]); + expect(Array.from(ready)).toEqual([part1Path.toLowerCase()]); + }); + + it("skips unchanged hybrid archives after a previous extraction failure", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-")); + tempDirs.push(root); + + const { + session, + packageId, + itemId, + outputDir, + extractDir + } = createCompletedArchiveSession(root, "hybrid-failure-skip", "episode.mkv"); + const item = session.items[itemId]!; + const archiveKey = item.targetPath.toLowerCase(); + item.fullStatus = "Entpacken - Error"; + session.packages[packageId]!.status = "queued"; + + const manager = new DownloadManager( + { + ...defaultSettings(), + token: "rd-token", + outputDir: path.join(root, "downloads"), + extractDir: path.join(root, "extract"), + autoExtract: true, + hybridExtract: true + }, + session, + createStoragePaths(path.join(root, "state")) + ); + + const pkg = (manager as any).session.packages[packageId]; + const items = [((manager as any).session.items[itemId])]; + const marker = (manager as any).buildHybridArchiveRetryMarker(pkg, items, archiveKey); + (manager as any).hybridFailedArchives.set(packageId, new Map([ + [archiveKey, { marker, lastError: "Checksum error in the encrypted file", updatedAt: Date.now() }] + ])); + + const extracted = await (manager as any).runHybridExtraction(packageId, pkg, items); + + expect(extracted).toBe(0); + expect(fs.existsSync(path.join(extractDir, "episode.mkv"))).toBe(false); + expect(((manager as any).session.items[itemId]).fullStatus).toBe("Entpacken - Error"); + expect(((manager as any).session.packages[packageId]).status).not.toBe("extracting"); + expect(fs.existsSync(path.join(outputDir, "episode.zip"))).toBe(true); + }); + + it("does not auto-reschedule extraction for completed items already marked as extract error", () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-")); + tempDirs.push(root); + + const { + session, + packageId, + itemId + } = createCompletedArchiveSession(root, "hybrid-error-hold", "episode.mkv"); + session.items[itemId]!.fullStatus = "Entpacken - Error"; + session.packages[packageId]!.status = "queued"; + + const manager = new DownloadManager( + { + ...defaultSettings(), + token: "rd-token", + outputDir: path.join(root, "downloads"), + extractDir: path.join(root, "extract"), + autoExtract: true, + hybridExtract: true + }, + session, + createStoragePaths(path.join(root, "state")) + ); + + (manager as any).triggerPendingExtractions(); + + expect((manager as any).packagePostProcessTasks.has(packageId)).toBe(false); + expect((manager as any).session.items[itemId].fullStatus).toBe("Entpacken - Error"); + }); + + it("does not auto-reschedule extraction for completed items already marked as entpack-fehler", () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-")); + tempDirs.push(root); + + const { + session, + packageId, + itemId + } = createCompletedArchiveSession(root, "hybrid-entpack-fehler-hold", "episode.mkv"); + session.items[itemId]!.fullStatus = "Entpack-Fehler: Checksum error in encrypted file"; + session.packages[packageId]!.status = "queued"; + + const manager = new DownloadManager( + { + ...defaultSettings(), + token: "rd-token", + outputDir: path.join(root, "downloads"), + extractDir: path.join(root, "extract"), + autoExtract: true, + hybridExtract: true + }, + session, + createStoragePaths(path.join(root, "state")) + ); + + (manager as any).triggerPendingExtractions(); + + expect((manager as any).packagePostProcessTasks.has(packageId)).toBe(false); + expect((manager as any).session.items[itemId].fullStatus).toBe("Entpack-Fehler: Checksum error in encrypted file"); + }); + + it("applies final extract errors only to the affected full-extract archive items", () => { + const createdAt = Date.now() - 10_000; + const completedItems = [ + { + id: "full-fail-item-1", + status: "completed", + fileName: "show.s01e01.part1.rar", + downloadedBytes: 100 * 1024 * 1024, + fullStatus: "Fertig (100 MB)", + updatedAt: createdAt + }, + { + id: "full-fail-item-2", + status: "completed", + fileName: "show.s01e01.part2.rar", + downloadedBytes: 100 * 1024 * 1024, + fullStatus: "Fertig (100 MB)", + updatedAt: createdAt + }, + { + id: "full-fail-item-3", + status: "completed", + fileName: "show.s01e02.part1.rar", + downloadedBytes: 200 * 1024 * 1024, + fullStatus: "Fertig (200 MB)", + updatedAt: createdAt + }, + { + id: "full-fail-item-4", + status: "completed", + fileName: "show.s01e02.part2.rar", + downloadedBytes: 200 * 1024 * 1024, + fullStatus: "Fertig (200 MB)", + updatedAt: createdAt + } + ] as any[]; + const previousStatuses = new Map(completedItems.map((item: any) => [item.id, item.fullStatus])); + + for (const item of completedItems) { + item.fullStatus = "Entpacken - Ausstehend"; + } + completedItems[0].fullStatus = "Entpacken - Error"; + completedItems[1].fullStatus = "Entpacken - Error"; + const resolveArchiveItems = (archiveName: string) => { + const base = archiveName.replace(/\.part0*1\.rar$/i, ""); + return completedItems.filter((item: any) => String(item.fileName || "").toLowerCase().startsWith(`${base}.part`)); + }; + + (DownloadManager.prototype as any).applyPackageExtractFailureStatuses.call( + {}, + completedItems, + resolveArchiveItems, + new Map([["show.s01e01.part1.rar", "Checksum error in the encrypted file"]]), + "Checksum error in the encrypted file", + previousStatuses, + createdAt + 5_000 + ); + + expect(completedItems[0].fullStatus).toBe("Entpack-Fehler [show.s01e01.part1.rar]: Checksum/CRC-Fehler im Archiv"); + expect(completedItems[1].fullStatus).toBe("Entpack-Fehler [show.s01e01.part1.rar]: Checksum/CRC-Fehler im Archiv"); + expect(completedItems[2].fullStatus).toBe("Fertig (200 MB)"); + expect(completedItems[3].fullStatus).toBe("Fertig (200 MB)"); + }); + + it("clears stale pending extraction labels for untouched items when another archive fails", () => { + const createdAt = Date.now() - 10_000; + const completedItems = [ + { + id: "stale-fail-item-1", + status: "completed", + fileName: "show.s01e01.part1.rar", + downloadedBytes: 100 * 1024 * 1024, + fullStatus: "Fertig (100 MB)", + updatedAt: createdAt + }, + { + id: "stale-fail-item-2", + status: "completed", + fileName: "show.s01e01.part2.rar", + downloadedBytes: 100 * 1024 * 1024, + fullStatus: "Fertig (100 MB)", + updatedAt: createdAt + }, + { + id: "stale-fail-item-3", + status: "completed", + fileName: "show.s01e05.part2.rar", + downloadedBytes: 180 * 1024 * 1024, + fullStatus: "Entpacken - Ausstehend", + updatedAt: createdAt + } + ] as any[]; + const previousStatuses = new Map(completedItems.map((item: any) => [item.id, item.fullStatus])); + + completedItems[0].fullStatus = "Entpacken - Error"; + completedItems[1].fullStatus = "Entpacken - Error"; + + (DownloadManager.prototype as any).applyPackageExtractFailureStatuses.call( + {}, + completedItems, + (archiveName: string) => resolveArchiveItemsFromList(archiveName, completedItems), + new Map([["show.s01e01.part1.rar", "Checksum error in the encrypted file"]]), + "Checksum error in the encrypted file", + previousStatuses, + createdAt + 5_000 + ); + + expect(completedItems[0].fullStatus).toBe("Entpack-Fehler [show.s01e01.part1.rar]: Checksum/CRC-Fehler im Archiv"); + expect(completedItems[1].fullStatus).toBe("Entpack-Fehler [show.s01e01.part1.rar]: Checksum/CRC-Fehler im Archiv"); + expect(completedItems[2].fullStatus).toBe("Fertig (180 MB)"); + }); + + it("detects start conflicts when extract output already exists", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-")); + tempDirs.push(root); + + const packageId = "conflict-pkg"; + const itemId = "conflict-item"; + const now = Date.now() - 5000; + const outputDir = path.join(root, "downloads", "conflict"); + const extractDir = path.join(root, "extract", "conflict"); + fs.mkdirSync(outputDir, { recursive: true }); + fs.mkdirSync(extractDir, { recursive: true }); + fs.writeFileSync(path.join(extractDir, "existing.mkv"), "x", "utf8"); + + const session = emptySession(); + session.packageOrder = [packageId]; + session.packages[packageId] = { + id: packageId, + name: "conflict", + outputDir, + extractDir, + status: "queued", + itemIds: [itemId], + cancelled: false, + enabled: true, + createdAt: now, + updatedAt: now + }; + session.items[itemId] = { + id: itemId, + packageId, + url: "https://dummy/conflict", + provider: null, + status: "queued", + retries: 0, + speedBps: 0, + downloadedBytes: 0, + totalBytes: null, + progressPercent: 0, + fileName: "conflict.part01.rar", + targetPath: path.join(outputDir, "conflict.part01.rar"), + resumable: true, + attempts: 0, + lastError: "", + fullStatus: "Wartet", + createdAt: now, + updatedAt: now + }; + + const manager = new DownloadManager( + { + ...defaultSettings(), + token: "rd-token", + outputDir: path.join(root, "downloads"), + extractDir: path.join(root, "extract") + }, + session, + createStoragePaths(path.join(root, "state")) + ); + + const conflicts = await manager.getStartConflicts(); + expect(conflicts.length).toBe(1); + expect(conflicts[0]?.packageId).toBe(packageId); + }); + + it("resolves start conflict by skipping package", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-")); + tempDirs.push(root); + + const packageId = "skip-pkg"; + const itemId = "skip-item"; + const now = Date.now() - 5000; + const outputDir = path.join(root, "downloads", "skip"); + const extractDir = path.join(root, "extract", "skip"); + fs.mkdirSync(outputDir, { recursive: true }); + fs.mkdirSync(extractDir, { recursive: true }); + fs.writeFileSync(path.join(extractDir, "existing.mkv"), "x", "utf8"); + + const session = emptySession(); + session.packageOrder = [packageId]; + session.packages[packageId] = { + id: packageId, + name: "skip", + outputDir, + extractDir, + status: "queued", + itemIds: [itemId], + cancelled: false, + enabled: true, + createdAt: now, + updatedAt: now + }; + session.items[itemId] = { + id: itemId, + packageId, + url: "https://dummy/skip", + provider: null, + status: "queued", + retries: 0, + speedBps: 0, + downloadedBytes: 0, + totalBytes: null, + progressPercent: 0, + fileName: "skip.part01.rar", + targetPath: path.join(outputDir, "skip.part01.rar"), + resumable: true, + attempts: 0, + lastError: "", + fullStatus: "Wartet", + createdAt: now, + updatedAt: now + }; + + const manager = new DownloadManager( + { + ...defaultSettings(), + token: "rd-token", + outputDir: path.join(root, "downloads"), + extractDir: path.join(root, "extract") + }, + session, + createStoragePaths(path.join(root, "state")) + ); + + const result = await manager.resolveStartConflict(packageId, "skip"); + expect(result.skipped).toBe(true); + const snapshot = manager.getSnapshot(); + expect(snapshot.session.packages[packageId]).toBeDefined(); + expect(snapshot.session.packages[packageId]?.status).toBe("queued"); + expect(snapshot.session.items[itemId]).toBeDefined(); + expect(snapshot.session.items[itemId]?.status).toBe("queued"); + expect(snapshot.session.items[itemId]?.fullStatus).toBe("Wartet"); + }); + + it("keeps already completed items when skipping start conflict", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-")); + tempDirs.push(root); + + const packageId = "skip-partial-pkg"; + const completedItemId = "skip-partial-completed"; + const pendingItemId = "skip-partial-pending"; + const now = Date.now() - 5000; + const outputDir = path.join(root, "downloads", "skip-partial"); + const extractDir = path.join(root, "extract", "skip-partial"); + fs.mkdirSync(outputDir, { recursive: true }); + fs.mkdirSync(extractDir, { recursive: true }); + fs.writeFileSync(path.join(extractDir, "existing.mkv"), "x", "utf8"); + const completedTarget = path.join(outputDir, "skip-partial.part01.rar"); + fs.writeFileSync(completedTarget, "part", "utf8"); + + const session = emptySession(); + session.packageOrder = [packageId]; + session.packages[packageId] = { + id: packageId, + name: "skip-partial", + outputDir, + extractDir, + status: "queued", + itemIds: [completedItemId, pendingItemId], + cancelled: false, + enabled: true, + createdAt: now, + updatedAt: now + }; + session.items[completedItemId] = { + id: completedItemId, + packageId, + url: "https://dummy/skip-partial/completed", + provider: "realdebrid", + status: "completed", + retries: 0, + speedBps: 0, + downloadedBytes: 123, + totalBytes: 123, + progressPercent: 100, + fileName: "skip-partial.part01.rar", + targetPath: completedTarget, + resumable: true, + attempts: 1, + lastError: "", + fullStatus: "Entpackt", + createdAt: now, + updatedAt: now + }; + session.items[pendingItemId] = { + id: pendingItemId, + packageId, + url: "https://dummy/skip-partial/pending", + provider: null, + status: "queued", + retries: 0, + speedBps: 0, + downloadedBytes: 0, + totalBytes: null, + progressPercent: 0, + fileName: "skip-partial.part02.rar", + targetPath: path.join(outputDir, "skip-partial.part02.rar"), + resumable: true, + attempts: 0, + lastError: "", + fullStatus: "Wartet", + createdAt: now, + updatedAt: now + }; + + const manager = new DownloadManager( + { + ...defaultSettings(), + token: "rd-token", + outputDir: path.join(root, "downloads"), + extractDir: path.join(root, "extract") + }, + session, + createStoragePaths(path.join(root, "state")) + ); + + const result = await manager.resolveStartConflict(packageId, "skip"); + expect(result.skipped).toBe(true); + const snapshot = manager.getSnapshot(); + expect(snapshot.session.packages[packageId]).toBeDefined(); + expect(snapshot.session.items[completedItemId]?.status).toBe("completed"); + expect(snapshot.session.items[completedItemId]?.fullStatus).toBe("Entpackt"); + expect(snapshot.session.items[pendingItemId]?.status).toBe("queued"); + expect(snapshot.session.items[pendingItemId]?.fullStatus).toBe("Wartet"); + }); + + it("resolves start conflict by overwriting and resetting queued package", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-")); + tempDirs.push(root); + + const packageId = "overwrite-pkg"; + const itemId = "overwrite-item"; + const now = Date.now() - 5000; + const outputDir = path.join(root, "downloads", "overwrite"); + const extractDir = path.join(root, "extract", "overwrite"); + fs.mkdirSync(outputDir, { recursive: true }); + fs.mkdirSync(extractDir, { recursive: true }); + fs.writeFileSync(path.join(outputDir, "overwrite.part01.rar"), "part", "utf8"); + fs.writeFileSync(path.join(extractDir, "existing.mkv"), "x", "utf8"); + + const session = emptySession(); + session.packageOrder = [packageId]; + session.packages[packageId] = { + id: packageId, + name: "overwrite", + outputDir, + extractDir, + status: "queued", + itemIds: [itemId], + cancelled: false, + enabled: true, + createdAt: now, + updatedAt: now + }; + session.items[itemId] = { + id: itemId, + packageId, + url: "https://dummy/overwrite", + provider: null, + status: "queued", + retries: 1, + speedBps: 0, + downloadedBytes: 42, + totalBytes: 100, + progressPercent: 42, + fileName: "overwrite.part01.rar", + targetPath: path.join(outputDir, "overwrite.part01.rar"), + resumable: true, + attempts: 3, + lastError: "x", + fullStatus: "Wartet", + createdAt: now, + updatedAt: now + }; + + const manager = new DownloadManager( + { + ...defaultSettings(), + token: "rd-token", + outputDir: path.join(root, "downloads"), + extractDir: path.join(root, "extract") + }, + session, + createStoragePaths(path.join(root, "state")) + ); + + const result = await manager.resolveStartConflict(packageId, "overwrite"); + expect(result.overwritten).toBe(true); + const snapshot = manager.getSnapshot(); + const item = snapshot.session.items[itemId]; + expect(item?.status).toBe("queued"); + expect(item?.downloadedBytes).toBe(0); + expect(item?.progressPercent).toBe(0); + expect(item?.attempts).toBe(0); + expect(item?.lastError).toBe(""); + expect(item?.fullStatus).toBe("Wartet"); + expect(fs.existsSync(outputDir)).toBe(false); + expect(fs.existsSync(extractDir)).toBe(false); + }); + + it("requeues legacy 'Gestoppt' items on startup", () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-")); + tempDirs.push(root); + + const session = emptySession(); + const packageId = "stopped-pkg"; + const itemId = "stopped-item"; + const createdAt = Date.now() - 20_000; + session.packageOrder = [packageId]; + session.packages[packageId] = { + id: packageId, + name: "stopped", + outputDir: path.join(root, "downloads", "stopped"), + extractDir: path.join(root, "extract", "stopped"), + status: "completed", + itemIds: [itemId], + cancelled: false, + enabled: true, + createdAt, + updatedAt: createdAt + }; + session.items[itemId] = { + id: itemId, + packageId, + url: "https://dummy/stopped", + provider: "megadebrid", + status: "cancelled", + retries: 1, + speedBps: 0, + downloadedBytes: 512, + totalBytes: 2048, + progressPercent: 25, + fileName: "resume.part01.rar", + targetPath: path.join(root, "downloads", "stopped", "resume.part01.rar"), + resumable: true, + attempts: 1, + lastError: "", + fullStatus: "Gestoppt", + createdAt, + updatedAt: createdAt + }; + + const manager = new DownloadManager( + { + ...defaultSettings(), + token: "rd-token", + outputDir: path.join(root, "downloads"), + extractDir: path.join(root, "extract"), + autoExtract: false + }, + session, + createStoragePaths(path.join(root, "state")) + ); + + const snapshot = manager.getSnapshot(); + expect(snapshot.session.items[itemId]?.status).toBe("queued"); + expect(snapshot.session.items[itemId]?.fullStatus).toBe("Wartet"); + expect(snapshot.session.packages[packageId]?.status).toBe("queued"); + }); + + it("cleans leftover split archives on startup for already extracted packages", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-")); + tempDirs.push(root); + + const packageDir = path.join(root, "downloads", "legacy"); + fs.mkdirSync(packageDir, { recursive: true }); + const part1 = path.join(packageDir, "legacy.release.part01.rar"); + const part2 = path.join(packageDir, "legacy.release.part02.rar"); + const part3 = path.join(packageDir, "legacy.release.part03.rar"); + const keep = path.join(packageDir, "keep.txt"); + fs.writeFileSync(part2, "part2", "utf8"); + fs.writeFileSync(part3, "part3", "utf8"); + fs.writeFileSync(keep, "keep", "utf8"); + + const session = emptySession(); + const packageId = "legacy-pkg"; + const itemId = "legacy-item"; + const createdAt = Date.now() - 20_000; + + session.packageOrder = [packageId]; + session.packages[packageId] = { + id: packageId, + name: "legacy", + outputDir: packageDir, + extractDir: path.join(root, "extract", "legacy"), + status: "completed", + itemIds: [itemId], + cancelled: false, + enabled: true, + createdAt, + updatedAt: createdAt + }; + session.items[itemId] = { + id: itemId, + packageId, + url: "https://dummy/legacy", + provider: "realdebrid", + status: "completed", + retries: 0, + speedBps: 0, + downloadedBytes: 123, + totalBytes: 123, + progressPercent: 100, + fileName: path.basename(part1), + targetPath: part1, + resumable: true, + attempts: 1, + lastError: "", + fullStatus: "Entpackt", + createdAt, + updatedAt: createdAt + }; + + new DownloadManager( + { + ...defaultSettings(), + token: "rd-token", + outputDir: path.join(root, "downloads"), + extractDir: path.join(root, "extract"), + autoExtract: true, + cleanupMode: "delete" + }, + session, + createStoragePaths(path.join(root, "state")) + ); + + await waitFor(() => !fs.existsSync(part2) && !fs.existsSync(part3), 5000); + expect(fs.existsSync(keep)).toBe(true); + }); + + it("cleans legacy leftovers when package is extracted but marker is old", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-")); + tempDirs.push(root); + + const packageDir = path.join(root, "downloads", "legacy-old"); + const extractDir = path.join(root, "extract", "legacy-old"); + fs.mkdirSync(packageDir, { recursive: true }); + fs.mkdirSync(extractDir, { recursive: true }); + + const part1 = path.join(packageDir, "legacy.old.part01.rar"); + const part2 = path.join(packageDir, "legacy.old.part02.rar"); + const part3 = path.join(packageDir, "legacy.old.part03.rar"); + const keep = path.join(packageDir, "keep.nfo"); + fs.writeFileSync(part1, Buffer.alloc(123, 0x61)); + fs.writeFileSync(part2, Buffer.alloc(123, 0x62)); + fs.writeFileSync(part3, Buffer.alloc(123, 0x63)); + fs.writeFileSync(keep, "keep", "utf8"); + fs.writeFileSync(path.join(extractDir, "episode.mkv"), "video", "utf8"); + + const session = emptySession(); + const packageId = "legacy-old-pkg"; + const itemId = "legacy-old-item"; + const createdAt = Date.now() - 20_000; + + session.packageOrder = [packageId]; + session.packages[packageId] = { + id: packageId, + name: "legacy-old", + outputDir: packageDir, + extractDir, + status: "completed", + itemIds: [itemId], + cancelled: false, + enabled: true, + createdAt, + updatedAt: createdAt + }; + session.items[itemId] = { + id: itemId, + packageId, + url: "https://dummy/legacy-old", + provider: "realdebrid", + status: "completed", + retries: 0, + speedBps: 0, + downloadedBytes: 123, + totalBytes: 123, + progressPercent: 100, + fileName: path.basename(part1), + targetPath: part1, + resumable: true, + attempts: 1, + lastError: "", + fullStatus: "Fertig (123 MB)", + createdAt, + updatedAt: createdAt + }; + + new DownloadManager( + { + ...defaultSettings(), + token: "rd-token", + outputDir: path.join(root, "downloads"), + extractDir: path.join(root, "extract"), + autoExtract: false, + cleanupMode: "delete" + }, + session, + createStoragePaths(path.join(root, "state")) + ); + + await waitFor(() => !fs.existsSync(part1) && !fs.existsSync(part2) && !fs.existsSync(part3), 5000); + expect(fs.existsSync(keep)).toBe(true); + expect(fs.existsSync(path.join(extractDir, "episode.mkv"))).toBe(true); + }); + + it("removes empty download package directory after startup cleanup backfill", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-")); + tempDirs.push(root); + + const packageDir = path.join(root, "downloads", "legacy-empty"); + fs.mkdirSync(packageDir, { recursive: true }); + const part1 = path.join(packageDir, "legacy.empty.part01.rar"); + const part2 = path.join(packageDir, "legacy.empty.part02.rar"); + fs.writeFileSync(part1, "part1", "utf8"); + fs.writeFileSync(part2, "part2", "utf8"); + + const session = emptySession(); + const packageId = "legacy-empty-pkg"; + const itemId = "legacy-empty-item"; + const createdAt = Date.now() - 20_000; + + session.packageOrder = [packageId]; + session.packages[packageId] = { + id: packageId, + name: "legacy-empty", + outputDir: packageDir, + extractDir: path.join(root, "extract", "legacy-empty"), + status: "completed", + itemIds: [itemId], + cancelled: false, + enabled: true, + createdAt, + updatedAt: createdAt + }; + session.items[itemId] = { + id: itemId, + packageId, + url: "https://dummy/legacy-empty", + provider: "realdebrid", + status: "completed", + retries: 0, + speedBps: 0, + downloadedBytes: 123, + totalBytes: 123, + progressPercent: 100, + fileName: path.basename(part1), + targetPath: part1, + resumable: true, + attempts: 1, + lastError: "", + fullStatus: "Entpackt", + createdAt, + updatedAt: createdAt + }; + + new DownloadManager( + { + ...defaultSettings(), + token: "rd-token", + outputDir: path.join(root, "downloads"), + extractDir: path.join(root, "extract"), + autoExtract: false, + cleanupMode: "delete" + }, + session, + createStoragePaths(path.join(root, "state")) + ); + + await waitFor(() => !fs.existsSync(packageDir), 5000); + }); + + it("does not over-clean packages that share one extract directory", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-")); + tempDirs.push(root); + + const sharedExtractDir = path.join(root, "extract", "shared"); + fs.mkdirSync(sharedExtractDir, { recursive: true }); + fs.writeFileSync(path.join(sharedExtractDir, "already-extracted.mkv"), "ok", "utf8"); + + const pkg1Dir = path.join(root, "downloads", "pkg1"); + const pkg2Dir = path.join(root, "downloads", "pkg2"); + fs.mkdirSync(pkg1Dir, { recursive: true }); + fs.mkdirSync(pkg2Dir, { recursive: true }); + + const pkg1Part1 = path.join(pkg1Dir, "show.one.part01.rar"); + const pkg1Part2 = path.join(pkg1Dir, "show.one.part02.rar"); + const pkg2Part1 = path.join(pkg2Dir, "show.two.part01.rar"); + const pkg2Part2 = path.join(pkg2Dir, "show.two.part02.rar"); + fs.writeFileSync(pkg1Part1, "a1", "utf8"); + fs.writeFileSync(pkg1Part2, "a2", "utf8"); + fs.writeFileSync(pkg2Part1, "b1", "utf8"); + fs.writeFileSync(pkg2Part2, "b2", "utf8"); + + const session = emptySession(); + const createdAt = Date.now() - 30_000; + + session.packageOrder = ["pkg1", "pkg2"]; + session.packages.pkg1 = { + id: "pkg1", + name: "pkg1", + outputDir: pkg1Dir, + extractDir: sharedExtractDir, + status: "completed", + itemIds: ["pkg1-item"], + cancelled: false, + enabled: true, + createdAt, + updatedAt: createdAt + }; + session.packages.pkg2 = { + id: "pkg2", + name: "pkg2", + outputDir: pkg2Dir, + extractDir: sharedExtractDir, + status: "completed", + itemIds: ["pkg2-item"], + cancelled: false, + enabled: true, + createdAt, + updatedAt: createdAt + }; + + session.items["pkg1-item"] = { + id: "pkg1-item", + packageId: "pkg1", + url: "https://dummy/pkg1", + provider: "realdebrid", + status: "completed", + retries: 0, + speedBps: 0, + downloadedBytes: 1, + totalBytes: 1, + progressPercent: 100, + fileName: path.basename(pkg1Part1), + targetPath: pkg1Part1, + resumable: true, + attempts: 1, + lastError: "", + fullStatus: "Entpackt", + createdAt, + updatedAt: createdAt + }; + session.items["pkg2-item"] = { + id: "pkg2-item", + packageId: "pkg2", + url: "https://dummy/pkg2", + provider: "realdebrid", + status: "completed", + retries: 0, + speedBps: 0, + downloadedBytes: 1, + totalBytes: 1, + progressPercent: 100, + fileName: path.basename(pkg2Part1), + targetPath: pkg2Part1, + resumable: true, + attempts: 1, + lastError: "", + fullStatus: "Fertig (100 MB)", + createdAt, + updatedAt: createdAt + }; + + new DownloadManager( + { + ...defaultSettings(), + token: "rd-token", + outputDir: path.join(root, "downloads"), + extractDir: path.join(root, "extract"), + autoExtract: false, + cleanupMode: "delete" + }, + session, + createStoragePaths(path.join(root, "state")) + ); + + await waitFor(() => !fs.existsSync(pkg1Part1) && !fs.existsSync(pkg1Part2), 5000); + expect(fs.existsSync(pkg2Part1)).toBe(true); + expect(fs.existsSync(pkg2Part2)).toBe(true); + }); + + it("resets run counters and reconnect state on start", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-")); + tempDirs.push(root); + + const session = emptySession(); + session.runStartedAt = Date.now() - 3600 * 1000; + session.totalDownloadedBytes = 9_999_999; + session.reconnectUntil = Date.now() + 120000; + session.reconnectReason = "HTTP 503"; + + const manager = new DownloadManager( + { + ...defaultSettings(), + token: "rd-token", + outputDir: path.join(root, "downloads"), + extractDir: path.join(root, "extract"), + autoExtract: false + }, + session, + createStoragePaths(path.join(root, "state")) + ); + + await manager.start(); + await waitFor(() => !manager.getSnapshot().session.running, 5000); + + const snapshot = manager.getSnapshot(); + const summary = manager.getSummary(); + expect(snapshot.session.totalDownloadedBytes).toBe(0); + expect(snapshot.session.reconnectUntil).toBe(0); + expect(snapshot.session.reconnectReason).toBe(""); + expect(summary).toBeNull(); + }); + + it("shows zero total when queue is empty despite stale persisted bytes", () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-")); + tempDirs.push(root); + + const session = emptySession(); + session.totalDownloadedBytes = 19.99 * 1024 * 1024 * 1024; + session.runStartedAt = Date.now() - 5 * 60 * 1000; + + const manager = new DownloadManager( + { + ...defaultSettings(), + token: "rd-token", + outputDir: path.join(root, "downloads"), + extractDir: path.join(root, "extract"), + autoExtract: false + }, + session, + createStoragePaths(path.join(root, "state")) + ); + + const snapshot = manager.getSnapshot(); + expect(snapshot.stats.totalPackages).toBe(0); + expect(snapshot.stats.totalFiles).toBe(0); + expect(snapshot.stats.totalDownloaded).toBe(0); + expect(snapshot.session.totalDownloadedBytes).toBe(0); + expect(snapshot.session.runStartedAt).toBe(0); + }); + + it("clearAll resets total bytes and stats", () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-")); + tempDirs.push(root); + + const session = emptySession(); + const packageId = "pkg-clear"; + const itemId = "item-clear"; + const now = Date.now() - 1000; + const outputDir = path.join(root, "downloads", "pkg-clear"); + const extractDir = path.join(root, "extract", "pkg-clear"); + const targetPath = path.join(outputDir, "episode.mkv"); + + session.packageOrder = [packageId]; + session.packages[packageId] = { + id: packageId, + name: "pkg-clear", + outputDir, + extractDir, + status: "completed", + itemIds: [itemId], + cancelled: false, + enabled: true, + createdAt: now, + updatedAt: now + }; + session.items[itemId] = { + id: itemId, + packageId, + url: "https://dummy/item-clear", + provider: "realdebrid", + status: "completed", + retries: 0, + speedBps: 0, + downloadedBytes: 1024, + totalBytes: 1024, + progressPercent: 100, + fileName: "episode.mkv", + targetPath, + resumable: true, + attempts: 1, + lastError: "", + fullStatus: "Fertig (1 KB)", + createdAt: now, + updatedAt: now + }; + session.totalDownloadedBytes = 1024; + session.runStartedAt = now; + + const manager = new DownloadManager( + { + ...defaultSettings(), + token: "rd-token", + outputDir: path.join(root, "downloads"), + extractDir: path.join(root, "extract"), + autoExtract: false + }, + session, + createStoragePaths(path.join(root, "state")) + ); + + manager.clearAll(); + const snapshot = manager.getSnapshot(); + expect(snapshot.stats.totalPackages).toBe(0); + expect(snapshot.stats.totalFiles).toBe(0); + expect(snapshot.stats.totalDownloaded).toBe(0); + expect(snapshot.session.totalDownloadedBytes).toBe(0); + expect(snapshot.session.runStartedAt).toBe(0); + }); + + it("keeps cumulative session totals when completed items are removed from the queue", () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-")); + tempDirs.push(root); + + const session = emptySession(); + const packageId = "pkg-complete-remove"; + const itemId = "item-complete-remove"; + const now = Date.now() - 1000; + const outputDir = path.join(root, "downloads", "pkg-complete-remove"); + const extractDir = path.join(root, "extract", "pkg-complete-remove"); + const targetPath = path.join(outputDir, "episode.mkv"); + + session.packageOrder = [packageId]; + session.packages[packageId] = { + id: packageId, + name: "pkg-complete-remove", + outputDir, + extractDir, + status: "completed", + itemIds: [itemId], + cancelled: false, + enabled: true, + createdAt: now, + updatedAt: now + }; + session.items[itemId] = { + id: itemId, + packageId, + url: "https://dummy/item-complete-remove", + provider: "realdebrid", + status: "completed", + retries: 0, + speedBps: 0, + downloadedBytes: 3 * 1024, + totalBytes: 3 * 1024, + progressPercent: 100, + fileName: "episode.mkv", + targetPath, + resumable: true, + attempts: 1, + lastError: "", + fullStatus: "Fertig (3 KB)", + createdAt: now, + updatedAt: now + }; + + const manager = new DownloadManager( + { + ...defaultSettings(), + token: "rd-token", + outputDir: path.join(root, "downloads"), + extractDir: path.join(root, "extract"), + autoExtract: false + }, + session, + createStoragePaths(path.join(root, "state")) + ); + + const internal = manager as unknown as { + session: { totalDownloadedBytes: number }; + sessionDownloadedBytes: number; + sessionCompletedFiles: number; + itemContributedBytes: Map; + removePackageFromSession: (packageId: string, itemIds: string[], reason?: "completed" | "deleted") => void; + }; + + internal.session.totalDownloadedBytes = 16 * 1024 * 1024 * 1024; + internal.sessionDownloadedBytes = 16 * 1024 * 1024 * 1024; + internal.sessionCompletedFiles = 1; + internal.itemContributedBytes.set(itemId, 3 * 1024 * 1024 * 1024); + + internal.removePackageFromSession(packageId, [itemId], "completed"); + + const snapshot = manager.getSnapshot(); + expect(snapshot.stats.totalPackages).toBe(0); + expect(snapshot.stats.totalDownloaded).toBe(16 * 1024 * 1024 * 1024); + expect(snapshot.stats.totalFilesSession).toBe(1); + expect(snapshot.session.totalDownloadedBytes).toBe(16 * 1024 * 1024 * 1024); + }); + + it("does not start a run when queue is empty", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-")); + tempDirs.push(root); + + const manager = new DownloadManager( + { + ...defaultSettings(), + token: "rd-token", + outputDir: path.join(root, "downloads"), + extractDir: path.join(root, "extract"), + autoExtract: false + }, + emptySession(), + createStoragePaths(path.join(root, "state")) + ); + + manager.start(); + await new Promise((resolve) => setTimeout(resolve, 80)); + + const snapshot = manager.getSnapshot(); + expect(snapshot.session.running).toBe(false); + expect(manager.getSummary()).toBeNull(); + }); + + it("calculates ETA from current run items only", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-")); + tempDirs.push(root); + const binary = Buffer.alloc(128 * 1024, 4); + + const server = http.createServer((req, res) => { + if ((req.url || "") !== "/slow-eta") { + res.statusCode = 404; + res.end("not-found"); + return; + } + res.statusCode = 200; + res.setHeader("Accept-Ranges", "bytes"); + res.setHeader("Content-Length", String(binary.length)); + const half = Math.floor(binary.length / 2); + res.write(binary.subarray(0, half)); + setTimeout(() => { + res.end(binary.subarray(half)); + }, 700); + }); + + server.listen(0, "127.0.0.1"); + await once(server, "listening"); + + const address = server.address(); + if (!address || typeof address === "string") { + throw new Error("server address unavailable"); + } + const directUrl = `http://127.0.0.1:${address.port}/slow-eta`; + + globalThis.fetch = async (input: RequestInfo | URL, init?: RequestInit): Promise => { + const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url; + if (url.includes("/unrestrict/link")) { + return new Response( + JSON.stringify({ + download: directUrl, + filename: "new-episode.mkv", + filesize: binary.length + }), + { + status: 200, + headers: { "Content-Type": "application/json" } + } + ); + } + return originalFetch(input, init); + }; + + try { + const session = emptySession(); + const oldPkgId = "old-pkg"; + const oldItemId = "old-item"; + const oldNow = Date.now() - 5000; + session.packageOrder = [oldPkgId]; + session.packages[oldPkgId] = { + id: oldPkgId, + name: "old", + outputDir: path.join(root, "downloads", "old"), + extractDir: path.join(root, "extract", "old"), + status: "completed", + itemIds: [oldItemId], + cancelled: false, + enabled: true, + createdAt: oldNow, + updatedAt: oldNow + }; + session.items[oldItemId] = { + id: oldItemId, + packageId: oldPkgId, + url: "https://dummy/old", + provider: "realdebrid", + status: "completed", + retries: 0, + speedBps: 0, + downloadedBytes: 100, + totalBytes: 100, + progressPercent: 100, + fileName: "old.bin", + targetPath: path.join(root, "downloads", "old", "old.bin"), + resumable: true, + attempts: 1, + lastError: "", + fullStatus: "done", + createdAt: oldNow, + updatedAt: oldNow + }; + + const manager = new DownloadManager( + { + ...defaultSettings(), + token: "rd-token", + outputDir: path.join(root, "downloads"), + extractDir: path.join(root, "extract"), + autoExtract: false + }, + session, + createStoragePaths(path.join(root, "state")) + ); + + manager.addPackages([{ name: "new", links: ["https://dummy/new"] }]); + await manager.start(); + await new Promise((resolve) => setTimeout(resolve, 120)); + + const runningSnapshot = manager.getSnapshot(); + expect(runningSnapshot.session.running).toBe(true); + expect(runningSnapshot.etaText).toBe("ETA: --"); + + await waitFor(() => !manager.getSnapshot().session.running, 25000); + } finally { + server.close(); + await once(server, "close"); + } + }); + + it("performs one fresh retry after fetch failed during unrestrict", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-")); + tempDirs.push(root); + const binary = Buffer.alloc(96 * 1024, 12); + + const server = http.createServer((req, res) => { + if ((req.url || "") !== "/fresh-retry") { + res.statusCode = 404; + res.end("not-found"); + return; + } + res.statusCode = 200; + res.setHeader("Accept-Ranges", "bytes"); + res.setHeader("Content-Length", String(binary.length)); + res.end(binary); + }); + + server.listen(0, "127.0.0.1"); + await once(server, "listening"); + + const address = server.address(); + if (!address || typeof address === "string") { + throw new Error("server address unavailable"); + } + const directUrl = `http://127.0.0.1:${address.port}/fresh-retry`; + let unrestrictCalls = 0; + + globalThis.fetch = async (input: RequestInfo | URL, init?: RequestInit): Promise => { + const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url; + if (url.includes("/unrestrict/link")) { + unrestrictCalls += 1; + if (unrestrictCalls <= 3) { + throw new TypeError("fetch failed"); + } + return new Response( + JSON.stringify({ + download: directUrl, + filename: "fresh-retry.bin", + filesize: binary.length + }), + { + status: 200, + headers: { "Content-Type": "application/json" } + } + ); + } + return originalFetch(input, init); + }; + + try { + const manager = new DownloadManager( + { + ...defaultSettings(), + token: "rd-token", + outputDir: path.join(root, "downloads"), + extractDir: path.join(root, "extract"), + autoExtract: false, + autoReconnect: false + }, + emptySession(), + createStoragePaths(path.join(root, "state")) + ); + + manager.addPackages([{ name: "fresh-retry", links: ["https://dummy/fresh"] }]); + await manager.start(); + await waitFor(() => !manager.getSnapshot().session.running, 30000); + + const item = Object.values(manager.getSnapshot().session.items)[0]; + expect(unrestrictCalls).toBeGreaterThan(3); + expect(item?.status).toBe("completed"); + expect(item?.lastError || "").toBe(""); + expect(fs.existsSync(item.targetPath)).toBe(true); + } finally { + server.close(); + await once(server, "close"); + } + }); + + it("retries suspicious mini files under 100 KB until the full file arrives", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-")); + tempDirs.push(root); + const binary = Buffer.alloc(2 * 1024 * 1024, 21); + let directCalls = 0; + + const server = http.createServer((req, res) => { + if ((req.url || "") !== "/mini-retry") { + res.statusCode = 404; + res.end("not-found"); + return; + } + + directCalls += 1; + if (directCalls === 1) { + const tiny = Buffer.from("temporary error", "utf8"); + res.statusCode = 200; + res.setHeader("Accept-Ranges", "bytes"); + res.setHeader("Content-Length", String(tiny.length)); + res.end(tiny); + return; + } + + res.statusCode = 200; + res.setHeader("Accept-Ranges", "bytes"); + res.setHeader("Content-Length", String(binary.length)); + res.end(binary); + }); + + server.listen(0, "127.0.0.1"); + await once(server, "listening"); + + const address = server.address(); + if (!address || typeof address === "string") { + throw new Error("server address unavailable"); + } + const directUrl = `http://127.0.0.1:${address.port}/mini-retry`; + + globalThis.fetch = async (input: RequestInfo | URL, init?: RequestInit): Promise => { + const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url; + if (url.includes("/unrestrict/link")) { + return new Response( + JSON.stringify({ + download: directUrl, + filename: "mini-retry.part01.rar", + filesize: binary.length + }), + { + status: 200, + headers: { "Content-Type": "application/json" } + } + ); + } + return originalFetch(input, init); + }; + + try { + const manager = new DownloadManager( + { + ...defaultSettings(), + token: "rd-token", + outputDir: path.join(root, "downloads"), + extractDir: path.join(root, "extract"), + autoExtract: false, + autoReconnect: false + }, + emptySession(), + createStoragePaths(path.join(root, "state")) + ); + + manager.addPackages([{ name: "mini-retry", links: ["https://dummy/mini-retry"] }]); + await manager.start(); + await waitFor(() => !manager.getSnapshot().session.running, 30000); + + const item = Object.values(manager.getSnapshot().session.items)[0]; + expect(item?.status).toBe("completed"); + if (process.platform !== "win32") { + expect(directCalls).toBeGreaterThan(1); + } + expect(fs.existsSync(item.targetPath)).toBe(true); + } finally { + server.close(); + await once(server, "close"); + } + }); + + it("accepts small .sfv metadata files without rejecting them as suspicious", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-")); + tempDirs.push(root); + const sfvContent = Buffer.from("archive.part1.rar 1A2B3C4D\narchive.part2.rar 5E6F7A8B\n", "utf8"); + + const server = http.createServer((req, res) => { + res.statusCode = 200; + res.setHeader("Content-Length", String(sfvContent.length)); + res.end(sfvContent); + }); + + server.listen(0, "127.0.0.1"); + await once(server, "listening"); + const address = server.address(); + if (!address || typeof address === "string") throw new Error("server address unavailable"); + const directUrl = `http://127.0.0.1:${address.port}/checksum.sfv`; + + globalThis.fetch = async (input: RequestInfo | URL, init?: RequestInit): Promise => { + const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url; + if (url.includes("/unrestrict/link")) { + return new Response( + JSON.stringify({ download: directUrl, filename: "archive.sfv", filesize: sfvContent.length }), + { status: 200, headers: { "Content-Type": "application/json" } } + ); + } + return originalFetch(input, init); + }; + + try { + const manager = new DownloadManager( + { + ...defaultSettings(), + token: "rd-token", + outputDir: path.join(root, "downloads"), + extractDir: path.join(root, "extract"), + autoExtract: false, + autoReconnect: false + }, + emptySession(), + createStoragePaths(path.join(root, "state")) + ); + + manager.addPackages([{ name: "sfv-test", links: ["https://dummy/sfv-file"] }]); + await manager.start(); + await waitFor(() => !manager.getSnapshot().session.running, 15000); + + const item = Object.values(manager.getSnapshot().session.items)[0]; + expect(item?.status).toBe("completed"); + expect(item?.retries).toBe(0); + expect(fs.existsSync(item.targetPath)).toBe(true); + const onDisk = fs.readFileSync(item.targetPath); + expect(onDisk.length).toBe(sfvContent.length); + } finally { + server.close(); + await once(server, "close"); + } + }); + + it("limits AllDebrid rapidgator starts to one active task by default", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-")); + tempDirs.push(root); + const binary = Buffer.alloc(2 * 1024 * 1024, 6); + let unlockInFlight = 0; + let maxUnlockInFlight = 0; + + const server = http.createServer((req, res) => { + const route = req.url || ""; + if (route !== "/rg-1" && route !== "/rg-2") { + res.statusCode = 404; + res.end("not-found"); + return; + } + setTimeout(() => { + res.statusCode = 200; + res.setHeader("Accept-Ranges", "bytes"); + res.setHeader("Content-Length", String(binary.length)); + res.end(binary); + }, 1500); + }); + + server.listen(0, "127.0.0.1"); + await once(server, "listening"); + + const address = server.address(); + if (!address || typeof address === "string") { + throw new Error("server address unavailable"); + } + + const link1 = "https://rapidgator.net/file/12345678901234567890123456789012/file1.mkv.html"; + const link2 = "https://rapidgator.net/file/abcdefabcdefabcdefabcdefabcdef12/file2.mkv.html"; + const directUrl1 = `http://127.0.0.1:${address.port}/rg-1`; + const directUrl2 = `http://127.0.0.1:${address.port}/rg-2`; + + globalThis.fetch = async (input: RequestInfo | URL, init?: RequestInit): Promise => { + const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url; + const method = String( + init?.method + || (typeof input === "string" || input instanceof URL ? "" : input.method || "") + ).toUpperCase(); + + if (url.includes("/user/hosts")) { + return new Response( + JSON.stringify({ + status: "success", + data: { + hosts: { + rapidgator: { + name: "Rapidgator", + status: true, + quota: 50, + quotaMax: 100, + quotaType: "traffic", + limitSimuDl: 1 + } + } + } + }), + { + status: 200, + headers: { "Content-Type": "application/json" } + } + ); + } + + if (url.includes("/link/unlock")) { + unlockInFlight += 1; + maxUnlockInFlight = Math.max(maxUnlockInFlight, unlockInFlight); + try { + await new Promise((resolve) => setTimeout(resolve, 120)); + const body = init?.body; + const bodyText = body instanceof URLSearchParams ? body.toString() : String(body || ""); + const originalLink = new URLSearchParams(bodyText).get("link") || ""; + const directUrl = originalLink === link2 ? directUrl2 : directUrl1; + const fileName = originalLink === link2 ? "rg-2.mkv" : "rg-1.mkv"; + return new Response( + JSON.stringify({ + status: "success", + data: { + link: directUrl, + filename: fileName, + filesize: binary.length + } + }), + { + status: 200, + headers: { "Content-Type": "application/json" } + } + ); + } finally { + unlockInFlight = Math.max(0, unlockInFlight - 1); + } + } + + if (url.startsWith("https://rapidgator.net/")) { + if (method === "HEAD") { + return new Response(null, { status: 200 }); + } + return new Response("Rapidgator", { + status: 200, + headers: { "Content-Type": "text/html" } + }); + } + + return originalFetch(input, init); + }; + + try { + const manager = new DownloadManager( + { + ...defaultSettings(), + allDebridToken: "ad-token", + providerOrder: [], + providerPrimary: "alldebrid", + providerSecondary: "none", + providerTertiary: "none", + outputDir: path.join(root, "downloads"), + extractDir: path.join(root, "extract"), + autoExtract: false, + autoReconnect: false, + enableIntegrityCheck: false, + maxParallel: 2 + }, + emptySession(), + createStoragePaths(path.join(root, "state")) + ); + + manager.addPackages([{ name: "rg-all-debrid", links: [link1, link2] }]); + await manager.start(); + await waitFor(() => { + const items = Object.values(manager.getSnapshot().session.items); + return items.some((item) => item.status === "downloading") && maxUnlockInFlight >= 1; + }, 15000); + await new Promise((resolve) => setTimeout(resolve, 3600)); + + const items = Object.values(manager.getSnapshot().session.items); + expect(items).toHaveLength(2); + expect(items.filter((item) => item.status === "downloading" || item.status === "completed")).toHaveLength(1); + expect(items.filter((item) => item.status === "queued" || item.status === "validating" || item.status === "reconnect_wait")).toHaveLength(1); + expect(maxUnlockInFlight).toBe(1); + manager.stop(); + await waitFor(() => !manager.getSnapshot().session.running, 15000); + } finally { + server.close(); + await once(server, "close"); + } + }, 35000); + + it("allows concurrent AllDebrid Web Rapidgator starts up to configured parallelism", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-")); + tempDirs.push(root); + const chunk = Buffer.alloc(256 * 1024, 9); + + const server = http.createServer((req, res) => { + const route = req.url || ""; + if (route !== "/ad-web-1" && route !== "/ad-web-2" && route !== "/ad-web-3") { + res.statusCode = 404; + res.end("not-found"); + return; + } + + let sent = 0; + const totalChunks = 10; + res.statusCode = 200; + res.setHeader("Accept-Ranges", "bytes"); + res.setHeader("Content-Length", String(chunk.length * totalChunks)); + const timer = setInterval(() => { + if (sent >= totalChunks) { + clearInterval(timer); + res.end(); + return; + } + res.write(chunk); + sent += 1; + }, 700); + res.on("close", () => clearInterval(timer)); + }); + + server.listen(0, "127.0.0.1"); + await once(server, "listening"); + + const address = server.address(); + if (!address || typeof address === "string") { + throw new Error("server address unavailable"); + } + + const link1 = "https://rapidgator.net/file/web-1/sample.part1.rar.html"; + const link2 = "https://rapidgator.net/file/web-2/sample.part2.rar.html"; + const link3 = "https://rapidgator.net/file/web-3/sample.part3.rar.html"; + const directUrl1 = `http://127.0.0.1:${address.port}/ad-web-1`; + const directUrl2 = `http://127.0.0.1:${address.port}/ad-web-2`; + const directUrl3 = `http://127.0.0.1:${address.port}/ad-web-3`; + + try { + const manager = new DownloadManager( + { + ...defaultSettings(), + allDebridToken: "ad-token", + allDebridUseWebLogin: true, + providerOrder: [], + providerPrimary: "alldebrid", + providerSecondary: "none", + providerTertiary: "none", + outputDir: path.join(root, "downloads"), + extractDir: path.join(root, "extract"), + autoExtract: false, + autoReconnect: false, + enableIntegrityCheck: false, + maxParallel: 3 + }, + emptySession(), + createStoragePaths(path.join(root, "state")), + { + allDebridWebUnrestrict: async (link) => ({ + fileName: link === link2 ? "ad-web-2.bin" : link === link3 ? "ad-web-3.bin" : "ad-web-1.bin", + directUrl: link === link2 ? directUrl2 : link === link3 ? directUrl3 : directUrl1, + fileSize: chunk.length * 10, + retriesUsed: 0 + }) + } + ); + + manager.addPackages([{ name: "ad-web-parallel", links: [link1, link2, link3] }]); + await manager.start(); + + await waitFor(() => { + const items = Object.values(manager.getSnapshot().session.items); + return items.filter((item) => item.status === "downloading").length >= 2; + }, 10000); + + const items = Object.values(manager.getSnapshot().session.items); + expect(items.filter((item) => item.status === "downloading").length).toBeGreaterThanOrEqual(2); + + manager.stop(); + await waitFor(() => !manager.getSnapshot().session.running, 15000); + } finally { + server.close(); + await once(server, "close"); + } + }, 20000); + + it("retries a transient Mega-Debrid resolve failure fast (no long cooldown) with a German reason", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-")); + tempDirs.push(root); + + let unrestrictCalls = 0; + + const manager = new DownloadManager( + { + ...defaultSettings(), + megaCredentials: "mega-user:mega-pass", + megaDebridWebEnabled: true, + megaDebridApiEnabled: false, + megaDebridPreferApi: false, + providerOrder: [], + providerPrimary: "megadebrid", + providerSecondary: "none", + providerTertiary: "none", + outputDir: path.join(root, "downloads"), + extractDir: path.join(root, "extract"), + autoExtract: false, + autoReconnect: false, + enableIntegrityCheck: false, + maxParallel: 1 + }, + emptySession(), + createStoragePaths(path.join(root, "state")), + { + megaWebUnrestrict: vi.fn(async (_link: string, _signal?: AbortSignal) => { + unrestrictCalls += 1; + throw new Error("Fichier supprimé chez l'hébergeur"); + }) + } + ); + + manager.addPackages([{ + name: "mega-transient", + links: ["https://rapidgator.net/file/mega-transient.part1.rar.html"] + }]); + + await manager.start(); + await waitFor( + () => String(Object.values(manager.getSnapshot().session.items)[0]?.fullStatus || "").includes("neuer Versuch"), + 12000 + ); + + const item = Object.values(manager.getSnapshot().session.items)[0]; + expect(item.status).not.toBe("failed"); + expect(unrestrictCalls).toBeGreaterThanOrEqual(1); + expect(item.lastError).toBe("Datei beim Hoster gerade nicht abrufbar"); + expect(String(item.fullStatus || "")).toContain("Datei beim Hoster gerade nicht abrufbar"); + expect(String(item.fullStatus || "")).toContain("neuer Versuch"); + const countdownMatch = String(item.fullStatus || "").match(/\((\d+)s\)/); + expect(countdownMatch).not.toBeNull(); + expect(Number(countdownMatch![1])).toBeLessThanOrEqual(10); + + await waitFor(() => unrestrictCalls >= 2, 12000); + + manager.stop(); + await new Promise((resolve) => setTimeout(resolve, 150)); + }, 20000); + + it("limits Mega-Debrid Web validating starts to one item at a time", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-")); + tempDirs.push(root); + + let unrestrictCalls = 0; + const pendingRejectors = new Set<(error: Error) => void>(); + + const manager = new DownloadManager( + { + ...defaultSettings(), + megaLogin: "mega-user", + megaPassword: "mega-pass", + megaCredentials: "mega-user:mega-pass", + megaDebridWebEnabled: true, + megaDebridApiEnabled: false, + megaDebridPreferApi: false, + providerOrder: [], + providerPrimary: "megadebrid", + providerSecondary: "none", + providerTertiary: "none", + outputDir: path.join(root, "downloads"), + extractDir: path.join(root, "extract"), + autoExtract: false, + autoReconnect: false, + enableIntegrityCheck: false, + maxParallel: 3 + }, + emptySession(), + createStoragePaths(path.join(root, "state")), + { + megaWebUnrestrict: vi.fn(async (_link: string, signal?: AbortSignal) => { + unrestrictCalls += 1; + return await new Promise((resolve, reject) => { + const rejector = (error: Error): void => { + signal?.removeEventListener("abort", onAbort); + pendingRejectors.delete(rejector); + reject(error); + }; + const onAbort = (): void => { + rejector(new Error("aborted:test-mega-web")); + }; + if (signal?.aborted) { + onAbort(); + return; + } + signal?.addEventListener("abort", onAbort, { once: true }); + pendingRejectors.add(rejector); + }); + }) + } + ); + + manager.addPackages([{ + name: "mega-web-serialized", + links: [ + "https://rapidgator.net/file/mega-web-1.part1.rar.html", + "https://rapidgator.net/file/mega-web-2.part2.rar.html", + "https://rapidgator.net/file/mega-web-3.part3.rar.html" + ] + }]); + + await manager.start(); + await waitFor(() => unrestrictCalls === 1, 10000); + await new Promise((resolve) => setTimeout(resolve, 250)); + + const items = Object.values(manager.getSnapshot().session.items); + expect(items.filter((item) => item.status === "validating")).toHaveLength(1); + expect(items.filter((item) => item.status === "queued")).toHaveLength(2); + expect(unrestrictCalls).toBe(1); + + manager.stop(); + for (const reject of Array.from(pendingRejectors)) { + reject(new Error("aborted:test-mega-web")); + } + await new Promise((resolve) => setTimeout(resolve, 150)); + }); + + it("runs one Mega-Debrid Web validation per available account in parallel", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-")); + tempDirs.push(root); + + let unrestrictCalls = 0; + const pendingRejectors = new Set<(error: Error) => void>(); + + const manager = new DownloadManager( + { + ...defaultSettings(), + megaCredentials: "mega-user-a:pass-a\nmega-user-b:pass-b", + megaDebridWebEnabled: true, + megaDebridApiEnabled: false, + megaDebridPreferApi: false, + providerOrder: [], + providerPrimary: "megadebrid", + providerSecondary: "none", + providerTertiary: "none", + outputDir: path.join(root, "downloads"), + extractDir: path.join(root, "extract"), + autoExtract: false, + autoReconnect: false, + enableIntegrityCheck: false, + maxParallel: 4 + }, + emptySession(), + createStoragePaths(path.join(root, "state")), + { + megaWebUnrestrict: vi.fn(async (_link: string, signal?: AbortSignal) => { + unrestrictCalls += 1; + return await new Promise((resolve, reject) => { + const rejector = (error: Error): void => { + signal?.removeEventListener("abort", onAbort); + pendingRejectors.delete(rejector); + reject(error); + }; + const onAbort = (): void => { + rejector(new Error("aborted:test-mega-web")); + }; + if (signal?.aborted) { + onAbort(); + return; + } + signal?.addEventListener("abort", onAbort, { once: true }); + pendingRejectors.add(rejector); + }); + }) + } + ); + + manager.addPackages([{ + name: "mega-web-parallel", + links: [ + "https://rapidgator.net/file/mega-web-1.part1.rar.html", + "https://rapidgator.net/file/mega-web-2.part2.rar.html", + "https://rapidgator.net/file/mega-web-3.part3.rar.html" + ] + }]); + + await manager.start(); + await waitFor(() => unrestrictCalls === 2, 10000); + await new Promise((resolve) => setTimeout(resolve, 250)); + + const items = Object.values(manager.getSnapshot().session.items); + expect(items.filter((item) => item.status === "validating")).toHaveLength(2); + expect(items.filter((item) => item.status === "queued")).toHaveLength(1); + expect(unrestrictCalls).toBe(2); + + manager.stop(); + for (const reject of Array.from(pendingRejectors)) { + reject(new Error("aborted:test-mega-web")); + } + await new Promise((resolve) => setTimeout(resolve, 150)); + }); + + it("does not over-admit Mega-Debrid Web validations when an account is in cooldown", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-")); + tempDirs.push(root); + + let unrestrictCalls = 0; + const pendingRejectors = new Set<(error: Error) => void>(); + + primeMegaDebridRuntimeCooldownForTests(`${getMegaDebridAccountId("mega-user-b")}:web`, 120000); + + const manager = new DownloadManager( + { + ...defaultSettings(), + megaCredentials: "mega-user-a:pass-a\nmega-user-b:pass-b", + megaDebridWebEnabled: true, + megaDebridApiEnabled: false, + megaDebridPreferApi: false, + providerOrder: [], + providerPrimary: "megadebrid", + providerSecondary: "none", + providerTertiary: "none", + outputDir: path.join(root, "downloads"), + extractDir: path.join(root, "extract"), + autoExtract: false, + autoReconnect: false, + enableIntegrityCheck: false, + maxParallel: 4 + }, + emptySession(), + createStoragePaths(path.join(root, "state")), + { + megaWebUnrestrict: vi.fn(async (_link: string, signal?: AbortSignal) => { + unrestrictCalls += 1; + return await new Promise((resolve, reject) => { + const rejector = (error: Error): void => { + signal?.removeEventListener("abort", onAbort); + pendingRejectors.delete(rejector); + reject(error); + }; + const onAbort = (): void => { + rejector(new Error("aborted:test-mega-web")); + }; + if (signal?.aborted) { + onAbort(); + return; + } + signal?.addEventListener("abort", onAbort, { once: true }); + pendingRejectors.add(rejector); + }); + }) + } + ); + + manager.addPackages([{ + name: "mega-web-cooldown", + links: [ + "https://rapidgator.net/file/mega-web-1.part1.rar.html", + "https://rapidgator.net/file/mega-web-2.part2.rar.html", + "https://rapidgator.net/file/mega-web-3.part3.rar.html" + ] + }]); + + await manager.start(); + await waitFor(() => unrestrictCalls === 1, 10000); + await new Promise((resolve) => setTimeout(resolve, 250)); + + const items = Object.values(manager.getSnapshot().session.items); + expect(items.filter((item) => item.status === "validating")).toHaveLength(1); + expect(items.filter((item) => item.status === "queued")).toHaveLength(2); + expect(unrestrictCalls).toBe(1); + + manager.stop(); + for (const reject of Array.from(pendingRejectors)) { + reject(new Error("aborted:test-mega-web")); + } + await new Promise((resolve) => setTimeout(resolve, 150)); + }); + + it("resets Mega-Debrid per-account daily usage at the day boundary (not just provider/debrid-link)", () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-")); + tempDirs.push(root); + const acctId = getMegaDebridAccountId("mega-user"); + + const manager = new DownloadManager( + { + ...defaultSettings(), + megaCredentials: "mega-user:mega-pass", + providerDailyUsageDay: "2000-01-01", + providerDailyUsageBytes: { megadebrid: 9_000_000_000 } as Record, + megaDebridAccountDailyUsageBytes: { [acctId]: 9_000_000_000 } as Record, + megaDebridAccountDailyLimitBytes: { [acctId]: 1_000_000_000 } as Record + }, + emptySession(), + createStoragePaths(path.join(root, "state")), + {} + ); + + const snap = manager.getSnapshot(); + expect(snap.settings.providerDailyUsageDay).not.toBe("2000-01-01"); + expect(snap.settings.megaDebridAccountDailyUsageBytes || {}).toEqual({}); + expect(snap.settings.providerDailyUsageBytes || {}).toEqual({}); + }); + + it("does not freeze the scheduler when a reset item's old task is parked in a non-abort-observing await", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-")); + tempDirs.push(root); + + let convCalls = 0; + const manager = new DownloadManager( + { + ...defaultSettings(), + megaCredentials: "mega-user:mega-pass", + megaDebridWebEnabled: true, + megaDebridApiEnabled: false, + megaDebridPreferApi: false, + providerOrder: [], + providerPrimary: "megadebrid", + providerSecondary: "none", + providerTertiary: "none", + outputDir: path.join(root, "downloads"), + extractDir: path.join(root, "extract"), + autoExtract: false, + autoReconnect: false, + enableIntegrityCheck: false, + maxParallel: 4 + }, + emptySession(), + createStoragePaths(path.join(root, "state")), + { + megaWebUnrestrict: vi.fn(async (): Promise => { + convCalls += 1; + return await new Promise(() => { /* never settles, ignores abort */ }); + }) + } + ); + + manager.addPackages([{ + name: "freeze-repro", + links: [ + "https://rapidgator.net/file/freeze-1.part1.rar.html", + "https://rapidgator.net/file/freeze-2.part2.rar.html", + "https://rapidgator.net/file/freeze-3.part3.rar.html" + ] + }]); + + await manager.start(); + await waitFor(() => convCalls === 1, 10000); + + const validatingItem = Object.values(manager.getSnapshot().session.items).find((i) => i.status === "validating"); + expect(validatingItem).toBeTruthy(); + + manager.resetItems([validatingItem!.id]); + + await waitFor(() => convCalls === 2, 5000); + expect(convCalls).toBe(2); + + manager.stop(); + await new Promise((resolve) => setTimeout(resolve, 150)); + }, 20000); + + it("serializes Mega-Debrid API conversions to one per account (no single-token hammering)", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-")); + tempDirs.push(root); + + let getLinkCalls = 0; + const pendingRejectors = new Set<(error: Error) => void>(); + globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit): Promise => { + const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : (input as Request).url; + if (url.includes("action=connectUser")) { + return new Response(JSON.stringify({ response_code: "ok", token: `tok-${getLinkCalls}-${url.length}` }), { status: 200, headers: { "Content-Type": "application/json" } }); + } + if (url.includes("action=getLink")) { + getLinkCalls += 1; + const signal = init?.signal; + return await new Promise((_resolve, reject) => { + const rejector = (error: Error): void => { + signal?.removeEventListener("abort", onAbort); + pendingRejectors.delete(rejector); + reject(error); + }; + const onAbort = (): void => { rejector(new Error("aborted:test-api")); }; + if (signal?.aborted) { onAbort(); return; } + signal?.addEventListener("abort", onAbort, { once: true }); + pendingRejectors.add(rejector); + }); + } + return originalFetch(input, init); + }) as typeof fetch; + + const manager = new DownloadManager( + { + ...defaultSettings(), + megaCredentials: "mega-user-a:pass-a\nmega-user-b:pass-b", + megaDebridApiEnabled: true, + megaDebridWebEnabled: false, + megaDebridPreferApi: true, + providerOrder: [], + providerPrimary: "megadebrid", + providerSecondary: "none", + providerTertiary: "none", + outputDir: path.join(root, "downloads"), + extractDir: path.join(root, "extract"), + autoExtract: false, + autoReconnect: false, + enableIntegrityCheck: false, + maxParallel: 6 + }, + emptySession(), + createStoragePaths(path.join(root, "state")), + {} + ); + + manager.addPackages([{ + name: "mega-api-serialized", + links: [ + "https://rapidgator.net/file/api-1.part1.rar.html", + "https://rapidgator.net/file/api-2.part2.rar.html", + "https://rapidgator.net/file/api-3.part3.rar.html" + ] + }]); + + await manager.start(); + await waitFor(() => getLinkCalls === 2, 10000); + await new Promise((resolve) => setTimeout(resolve, 250)); + + const items = Object.values(manager.getSnapshot().session.items); + expect(items.filter((item) => item.status === "validating")).toHaveLength(2); + expect(items.filter((item) => item.status === "queued")).toHaveLength(1); + expect(getLinkCalls).toBe(2); + + manager.stop(); + for (const reject of Array.from(pendingRejectors)) { + reject(new Error("aborted:test-api")); + } + await new Promise((resolve) => setTimeout(resolve, 150)); + }); + + it("limits Mega-Debrid API conversions to one at a time with a single account", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-")); + tempDirs.push(root); + + let getLinkCalls = 0; + const pendingRejectors = new Set<(error: Error) => void>(); + globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit): Promise => { + const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : (input as Request).url; + if (url.includes("action=connectUser")) { + return new Response(JSON.stringify({ response_code: "ok", token: "tok-single" }), { status: 200, headers: { "Content-Type": "application/json" } }); + } + if (url.includes("action=getLink")) { + getLinkCalls += 1; + const signal = init?.signal; + return await new Promise((_resolve, reject) => { + const rejector = (error: Error): void => { + signal?.removeEventListener("abort", onAbort); + pendingRejectors.delete(rejector); + reject(error); + }; + const onAbort = (): void => { rejector(new Error("aborted:test-api")); }; + if (signal?.aborted) { onAbort(); return; } + signal?.addEventListener("abort", onAbort, { once: true }); + pendingRejectors.add(rejector); + }); + } + return originalFetch(input, init); + }) as typeof fetch; + + const manager = new DownloadManager( + { + ...defaultSettings(), + megaCredentials: "mega-user:mega-pass", + megaDebridApiEnabled: true, + megaDebridWebEnabled: false, + megaDebridPreferApi: true, + providerOrder: [], + providerPrimary: "megadebrid", + providerSecondary: "none", + providerTertiary: "none", + outputDir: path.join(root, "downloads"), + extractDir: path.join(root, "extract"), + autoExtract: false, + autoReconnect: false, + enableIntegrityCheck: false, + maxParallel: 6 + }, + emptySession(), + createStoragePaths(path.join(root, "state")), + {} + ); + + manager.addPackages([{ + name: "mega-api-single", + links: [ + "https://rapidgator.net/file/single-1.part1.rar.html", + "https://rapidgator.net/file/single-2.part2.rar.html", + "https://rapidgator.net/file/single-3.part3.rar.html" + ] + }]); + + await manager.start(); + await waitFor(() => getLinkCalls === 1, 10000); + await new Promise((resolve) => setTimeout(resolve, 250)); + + const items = Object.values(manager.getSnapshot().session.items); + expect(items.filter((item) => item.status === "validating")).toHaveLength(1); + expect(items.filter((item) => item.status === "queued")).toHaveLength(2); + expect(getLinkCalls).toBe(1); + + manager.stop(); + for (const reject of Array.from(pendingRejectors)) { + reject(new Error("aborted:test-api")); + } + await new Promise((resolve) => setTimeout(resolve, 150)); + }); + + it("shows the same AllDebrid countdown for all immediately free slots", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-")); + tempDirs.push(root); + const chunk = Buffer.alloc(256 * 1024, 9); + const totalLinks = 8; + + const server = http.createServer((req, res) => { + const route = req.url || ""; + if (!/^\/ad-web-\d+$/.test(route)) { + res.statusCode = 404; + res.end("not-found"); + return; + } + + let sent = 0; + const totalChunks = 10; + res.statusCode = 200; + res.setHeader("Accept-Ranges", "bytes"); + res.setHeader("Content-Length", String(chunk.length * totalChunks)); + const timer = setInterval(() => { + if (sent >= totalChunks) { + clearInterval(timer); + res.end(); + return; + } + res.write(chunk); + sent += 1; + }, 700); + res.on("close", () => clearInterval(timer)); + }); + + server.listen(0, "127.0.0.1"); + await once(server, "listening"); + + const address = server.address(); + if (!address || typeof address === "string") { + throw new Error("server address unavailable"); + } + + const links = Array.from({ length: totalLinks }, (_, index) => `https://rapidgator.net/file/web-${index + 1}/sample.part${index + 1}.rar.html`); + + try { + const manager = new DownloadManager( + { + ...defaultSettings(), + allDebridToken: "ad-token", + allDebridUseWebLogin: true, + providerOrder: [], + providerPrimary: "alldebrid", + providerSecondary: "none", + providerTertiary: "none", + outputDir: path.join(root, "downloads"), + extractDir: path.join(root, "extract"), + autoExtract: false, + autoReconnect: false, + enableIntegrityCheck: false, + maxParallel: 5 + }, + emptySession(), + createStoragePaths(path.join(root, "state")), + { + allDebridWebUnrestrict: async (link) => { + const match = link.match(/web-(\d+)/); + const slot = Number(match?.[1] || 1); + return { + fileName: `ad-web-${slot}.bin`, + directUrl: `http://127.0.0.1:${address.port}/ad-web-${slot}`, + fileSize: chunk.length * 10, + retriesUsed: 0 + }; + } + } + ); + + manager.addPackages([{ name: "ad-web-visibility", links }]); + await manager.start(); + + await waitFor(() => { + const items = Object.values(manager.getSnapshot().session.items); + const countdownItems = items.filter((item) => /^AllDebrid Start in \d+s$/.test(item.fullStatus || "")); + return countdownItems.length === 5; + }, 10000); + + const items = Object.values(manager.getSnapshot().session.items); + const activeCount = items.filter((item) => item.status === "downloading" || item.status === "validating").length; + const countdownItems = items.filter((item) => /^AllDebrid Start in \d+s$/.test(item.fullStatus || "")); + const uniqueCountdowns = new Set(countdownItems.map((item) => item.fullStatus || "")); + + expect(activeCount).toBe(0); + expect(countdownItems.length).toBe(5); + expect(uniqueCountdowns.size).toBe(1); + + manager.stop(); + await waitFor(() => !manager.getSnapshot().session.running, 15000); + } finally { + server.close(); + await once(server, "close"); + } + }, 20000); + + it("starts immediately free AllDebrid slots after the same 3 second delay", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-")); + tempDirs.push(root); + const binary = Buffer.alloc(512 * 1024, 5); + + const server = http.createServer((req, res) => { + const route = req.url || ""; + if (route !== "/ad-1" && route !== "/ad-2" && route !== "/ad-3") { + res.statusCode = 404; + res.end("not-found"); + return; + } + setTimeout(() => { + res.statusCode = 200; + res.setHeader("Accept-Ranges", "bytes"); + res.setHeader("Content-Length", String(binary.length)); + res.end(binary); + }, 6500); + }); + + server.listen(0, "127.0.0.1"); + await once(server, "listening"); + + const address = server.address(); + if (!address || typeof address === "string") { + throw new Error("server address unavailable"); + } + + const link1 = "https://host-a.example/file1.bin"; + const link2 = "https://host-b.example/file2.bin"; + const link3 = "https://host-c.example/file3.bin"; + const directUrl1 = `http://127.0.0.1:${address.port}/ad-1`; + const directUrl2 = `http://127.0.0.1:${address.port}/ad-2`; + const directUrl3 = `http://127.0.0.1:${address.port}/ad-3`; + + globalThis.fetch = async (input: RequestInfo | URL, init?: RequestInit): Promise => { + const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url; + if (url.includes("/link/unlock")) { + const body = init?.body; + const bodyText = body instanceof URLSearchParams ? body.toString() : String(body || ""); + const originalLink = new URLSearchParams(bodyText).get("link") || ""; + const directUrl = originalLink === link2 ? directUrl2 : originalLink === link3 ? directUrl3 : directUrl1; + const fileName = originalLink === link2 ? "ad-2.bin" : originalLink === link3 ? "ad-3.bin" : "ad-1.bin"; + return new Response( + JSON.stringify({ + status: "success", + data: { + link: directUrl, + filename: fileName, + filesize: binary.length + } + }), + { + status: 200, + headers: { "Content-Type": "application/json" } + } + ); + } + return originalFetch(input, init); + }; + + try { + const manager = new DownloadManager( + { + ...defaultSettings(), + allDebridToken: "ad-token", + providerOrder: [], + providerPrimary: "alldebrid", + providerSecondary: "none", + providerTertiary: "none", + outputDir: path.join(root, "downloads"), + extractDir: path.join(root, "extract"), + autoExtract: false, + autoReconnect: false, + enableIntegrityCheck: false, + maxParallel: 3 + }, + emptySession(), + createStoragePaths(path.join(root, "state")) + ); + + manager.addPackages([{ name: "ad-paced", links: [link1, link2, link3] }]); + await manager.start(); + + const managerInternals = manager as unknown as { + retryAfterByItem: Map; + }; + await waitFor(() => managerInternals.retryAfterByItem.size >= 3, 5000); + + const now = Date.now(); + const readyTimes = [...managerInternals.retryAfterByItem.values()].sort((a, b) => a - b); + expect(readyTimes.length).toBe(3); + const firstDelay = readyTimes[0] - now; + const lastDelay = readyTimes[readyTimes.length - 1] - now; + expect(firstDelay).toBeGreaterThan(2000); + expect(firstDelay).toBeLessThan(4500); + expect(lastDelay - firstDelay).toBeLessThan(500); + + manager.stop(); + await waitFor(() => !manager.getSnapshot().session.running, 15000); + } finally { + server.close(); + await once(server, "close"); + } + }, 20000); + + it("tops up newly freed AllDebrid slots with a fresh 3 second countdown", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-")); + tempDirs.push(root); + const shortBinary = Buffer.alloc(64 * 1024, 7); + const longBinary = Buffer.alloc(512 * 1024, 8); + + const server = http.createServer((req, res) => { + const route = req.url || ""; + if (route === "/ad-1") { + setTimeout(() => { + res.statusCode = 200; + res.setHeader("Accept-Ranges", "bytes"); + res.setHeader("Content-Length", String(shortBinary.length)); + res.end(shortBinary); + }, 150); + return; + } + if (route === "/ad-2" || route === "/ad-3" || route === "/ad-4") { + setTimeout(() => { + res.statusCode = 200; + res.setHeader("Accept-Ranges", "bytes"); + res.setHeader("Content-Length", String(longBinary.length)); + res.end(longBinary); + }, 6000); + return; + } + res.statusCode = 404; + res.end("not-found"); + }); + + server.listen(0, "127.0.0.1"); + await once(server, "listening"); + + const address = server.address(); + if (!address || typeof address === "string") { + throw new Error("server address unavailable"); + } + + const links = [ + "https://rapidgator.net/file/ad-topup-1", + "https://rapidgator.net/file/ad-topup-2", + "https://rapidgator.net/file/ad-topup-3", + "https://rapidgator.net/file/ad-topup-4" + ]; + + try { + const manager = new DownloadManager( + { + ...defaultSettings(), + allDebridToken: "ad-token", + allDebridUseWebLogin: true, + providerOrder: [], + providerPrimary: "alldebrid", + providerSecondary: "none", + providerTertiary: "none", + outputDir: path.join(root, "downloads"), + extractDir: path.join(root, "extract"), + autoExtract: false, + autoReconnect: false, + enableIntegrityCheck: false, + maxParallel: 3 + }, + emptySession(), + createStoragePaths(path.join(root, "state")), + { + allDebridWebUnrestrict: async (link) => { + const slot = links.indexOf(link) + 1; + return { + fileName: `ad-topup-${slot}.bin`, + directUrl: `http://127.0.0.1:${address.port}/ad-${slot}`, + fileSize: slot === 1 ? shortBinary.length : longBinary.length, + retriesUsed: 0 + }; + } + } + ); + + manager.addPackages([{ name: "ad-topup", links }]); + await manager.start(); + + await waitFor(() => { + const items = Object.values(manager.getSnapshot().session.items); + return items.filter((item) => item.status === "downloading").length === 3; + }, 12000); + + await waitFor(() => { + const items = Object.values(manager.getSnapshot().session.items); + const completedCount = items.filter((item) => item.status === "completed").length; + const countdownItems = items.filter((item) => /^AllDebrid Start in [123]s$/.test(item.fullStatus || "")); + return completedCount >= 1 && countdownItems.length === 1; + }, 12000); + + manager.stop(); + await waitFor(() => !manager.getSnapshot().session.running, 15000); + } finally { + server.close(); + await once(server, "close"); + } + }, 25000); + + it("creates extract directory only at extraction and marks items as Entpackt", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-")); + tempDirs.push(root); + + const zip = new AdmZip(); + zip.addFile("inside.txt", Buffer.from("ok")); + const archive = zip.toBuffer(); + + const server = http.createServer((req, res) => { + if ((req.url || "") !== "/archive") { + res.statusCode = 404; + res.end("not-found"); + return; + } + setTimeout(() => { + res.statusCode = 200; + res.setHeader("Accept-Ranges", "bytes"); + res.setHeader("Content-Length", String(archive.length)); + res.end(archive); + }, 450); + }); + + server.listen(0, "127.0.0.1"); + await once(server, "listening"); + + const address = server.address(); + if (!address || typeof address === "string") { + throw new Error("server address unavailable"); + } + const directUrl = `http://127.0.0.1:${address.port}/archive`; + + globalThis.fetch = async (input: RequestInfo | URL, init?: RequestInit): Promise => { + const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url; + if (url.includes("/unrestrict/link")) { + return new Response( + JSON.stringify({ + download: directUrl, + filename: "sample.zip", + filesize: archive.length + }), + { + status: 200, + headers: { "Content-Type": "application/json" } + } + ); + } + return originalFetch(input, init); + }; + + try { + const manager = new DownloadManager( + { + ...defaultSettings(), + token: "rd-token", + outputDir: path.join(root, "downloads"), + extractDir: path.join(root, "extract"), + createExtractSubfolder: true, + autoExtract: true, + enableIntegrityCheck: false, + cleanupMode: "none" + }, + emptySession(), + createStoragePaths(path.join(root, "state")) + ); + + manager.addPackages([{ name: "zip-pack", links: ["https://dummy/archive"] }]); + const pkgId = manager.getSnapshot().session.packageOrder[0]; + const extractDir = manager.getSnapshot().session.packages[pkgId]?.extractDir || ""; + expect(extractDir).toBeTruthy(); + expect(fs.existsSync(extractDir)).toBe(false); + + await manager.start(); + await new Promise((resolve) => setTimeout(resolve, 140)); + expect(fs.existsSync(extractDir)).toBe(false); + + await waitFor(() => fs.existsSync(path.join(extractDir, "inside.txt")), 30000); + + const snapshot = manager.getSnapshot(); + const item = Object.values(snapshot.session.items)[0]; + expect(item?.status).toBe("completed"); + expect(item?.fullStatus.startsWith("Entpackt - Done")).toBe(true); + expect(fs.existsSync(extractDir)).toBe(true); + expect(fs.existsSync(path.join(extractDir, "inside.txt"))).toBe(true); + } finally { + server.close(); + await once(server, "close"); + } + }, 35000); + + it("keeps accurate summary when completed items are cleaned immediately", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-")); + tempDirs.push(root); + const binary = Buffer.alloc(128 * 1024, 3); + + const server = http.createServer((req, res) => { + if ((req.url || "") !== "/file") { + res.statusCode = 404; + res.end("not-found"); + return; + } + res.statusCode = 200; + res.setHeader("Accept-Ranges", "bytes"); + res.setHeader("Content-Length", String(binary.length)); + res.end(binary); + }); + + server.listen(0, "127.0.0.1"); + await once(server, "listening"); + + const address = server.address(); + if (!address || typeof address === "string") { + throw new Error("server address unavailable"); + } + const directUrl = `http://127.0.0.1:${address.port}/file`; + + globalThis.fetch = async (input: RequestInfo | URL, init?: RequestInit): Promise => { + const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url; + if (url.includes("/unrestrict/link")) { + return new Response( + JSON.stringify({ + download: directUrl, + filename: "episode.mkv", + filesize: binary.length + }), + { + status: 200, + headers: { "Content-Type": "application/json" } + } + ); + } + return originalFetch(input, init); + }; + + try { + const manager = new DownloadManager( + { + ...defaultSettings(), + token: "rd-token", + outputDir: path.join(root, "downloads"), + extractDir: path.join(root, "extract"), + autoExtract: false, + completedCleanupPolicy: "immediate" + }, + emptySession(), + createStoragePaths(path.join(root, "state")) + ); + + manager.addPackages([{ name: "cleanup", links: ["https://dummy/cleanup"] }]); + await manager.start(); + await waitFor(() => !manager.getSnapshot().session.running, 25000); + + const snapshot = manager.getSnapshot(); + const summary = manager.getSummary(); + expect(Object.keys(snapshot.session.items)).toHaveLength(0); + expect(summary).not.toBeNull(); + expect(summary?.total).toBe(1); + expect(summary?.success).toBe(1); + expect(summary?.failed).toBe(0); + expect(summary?.cancelled).toBe(0); + } finally { + server.close(); + await once(server, "close"); + } + }); + + it("removes finished package when package_done cleanup policy is enabled", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-")); + tempDirs.push(root); + + const zip = new AdmZip(); + zip.addFile("episode.txt", Buffer.from("ok")); + zip.addFile("padding.bin", crypto.randomBytes(8 * 1024)); + const archiveBinary = zip.toBuffer(); + + const server = http.createServer((req, res) => { + if ((req.url || "") !== "/cleanup-package") { + res.statusCode = 404; + res.end("not-found"); + return; + } + res.statusCode = 200; + res.setHeader("Accept-Ranges", "bytes"); + res.setHeader("Content-Length", String(archiveBinary.length)); + res.end(archiveBinary); + }); + + server.listen(0, "127.0.0.1"); + await once(server, "listening"); + + const address = server.address(); + if (!address || typeof address === "string") { + throw new Error("server address unavailable"); + } + const directUrl = `http://127.0.0.1:${address.port}/cleanup-package`; + + globalThis.fetch = async (input: RequestInfo | URL, init?: RequestInit): Promise => { + const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url; + if (url.includes("/unrestrict/link")) { + return new Response( + JSON.stringify({ + download: directUrl, + filename: "cleanup-package.zip", + filesize: archiveBinary.length + }), + { + status: 200, + headers: { "Content-Type": "application/json" } + } + ); + } + return originalFetch(input, init); + }; + + try { + const manager = new DownloadManager( + { + ...defaultSettings(), + token: "rd-token", + outputDir: path.join(root, "downloads"), + extractDir: path.join(root, "extract"), + autoExtract: true, + enableIntegrityCheck: false, + cleanupMode: "none", + completedCleanupPolicy: "package_done" + }, + emptySession(), + createStoragePaths(path.join(root, "state")) + ); + + manager.addPackages([{ name: "cleanup-package", links: ["https://dummy/cleanup-package"] }]); + await manager.start(); + await waitFor(() => !manager.getSnapshot().session.running, 30000); + await waitFor(() => manager.getSnapshot().session.packageOrder.length === 0, 12000); + + const snapshot = manager.getSnapshot(); + const summary = manager.getSummary(); + expect(snapshot.session.packageOrder).toHaveLength(0); + expect(Object.keys(snapshot.session.items)).toHaveLength(0); + expect(summary).not.toBeNull(); + expect(summary?.success).toBe(1); + } finally { + server.close(); + await once(server, "close"); + } + }, 35000); + + it("keeps partially failed packages under package_done cleanup policy", () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-")); + tempDirs.push(root); + + const session = emptySession(); + const packageId = "package-done-partial-failure-pkg"; + const completedItemId = "package-done-partial-failure-completed"; + const failedItemId = "package-done-partial-failure-failed"; + const createdAt = Date.now() - 10_000; + + session.packageOrder = [packageId]; + session.packages[packageId] = { + id: packageId, + name: "package-done-partial-failure", + outputDir: path.join(root, "downloads", "package-done-partial-failure"), + extractDir: path.join(root, "extract", "package-done-partial-failure"), + status: "failed", + itemIds: [completedItemId, failedItemId], + cancelled: false, + enabled: true, + createdAt, + updatedAt: createdAt + }; + session.items[completedItemId] = { + id: completedItemId, + packageId, + url: "https://dummy/package-done-partial-failure-completed", + provider: "realdebrid", + status: "completed", + retries: 0, + speedBps: 0, + downloadedBytes: 1024, + totalBytes: 1024, + progressPercent: 100, + fileName: "episode01.rar", + targetPath: path.join(root, "downloads", "package-done-partial-failure", "episode01.rar"), + resumable: true, + attempts: 1, + lastError: "", + fullStatus: "Entpackt - Done (<1s)", + createdAt, + updatedAt: createdAt + }; + session.items[failedItemId] = { + id: failedItemId, + packageId, + url: "https://dummy/package-done-partial-failure-failed", + provider: "realdebrid", + status: "failed", + retries: 1, + speedBps: 0, + downloadedBytes: 0, + totalBytes: 1024, + progressPercent: 0, + fileName: "episode02.rar", + targetPath: path.join(root, "downloads", "package-done-partial-failure", "episode02.rar"), + resumable: true, + attempts: 1, + lastError: "checksum", + fullStatus: "Entpack-Fehler: Checksum error", + createdAt, + updatedAt: createdAt + }; + + const manager = new DownloadManager( + { + ...defaultSettings(), + token: "rd-token", + outputDir: path.join(root, "downloads"), + extractDir: path.join(root, "extract"), + autoExtract: true, + enableIntegrityCheck: false, + cleanupMode: "delete", + completedCleanupPolicy: "package_done" + }, + session, + createStoragePaths(path.join(root, "state")) + ); + + (manager as any).applyPackageDoneCleanup(packageId); + (manager as any).applyCompletedCleanupPolicy(packageId, completedItemId); + + const snapshot = manager.getSnapshot(); + expect(snapshot.session.packages[packageId]).toBeDefined(); + expect(snapshot.session.items[completedItemId]).toBeDefined(); + expect(snapshot.session.items[failedItemId]).toBeDefined(); + expect(snapshot.session.packageOrder).toEqual([packageId]); + }); + + it("waits for deferred MKV collection before package_done cleanup removes the package", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-")); + tempDirs.push(root); + + const zip = new AdmZip(); + zip.addFile("Season 1/Episode01.mkv", Buffer.from("video")); + zip.addFile("Season 1/sample.txt", Buffer.from("sample")); + zip.addFile("padding.bin", crypto.randomBytes(8 * 1024)); + const archiveBinary = zip.toBuffer(); + + const server = http.createServer((req, res) => { + if ((req.url || "") !== "/cleanup-package-mkv") { + res.statusCode = 404; + res.end("not-found"); + return; + } + res.statusCode = 200; + res.setHeader("Accept-Ranges", "bytes"); + res.setHeader("Content-Length", String(archiveBinary.length)); + res.end(archiveBinary); + }); + + server.listen(0, "127.0.0.1"); + await once(server, "listening"); + + const address = server.address(); + if (!address || typeof address === "string") { + throw new Error("server address unavailable"); + } + const directUrl = `http://127.0.0.1:${address.port}/cleanup-package-mkv`; + + globalThis.fetch = async (input: RequestInfo | URL, init?: RequestInit): Promise => { + const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url; + if (url.includes("/unrestrict/link")) { + return new Response( + JSON.stringify({ + download: directUrl, + filename: "cleanup-package-mkv.zip", + filesize: archiveBinary.length + }), + { + status: 200, + headers: { "Content-Type": "application/json" } + } + ); + } + return originalFetch(input, init); + }; + + try { + const extractRoot = path.join(root, "extract"); + const mkvLibraryDir = path.join(root, "mkv-library"); + const manager = new DownloadManager( + { + ...defaultSettings(), + token: "rd-token", + outputDir: path.join(root, "downloads"), + extractDir: extractRoot, + autoExtract: true, + autoRename4sf4sj: false, + collectMkvToLibrary: true, + mkvLibraryDir, + enableIntegrityCheck: false, + cleanupMode: "delete", + completedCleanupPolicy: "package_done" + }, + emptySession(), + createStoragePaths(path.join(root, "state")) + ); + + manager.addPackages([{ name: "cleanup-package-mkv", links: ["https://dummy/cleanup-package-mkv"] }]); + await manager.start(); + await waitFor(() => !manager.getSnapshot().session.running, 30000); + await waitFor(() => manager.getSnapshot().session.packageOrder.length === 0, 12000); + + const flattenedPath = path.join(mkvLibraryDir, "Episode01.mkv"); + const extractDir = path.join(extractRoot, "cleanup-package-mkv"); + expect(fs.existsSync(flattenedPath)).toBe(true); + expect(fs.existsSync(extractDir)).toBe(false); + expect(Object.keys(manager.getSnapshot().session.items)).toHaveLength(0); + } finally { + server.close(); + await once(server, "close"); + } + }, 35000); + + it("cleans link, sample and residual artifacts before package_done cleanup removes the package", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-")); + tempDirs.push(root); + + const zip = new AdmZip(); + zip.addFile("Season 1/Episode01.mkv", Buffer.from("video")); + zip.addFile("Season 1/episode.links.txt", Buffer.from("https://example.com/file")); + zip.addFile("Season 1/cover.jpg", Buffer.from("cover")); + zip.addFile("Season 1/sample/sample.mkv", Buffer.from("sample-video")); + zip.addFile("Season 1/sample/readme.txt", Buffer.from("sample-text")); + zip.addFile("padding.bin", crypto.randomBytes(8 * 1024)); + const archiveBinary = zip.toBuffer(); + + const server = http.createServer((req, res) => { + if ((req.url || "") !== "/cleanup-package-full") { + res.statusCode = 404; + res.end("not-found"); + return; + } + res.statusCode = 200; + res.setHeader("Accept-Ranges", "bytes"); + res.setHeader("Content-Length", String(archiveBinary.length)); + res.end(archiveBinary); + }); + + server.listen(0, "127.0.0.1"); + await once(server, "listening"); + + const address = server.address(); + if (!address || typeof address === "string") { + throw new Error("server address unavailable"); + } + const directUrl = `http://127.0.0.1:${address.port}/cleanup-package-full`; + + globalThis.fetch = async (input: RequestInfo | URL, init?: RequestInit): Promise => { + const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url; + if (url.includes("/unrestrict/link")) { + return new Response( + JSON.stringify({ + download: directUrl, + filename: "cleanup-package-full.zip", + filesize: archiveBinary.length + }), + { + status: 200, + headers: { "Content-Type": "application/json" } + } + ); + } + return originalFetch(input, init); + }; + + try { + const extractRoot = path.join(root, "extract"); + const outputRoot = path.join(root, "downloads"); + const mkvLibraryDir = path.join(root, "mkv-library"); + const manager = new DownloadManager( + { + ...defaultSettings(), + token: "rd-token", + outputDir: outputRoot, + extractDir: extractRoot, + autoExtract: true, + autoRename4sf4sj: false, + collectMkvToLibrary: true, + mkvLibraryDir, + removeLinkFilesAfterExtract: true, + removeSamplesAfterExtract: true, + enableIntegrityCheck: false, + cleanupMode: "delete", + completedCleanupPolicy: "package_done" + }, + emptySession(), + createStoragePaths(path.join(root, "state")) + ); + + manager.addPackages([{ name: "cleanup-package-full", links: ["https://dummy/cleanup-package-full"] }]); + await manager.start(); + await waitFor(() => !manager.getSnapshot().session.running, 30000); + await waitFor(() => manager.getSnapshot().session.packageOrder.length === 0, 12000); + + const flattenedPath = path.join(mkvLibraryDir, "Episode01.mkv"); + expect(fs.existsSync(flattenedPath)).toBe(true); + expect(fs.existsSync(path.join(extractRoot, "cleanup-package-full"))).toBe(false); + expect(fs.existsSync(path.join(outputRoot, "cleanup-package-full"))).toBe(false); + expect(Object.keys(manager.getSnapshot().session.items)).toHaveLength(0); + } finally { + server.close(); + await once(server, "close"); + } + }, 35000); + + it("counts queued package cancellations in run summary", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-")); + tempDirs.push(root); + const binary = Buffer.alloc(256 * 1024, 5); + + const server = http.createServer((req, res) => { + if ((req.url || "") !== "/slow") { + res.statusCode = 404; + res.end("not-found"); + return; + } + res.statusCode = 200; + res.setHeader("Accept-Ranges", "bytes"); + res.setHeader("Content-Length", String(binary.length)); + const mid = Math.floor(binary.length / 2); + res.write(binary.subarray(0, mid)); + setTimeout(() => { + res.end(binary.subarray(mid)); + }, 600); + }); + + server.listen(0, "127.0.0.1"); + await once(server, "listening"); + + const address = server.address(); + if (!address || typeof address === "string") { + throw new Error("server address unavailable"); + } + const directUrl = `http://127.0.0.1:${address.port}/slow`; + + globalThis.fetch = async (input: RequestInfo | URL, init?: RequestInit): Promise => { + const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url; + if (url.includes("/unrestrict/link")) { + return new Response( + JSON.stringify({ + download: directUrl, + filename: "episode.mkv", + filesize: binary.length + }), + { + status: 200, + headers: { "Content-Type": "application/json" } + } + ); + } + return originalFetch(input, init); + }; + + try { + const manager = new DownloadManager( + { + ...defaultSettings(), + token: "rd-token", + outputDir: path.join(root, "downloads"), + extractDir: path.join(root, "extract"), + autoExtract: false, + maxParallel: 1 + }, + emptySession(), + createStoragePaths(path.join(root, "state")) + ); + + manager.addPackages([{ name: "cancel-run", links: ["https://dummy/one", "https://dummy/two"] }]); + manager.start(); + await new Promise((resolve) => setTimeout(resolve, 120)); + const pkgId = manager.getSnapshot().session.packageOrder[0]; + manager.cancelPackage(pkgId); + await waitFor(() => !manager.getSnapshot().session.running, 25000); + + const summary = manager.getSummary(); + expect(summary).not.toBeNull(); + expect(summary?.total).toBe(2); + expect(summary?.cancelled).toBe(2); + expect(summary?.success).toBe(0); + expect(summary?.failed).toBe(0); + } finally { + server.close(); + await once(server, "close"); + } + }); + + it("finishes run when remaining packages are disabled", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-")); + tempDirs.push(root); + const binary = Buffer.alloc(96 * 1024, 8); + + const server = http.createServer((req, res) => { + if ((req.url || "") !== "/enabled") { + res.statusCode = 404; + res.end("not-found"); + return; + } + res.statusCode = 200; + res.setHeader("Accept-Ranges", "bytes"); + res.setHeader("Content-Length", String(binary.length)); + res.end(binary); + }); + + server.listen(0, "127.0.0.1"); + await once(server, "listening"); + + const address = server.address(); + if (!address || typeof address === "string") { + throw new Error("server address unavailable"); + } + const directUrl = `http://127.0.0.1:${address.port}/enabled`; + + globalThis.fetch = async (input: RequestInfo | URL, init?: RequestInit): Promise => { + const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url; + if (url.includes("/unrestrict/link")) { + return new Response( + JSON.stringify({ + download: directUrl, + filename: "enabled.bin", + filesize: binary.length + }), + { + status: 200, + headers: { "Content-Type": "application/json" } + } + ); + } + return originalFetch(input, init); + }; + + try { + const manager = new DownloadManager( + { + ...defaultSettings(), + token: "rd-token", + outputDir: path.join(root, "downloads"), + extractDir: path.join(root, "extract"), + autoExtract: false, + maxParallel: 1 + }, + emptySession(), + createStoragePaths(path.join(root, "state")) + ); + + manager.addPackages([ + { name: "enabled", links: ["https://dummy/enabled"] }, + { name: "disabled", links: ["https://dummy/disabled"] } + ]); + const initial = manager.getSnapshot(); + const enabledPkgId = initial.session.packageOrder[0]; + const disabledPkgId = initial.session.packageOrder[1]; + const enabledItemId = initial.session.packages[enabledPkgId]?.itemIds[0] || ""; + const disabledItemId = initial.session.packages[disabledPkgId]?.itemIds[0] || ""; + + manager.togglePackage(disabledPkgId); + await manager.start(); + await waitFor(() => !manager.getSnapshot().session.running, 25000); + + const snapshot = manager.getSnapshot(); + expect(snapshot.session.packages[disabledPkgId]?.enabled).toBe(false); + expect(snapshot.session.items[enabledItemId]?.status).toBe("completed"); + expect(snapshot.session.items[disabledItemId]?.status).toBe("queued"); + } finally { + server.close(); + await once(server, "close"); + } + }); + + it("stops active package and keeps items queued", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-")); + tempDirs.push(root); + const binary = Buffer.alloc(320 * 1024, 15); + + const server = http.createServer((req, res) => { + if ((req.url || "") !== "/toggle") { + res.statusCode = 404; + res.end("not-found"); + return; + } + const half = Math.floor(binary.length / 2); + res.statusCode = 200; + res.setHeader("Accept-Ranges", "bytes"); + res.setHeader("Content-Length", String(binary.length)); + res.write(binary.subarray(0, half)); + setTimeout(() => { + res.end(binary.subarray(half)); + }, 1200); + }); + + server.listen(0, "127.0.0.1"); + await once(server, "listening"); + + const address = server.address(); + if (!address || typeof address === "string") { + throw new Error("server address unavailable"); + } + const directUrl = `http://127.0.0.1:${address.port}/toggle`; + + globalThis.fetch = async (input: RequestInfo | URL, init?: RequestInit): Promise => { + const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url; + if (url.includes("/unrestrict/link")) { + return new Response( + JSON.stringify({ + download: directUrl, + filename: "toggle.bin", + filesize: binary.length + }), + { + status: 200, + headers: { "Content-Type": "application/json" } + } + ); + } + return originalFetch(input, init); + }; + + try { + const manager = new DownloadManager( + { + ...defaultSettings(), + token: "rd-token", + outputDir: path.join(root, "downloads"), + extractDir: path.join(root, "extract"), + autoExtract: false, + maxParallel: 1 + }, + emptySession(), + createStoragePaths(path.join(root, "state")) + ); + + manager.addPackages([{ name: "toggle", links: ["https://dummy/toggle"] }]); + const initial = manager.getSnapshot(); + const pkgId = initial.session.packageOrder[0]; + const itemId = initial.session.packages[pkgId]?.itemIds[0] || ""; + + manager.start(); + await waitFor(() => { + const item = manager.getSnapshot().session.items[itemId]; + return item?.status === "downloading"; + }, 12000); + + manager.togglePackage(pkgId); + await waitFor(() => !manager.getSnapshot().session.running, 25000); + + const snapshot = manager.getSnapshot(); + const item = snapshot.session.items[itemId]; + expect(snapshot.session.packages[pkgId]?.enabled).toBe(false); + expect(item?.status).toBe("queued"); + expect(item?.fullStatus).toBe("Paket gestoppt"); + } finally { + server.close(); + await once(server, "close"); + } + }); + + it("shows stable ETA while paused", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-")); + tempDirs.push(root); + const binary = Buffer.alloc(320 * 1024, 10); + + const server = http.createServer((req, res) => { + if ((req.url || "") !== "/pause") { + res.statusCode = 404; + res.end("not-found"); + return; + } + res.statusCode = 200; + res.setHeader("Accept-Ranges", "bytes"); + res.setHeader("Content-Length", String(binary.length)); + const chunk = Math.floor(binary.length / 2); + res.write(binary.subarray(0, chunk)); + setTimeout(() => { + res.end(binary.subarray(chunk)); + }, 900); + }); + + server.listen(0, "127.0.0.1"); + await once(server, "listening"); + + const address = server.address(); + if (!address || typeof address === "string") { + throw new Error("server address unavailable"); + } + const directUrl = `http://127.0.0.1:${address.port}/pause`; + + globalThis.fetch = async (input: RequestInfo | URL, init?: RequestInit): Promise => { + const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url; + if (url.includes("/unrestrict/link")) { + return new Response( + JSON.stringify({ + download: directUrl, + filename: "pause.bin", + filesize: binary.length + }), + { + status: 200, + headers: { "Content-Type": "application/json" } + } + ); + } + return originalFetch(input, init); + }; + + try { + const manager = new DownloadManager( + { + ...defaultSettings(), + token: "rd-token", + outputDir: path.join(root, "downloads"), + extractDir: path.join(root, "extract"), + autoExtract: false, + maxParallel: 1 + }, + emptySession(), + createStoragePaths(path.join(root, "state")) + ); + + manager.addPackages([{ name: "pause-case", links: ["https://dummy/pause"] }]); + manager.start(); + await new Promise((resolve) => setTimeout(resolve, 120)); + manager.togglePause(); + const pausedSnapshot = manager.getSnapshot(); + expect(pausedSnapshot.session.paused).toBe(true); + expect(pausedSnapshot.etaText).toBe("ETA: --"); + + manager.stop(); + await waitFor(() => !manager.getSnapshot().session.running, 15000); + } finally { + server.close(); + await once(server, "close"); + } + }); + + it("handles rapid pause/resume toggles without deadlock", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-")); + tempDirs.push(root); + const binary = Buffer.alloc(320 * 1024, 11); + + const server = http.createServer((req, res) => { + if ((req.url || "") !== "/toggle-stress") { + res.statusCode = 404; + res.end("not-found"); + return; + } + const range = String(req.headers.range || ""); + const match = range.match(/bytes=(\d+)-/i); + const start = match ? Number(match[1]) : 0; + const chunk = binary.subarray(start); + + if (start > 0) { + res.statusCode = 206; + res.setHeader("Content-Range", `bytes ${start}-${binary.length - 1}/${binary.length}`); + } else { + res.statusCode = 200; + } + + res.setHeader("Accept-Ranges", "bytes"); + res.setHeader("Content-Length", String(chunk.length)); + const split = Math.max(1, Math.floor(chunk.length / 4)); + res.write(chunk.subarray(0, split)); + setTimeout(() => { + if (!res.writableEnded && !res.destroyed) { + res.end(chunk.subarray(split)); + } + }, 260); + }); + + server.listen(0, "127.0.0.1"); + await once(server, "listening"); + + const address = server.address(); + if (!address || typeof address === "string") { + throw new Error("server address unavailable"); + } + const directUrl = `http://127.0.0.1:${address.port}/toggle-stress`; + + globalThis.fetch = async (input: RequestInfo | URL, init?: RequestInit): Promise => { + const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url; + if (url.includes("/unrestrict/link")) { + return new Response( + JSON.stringify({ + download: directUrl, + filename: "stress.part01.rar", + filesize: binary.length + }), + { + status: 200, + headers: { "Content-Type": "application/json" } + } + ); + } + return originalFetch(input, init); + }; + + try { + const manager = new DownloadManager( + { + ...defaultSettings(), + token: "rd-token", + outputDir: path.join(root, "downloads"), + extractDir: path.join(root, "extract"), + autoExtract: false, + maxParallel: 1 + }, + emptySession(), + createStoragePaths(path.join(root, "state")) + ); + + manager.addPackages([{ name: "toggle-stress", links: ["https://dummy/toggle-stress"] }]); + manager.start(); + + await waitFor(() => Object.values(manager.getSnapshot().session.items)[0]?.status === "downloading", 12000); + for (let i = 0; i < 6; i += 1) { + manager.togglePause(); + await new Promise((resolve) => setTimeout(resolve, 90)); + } + + if (manager.getSnapshot().session.paused) { + manager.togglePause(); + } + + await waitFor(() => !manager.getSnapshot().session.running, 25000); + const item = Object.values(manager.getSnapshot().session.items)[0]; + expect(item?.status).toBe("completed"); + expect(fs.existsSync(item?.targetPath || "")).toBe(true); + expect(fs.statSync(item?.targetPath || "").size).toBe(binary.length); + } finally { + server.close(); + await once(server, "close"); + } + }); + + it("keeps active downloads resumable on shutdown preparation", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-")); + tempDirs.push(root); + const binary = Buffer.alloc(480 * 1024, 5); + + const server = http.createServer((req, res) => { + if ((req.url || "") !== "/shutdown") { + res.statusCode = 404; + res.end("not-found"); + return; + } + res.statusCode = 200; + res.setHeader("Accept-Ranges", "bytes"); + res.setHeader("Content-Length", String(binary.length)); + res.write(binary.subarray(0, Math.floor(binary.length / 3))); + setTimeout(() => { + if (!res.writableEnded && !res.destroyed) { + res.end(binary.subarray(Math.floor(binary.length / 3))); + } + }, 2200); + }); + + server.listen(0, "127.0.0.1"); + await once(server, "listening"); + + const address = server.address(); + if (!address || typeof address === "string") { + throw new Error("server address unavailable"); + } + const directUrl = `http://127.0.0.1:${address.port}/shutdown`; + + globalThis.fetch = async (input: RequestInfo | URL, init?: RequestInit): Promise => { + const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url; + if (url.includes("/unrestrict/link")) { + return new Response( + JSON.stringify({ + download: directUrl, + filename: "shutdown.part01.rar", + filesize: binary.length + }), + { + status: 200, + headers: { "Content-Type": "application/json" } + } + ); + } + return originalFetch(input, init); + }; + + try { + const manager = new DownloadManager( + { + ...defaultSettings(), + token: "rd-token", + outputDir: path.join(root, "downloads"), + extractDir: path.join(root, "extract"), + autoExtract: false, + maxParallel: 1 + }, + emptySession(), + createStoragePaths(path.join(root, "state")) + ); + + manager.addPackages([{ name: "shutdown-case", links: ["https://dummy/shutdown"] }]); + const itemId = Object.values(manager.getSnapshot().session.items)[0]?.id || ""; + manager.start(); + await waitFor(() => manager.getSnapshot().session.items[itemId]?.status === "downloading", 12000); + + manager.prepareForShutdown(); + await waitFor(() => { + const state = manager.getSnapshot(); + return !state.session.running && state.session.items[itemId]?.status === "queued"; + }, 8000); + + const item = manager.getSnapshot().session.items[itemId]; + expect(item?.status).toBe("queued"); + expect(item?.fullStatus).toBe("Wartet"); + } finally { + server.close(); + await once(server, "close"); + } + }); + + it("does not mark pre-allocated crash leftovers as 100% complete on resume", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-")); + tempDirs.push(root); + const binary = crypto.randomBytes(2 * 1024 * 1024); + const outputDir = path.join(root, "downloads", "resume-prealloc"); + const targetPath = path.join(outputDir, "resume-prealloc.part01.rar"); + fs.mkdirSync(outputDir, { recursive: true }); + fs.writeFileSync(targetPath, Buffer.alloc(binary.length, 0)); + + const session = emptySession(); + const packageId = "resume-prealloc-pkg"; + const itemId = "resume-prealloc-item"; + const createdAt = Date.now() - 20_000; + session.packageOrder = [packageId]; + session.packages[packageId] = { + id: packageId, + name: "resume-prealloc", + outputDir, + extractDir: path.join(root, "extract", "resume-prealloc"), + status: "queued", + itemIds: [itemId], + cancelled: false, + enabled: true, + createdAt, + updatedAt: createdAt + }; + session.items[itemId] = { + id: itemId, + packageId, + url: "https://dummy/resume-prealloc", + provider: "realdebrid", + status: "queued", + retries: 0, + speedBps: 0, + downloadedBytes: 0, + totalBytes: binary.length, + progressPercent: 0, + fileName: "resume-prealloc.part01.rar", + targetPath, + resumable: true, + attempts: 1, + lastError: "", + fullStatus: "Wartet", + createdAt, + updatedAt: createdAt + }; + + const rangeStarts: number[] = []; + let sawRangeAtFullSize = false; + const server = http.createServer((req, res) => { + if ((req.url || "") !== "/resume-prealloc") { + res.statusCode = 404; + res.end("not-found"); + return; + } + + const rangeHeader = String(req.headers.range || ""); + const match = /bytes=(\d+)-/i.exec(rangeHeader); + if (match) { + const start = Number(match[1] || 0); + rangeStarts.push(start); + if (start >= binary.length) { + sawRangeAtFullSize = true; + res.statusCode = 416; + res.setHeader("Content-Range", `bytes */${binary.length}`); + res.end(); + return; + } + const end = binary.length - 1; + res.statusCode = 206; + res.setHeader("Accept-Ranges", "bytes"); + res.setHeader("Content-Range", `bytes ${start}-${end}/${binary.length}`); + res.setHeader("Content-Length", String(binary.length - start)); + res.end(binary.subarray(start)); + return; + } + + res.statusCode = 200; + res.setHeader("Accept-Ranges", "bytes"); + res.setHeader("Content-Length", String(binary.length)); + res.end(binary); + }); + + server.listen(0, "127.0.0.1"); + await once(server, "listening"); + + const address = server.address(); + if (!address || typeof address === "string") { + throw new Error("server address unavailable"); + } + const directUrl = `http://127.0.0.1:${address.port}/resume-prealloc`; + + globalThis.fetch = async (input: RequestInfo | URL, init?: RequestInit): Promise => { + const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url; + if (url.includes("/unrestrict/link")) { + return new Response( + JSON.stringify({ + download: directUrl, + filename: "resume-prealloc.part01.rar", + filesize: binary.length + }), + { + status: 200, + headers: { "Content-Type": "application/json" } + } + ); + } + return originalFetch(input, init); + }; + + try { + const manager = new DownloadManager( + { + ...defaultSettings(), + token: "rd-token", + outputDir: path.join(root, "downloads"), + extractDir: path.join(root, "extract"), + autoExtract: false, + maxParallel: 1 + }, + session, + createStoragePaths(path.join(root, "state")) + ); + + await manager.start(); + await waitFor(() => !manager.getSnapshot().session.running, 30000); + + const item = manager.getSnapshot().session.items[itemId]; + expect(item?.status).toBe("completed"); + expect(item?.downloadedBytes).toBe(binary.length); + expect(item?.progressPercent).toBe(100); + expect(sawRangeAtFullSize).toBe(false); + expect(rangeStarts).not.toContain(binary.length); + expect(fs.readFileSync(targetPath).equals(binary)).toBe(true); + } finally { + server.close(); + await once(server, "close"); + } + }); + + it("resumes archive tails when persisted bytes lag slightly behind pre-allocated file size", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-")); + tempDirs.push(root); + const binary = crypto.randomBytes(2 * 1024 * 1024); + const persistedBytes = binary.length - (256 * 1024); + const outputDir = path.join(root, "downloads", "resume-prealloc-tail"); + const targetPath = path.join(outputDir, "resume-prealloc-tail.part01.rar"); + fs.mkdirSync(outputDir, { recursive: true }); + const preallocated = Buffer.concat([ + binary.subarray(0, persistedBytes), + Buffer.alloc(binary.length - persistedBytes, 0) + ]); + fs.writeFileSync(targetPath, preallocated); + + const session = emptySession(); + const packageId = "resume-prealloc-tail-pkg"; + const itemId = "resume-prealloc-tail-item"; + const createdAt = Date.now() - 20_000; + session.packageOrder = [packageId]; + session.packages[packageId] = { + id: packageId, + name: "resume-prealloc-tail", + outputDir, + extractDir: path.join(root, "extract", "resume-prealloc-tail"), + status: "queued", + itemIds: [itemId], + cancelled: false, + enabled: true, + createdAt, + updatedAt: createdAt + }; + session.items[itemId] = { + id: itemId, + packageId, + url: "https://dummy/resume-prealloc-tail", + provider: "realdebrid", + status: "queued", + retries: 0, + speedBps: 0, + downloadedBytes: persistedBytes, + totalBytes: binary.length, + progressPercent: Math.floor((persistedBytes / binary.length) * 100), + fileName: "resume-prealloc-tail.part01.rar", + targetPath, + resumable: true, + attempts: 1, + lastError: "", + fullStatus: "Wartet", + createdAt, + updatedAt: createdAt + }; + + const rangeStarts: number[] = []; + let sawRangeAtFullSize = false; + const server = http.createServer((req, res) => { + if ((req.url || "") !== "/resume-prealloc-tail") { + res.statusCode = 404; + res.end("not-found"); + return; + } + + const rangeHeader = String(req.headers.range || ""); + const match = /bytes=(\d+)-/i.exec(rangeHeader); + if (match) { + const start = Number(match[1] || 0); + rangeStarts.push(start); + if (start >= binary.length) { + sawRangeAtFullSize = true; + res.statusCode = 416; + res.setHeader("Content-Range", `bytes */${binary.length}`); + res.end(); + return; + } + const end = binary.length - 1; + res.statusCode = 206; + res.setHeader("Accept-Ranges", "bytes"); + res.setHeader("Content-Range", `bytes ${start}-${end}/${binary.length}`); + res.setHeader("Content-Length", String(binary.length - start)); + res.end(binary.subarray(start)); + return; + } + + res.statusCode = 200; + res.setHeader("Accept-Ranges", "bytes"); + res.setHeader("Content-Length", String(binary.length)); + res.end(binary); + }); + + server.listen(0, "127.0.0.1"); + await once(server, "listening"); + + const address = server.address(); + if (!address || typeof address === "string") { + throw new Error("server address unavailable"); + } + const directUrl = `http://127.0.0.1:${address.port}/resume-prealloc-tail`; + + globalThis.fetch = async (input: RequestInfo | URL, init?: RequestInit): Promise => { + const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url; + if (url.includes("/unrestrict/link")) { + return new Response( + JSON.stringify({ + download: directUrl, + filename: "resume-prealloc-tail.part01.rar", + filesize: binary.length + }), + { + status: 200, + headers: { "Content-Type": "application/json" } + } + ); + } + return originalFetch(input, init); + }; + + try { + const manager = new DownloadManager( + { + ...defaultSettings(), + token: "rd-token", + outputDir: path.join(root, "downloads"), + extractDir: path.join(root, "extract"), + autoExtract: false, + maxParallel: 1 + }, + session, + createStoragePaths(path.join(root, "state")) + ); + + await manager.start(); + await waitFor(() => !manager.getSnapshot().session.running, 30000); + + const item = manager.getSnapshot().session.items[itemId]; + expect(item?.status).toBe("completed"); + expect(item?.downloadedBytes).toBe(binary.length); + expect(item?.progressPercent).toBe(100); + expect(sawRangeAtFullSize).toBe(false); + expect(rangeStarts).not.toContain(binary.length); + expect(rangeStarts.some((start) => start === persistedBytes)).toBe(true); + expect(fs.readFileSync(targetPath).equals(binary)).toBe(true); + } finally { + server.close(); + await once(server, "close"); + } + }); + + it("marks extracting items as resumable extraction on shutdown", () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-")); + tempDirs.push(root); + + const session = emptySession(); + const packageId = "extract-stop-pkg"; + const itemId = "extract-stop-item"; + const createdAt = Date.now() - 20_000; + const outputDir = path.join(root, "downloads", "extract-stop"); + const extractDir = path.join(root, "extract", "extract-stop"); + + session.packageOrder = [packageId]; + session.packages[packageId] = { + id: packageId, + name: "extract-stop", + outputDir, + extractDir, + status: "extracting", + itemIds: [itemId], + cancelled: false, + enabled: true, + createdAt, + updatedAt: createdAt + }; + session.items[itemId] = { + id: itemId, + packageId, + url: "https://dummy/extract-stop", + provider: "realdebrid", + status: "completed", + retries: 0, + speedBps: 0, + downloadedBytes: 123, + totalBytes: 123, + progressPercent: 100, + fileName: "extract-stop.part01.rar", + targetPath: path.join(outputDir, "extract-stop.part01.rar"), + resumable: true, + attempts: 1, + lastError: "", + fullStatus: "Entpacken 40%", + createdAt, + updatedAt: createdAt + }; + + const manager = new DownloadManager( + { + ...defaultSettings(), + token: "rd-token", + outputDir: path.join(root, "downloads"), + extractDir: path.join(root, "extract"), + autoExtract: true + }, + session, + createStoragePaths(path.join(root, "state")) + ); + + manager.prepareForShutdown(); + const snapshot = manager.getSnapshot(); + expect(snapshot.session.packages[packageId]?.status).toBe("queued"); + expect(snapshot.session.items[itemId]?.fullStatus).toBe("Entpacken abgebrochen (wird fortgesetzt)"); + }); + + it("recovers pending extraction on startup for completed package", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-")); + tempDirs.push(root); + + const outputDir = path.join(root, "downloads", "recovery"); + const extractDir = path.join(root, "extract", "recovery"); + fs.mkdirSync(outputDir, { recursive: true }); + + const zip = new AdmZip(); + zip.addFile("episode.txt", Buffer.from("ok")); + const archivePath = path.join(outputDir, "episode.zip"); + zip.writeZip(archivePath); + + const session = emptySession(); + const packageId = "recover-pkg"; + const itemId = "recover-item"; + const createdAt = Date.now() - 20_000; + session.packageOrder = [packageId]; + session.packages[packageId] = { + id: packageId, + name: "recovery", + outputDir, + extractDir, + status: "downloading", + itemIds: [itemId], + cancelled: false, + enabled: true, + createdAt, + updatedAt: createdAt + }; + session.items[itemId] = { + id: itemId, + packageId, + url: "https://dummy/recover", + provider: "megadebrid", + status: "completed", + retries: 0, + speedBps: 0, + downloadedBytes: fs.statSync(archivePath).size, + totalBytes: fs.statSync(archivePath).size, + progressPercent: 100, + fileName: "episode.zip", + targetPath: archivePath, + resumable: true, + attempts: 1, + lastError: "", + fullStatus: "Fertig (100 MB)", + createdAt, + updatedAt: createdAt + }; + + const manager = new DownloadManager( + { + ...defaultSettings(), + token: "rd-token", + outputDir: path.join(root, "downloads"), + extractDir: path.join(root, "extract"), + createExtractSubfolder: true, + autoExtract: true, + enableIntegrityCheck: false, + cleanupMode: "none" + }, + session, + createStoragePaths(path.join(root, "state")) + ); + + await waitFor( + () => + fs.existsSync(path.join(extractDir, "episode.txt")) && + manager.getSnapshot().session.packages[packageId]?.status === "completed", + 25000 + ); + const snapshot = manager.getSnapshot(); + expect(snapshot.session.packages[packageId]?.status).toBe("completed"); + expect(snapshot.session.items[itemId]?.fullStatus.startsWith("Entpackt - Done")).toBe(true); + }, 30000); + + it("does not fail startup post-processing when source package dir is missing but extract output exists", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-")); + tempDirs.push(root); + + const outputDir = path.join(root, "downloads", "missing-source-ok"); + const extractDir = path.join(root, "extract", "missing-source-ok"); + fs.mkdirSync(extractDir, { recursive: true }); + fs.writeFileSync(path.join(extractDir, "episode.mkv"), "ok", "utf8"); + + const session = emptySession(); + const packageId = "missing-source-ok-pkg"; + const itemId = "missing-source-ok-item"; + const createdAt = Date.now() - 20_000; + session.packageOrder = [packageId]; + session.packages[packageId] = { + id: packageId, + name: "missing-source-ok", + outputDir, + extractDir, + status: "downloading", + itemIds: [itemId], + cancelled: false, + enabled: true, + createdAt, + updatedAt: createdAt + }; + session.items[itemId] = { + id: itemId, + packageId, + url: "https://dummy/missing-source-ok", + provider: "megadebrid", + status: "completed", + retries: 0, + speedBps: 0, + downloadedBytes: 123, + totalBytes: 123, + progressPercent: 100, + fileName: "missing-source-ok.part01.rar", + targetPath: path.join(outputDir, "missing-source-ok.part01.rar"), + resumable: true, + attempts: 1, + lastError: "", + fullStatus: "Fertig (123 B)", + createdAt, + updatedAt: createdAt + }; + + const manager = new DownloadManager( + { + ...defaultSettings(), + token: "rd-token", + outputDir: path.join(root, "downloads"), + extractDir: path.join(root, "extract"), + autoExtract: true, + enableIntegrityCheck: false, + cleanupMode: "none" + }, + session, + createStoragePaths(path.join(root, "state")) + ); + + await waitFor(() => manager.getSnapshot().session.items[itemId]?.fullStatus.startsWith("Entpackt"), 12000); + const snapshot = manager.getSnapshot(); + expect(snapshot.session.packages[packageId]?.status).toBe("completed"); + expect(snapshot.session.items[itemId]?.fullStatus.startsWith("Entpackt - Done")).toBe(true); + }, 20000); + + it("marks missing source package dir as extracted instead of failed", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-")); + tempDirs.push(root); + + const outputDir = path.join(root, "downloads", "missing-source-empty"); + const extractDir = path.join(root, "extract", "missing-source-empty"); + + const session = emptySession(); + const packageId = "missing-source-empty-pkg"; + const itemId = "missing-source-empty-item"; + const createdAt = Date.now() - 20_000; + session.packageOrder = [packageId]; + session.packages[packageId] = { + id: packageId, + name: "missing-source-empty", + outputDir, + extractDir, + status: "downloading", + itemIds: [itemId], + cancelled: false, + enabled: true, + createdAt, + updatedAt: createdAt + }; + session.items[itemId] = { + id: itemId, + packageId, + url: "https://dummy/missing-source-empty", + provider: "megadebrid", + status: "completed", + retries: 0, + speedBps: 0, + downloadedBytes: 123, + totalBytes: 123, + progressPercent: 100, + fileName: "missing-source-empty.part01.rar", + targetPath: path.join(outputDir, "missing-source-empty.part01.rar"), + resumable: true, + attempts: 1, + lastError: "", + fullStatus: "Fertig (123 B)", + createdAt, + updatedAt: createdAt + }; + + const manager = new DownloadManager( + { + ...defaultSettings(), + token: "rd-token", + outputDir: path.join(root, "downloads"), + extractDir: path.join(root, "extract"), + autoExtract: true, + enableIntegrityCheck: false, + cleanupMode: "none" + }, + session, + createStoragePaths(path.join(root, "state")) + ); + + await waitFor(() => manager.getSnapshot().session.items[itemId]?.fullStatus.startsWith("Entpackt"), 12000); + const snapshot = manager.getSnapshot(); + expect(snapshot.session.packages[packageId]?.status).toBe("completed"); + expect(snapshot.session.items[itemId]?.fullStatus).toBe("Entpackt (Quelle fehlt)"); + }, 20000); + + it("resumes deferred startup cleanup for already extracted packages and removes them when package_done is active", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-")); + tempDirs.push(root); + + const packageName = "startup-deferred-cleanup"; + const { + session, + packageId, + itemId, + outputDir, + extractDir + } = createCompletedArchiveSessionFromArchive(root, packageName, [ + { name: "Season 1/Episode01.mkv", data: Buffer.from("video") }, + { name: "Season 1/episode.links.txt", data: Buffer.from("https://example.com/file") }, + { name: "Season 1/sample/sample.mkv", data: Buffer.from("sample-video") }, + { name: "Season 1/sample/readme.txt", data: Buffer.from("sample-text") } + ]); + + session.packages[packageId].status = "completed"; + session.items[itemId].fullStatus = "Entpackt - Done (<1s)"; + fs.mkdirSync(path.join(extractDir, "Season 1", "sample"), { recursive: true }); + fs.writeFileSync(path.join(extractDir, "Season 1", "Episode01.mkv"), "video", "utf8"); + fs.writeFileSync(path.join(extractDir, "Season 1", "episode.links.txt"), "https://example.com/file", "utf8"); + fs.writeFileSync(path.join(extractDir, "Season 1", "sample", "sample.mkv"), "sample-video", "utf8"); + fs.writeFileSync(path.join(extractDir, "Season 1", "sample", "readme.txt"), "sample-text", "utf8"); + + const mkvLibraryDir = path.join(root, "mkv-library"); + const manager = new DownloadManager( + { + ...defaultSettings(), + token: "rd-token", + outputDir: path.join(root, "downloads"), + extractDir: path.join(root, "extract"), + autoExtract: true, + autoRename4sf4sj: false, + collectMkvToLibrary: true, + mkvLibraryDir, + removeLinkFilesAfterExtract: true, + removeSamplesAfterExtract: true, + enableIntegrityCheck: false, + cleanupMode: "delete", + completedCleanupPolicy: "package_done" + }, + session, + createStoragePaths(path.join(root, "state")) + ); + + const flattenedPath = path.join(mkvLibraryDir, "Episode01.mkv"); + await waitFor(() => fs.existsSync(flattenedPath), 12000); + await waitFor(() => manager.getSnapshot().session.packageOrder.length === 0, 12000); + + expect(fs.existsSync(flattenedPath)).toBe(true); + expect(fs.existsSync(extractDir)).toBe(false); + expect(fs.existsSync(outputDir)).toBe(false); + expect(manager.getSnapshot().session.items[itemId]).toBeUndefined(); + }, 20000); + + it("resumes deferred startup auto-rename for already extracted packages", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-")); + tempDirs.push(root); + + const packageName = "Asbest.S02.GERMAN.720p.WEB.AVC-4SF"; + const sourceFileName = "4sf-asbest.web.7p-s02e01.mkv"; + const expectedFileName = "Asbest.S02E01.GERMAN.720p.WEB.AVC-4SF.mkv"; + const { + session, + packageId, + itemId, + extractDir, + originalExtractedPath + } = createCompletedArchiveSession(root, packageName, sourceFileName); + + session.packages[packageId].status = "completed"; + session.items[itemId].fullStatus = "Entpackt - Done (<1s)"; + fs.mkdirSync(extractDir, { recursive: true }); + fs.writeFileSync(originalExtractedPath, "video", "utf8"); + + const manager = new DownloadManager( + { + ...defaultSettings(), + token: "rd-token", + outputDir: path.join(root, "downloads"), + extractDir: path.join(root, "extract"), + autoExtract: true, + autoRename4sf4sj: true, + enableIntegrityCheck: false, + cleanupMode: "none" + }, + session, + createStoragePaths(path.join(root, "state")) + ); + + const expectedPath = path.join(extractDir, expectedFileName); + await waitFor(() => fs.existsSync(expectedPath), 12000); + + expect(fs.existsSync(expectedPath)).toBe(true); + expect(fs.existsSync(originalExtractedPath)).toBe(false); + expect(manager.getSnapshot().session.items[itemId]?.fullStatus.startsWith("Entpackt - Done")).toBe(true); + }, 20000); + + it("does not requeue already extracted items on startup when source archives were intentionally removed", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-")); + tempDirs.push(root); + + const packageName = "startup-extracted-without-source"; + const outputDir = path.join(root, "downloads", packageName); + const extractDir = path.join(root, "extract", packageName); + fs.mkdirSync(extractDir, { recursive: true }); + fs.writeFileSync(path.join(extractDir, "Episode01.mkv"), "video", "utf8"); + + const session = emptySession(); + const packageId = `${packageName}-pkg`; + const itemId = `${packageName}-item`; + const createdAt = Date.now() - 20_000; + const targetPath = path.join(outputDir, "episode.zip"); + session.packageOrder = [packageId]; + session.packages[packageId] = { + id: packageId, + name: packageName, + outputDir, + extractDir, + status: "completed", + itemIds: [itemId], + cancelled: false, + enabled: true, + createdAt, + updatedAt: createdAt + }; + session.items[itemId] = { + id: itemId, + packageId, + url: `https://dummy/${packageName}`, + provider: "realdebrid", + status: "completed", + retries: 0, + speedBps: 0, + downloadedBytes: 12_345, + totalBytes: 12_345, + progressPercent: 100, + fileName: "episode.zip", + targetPath, + resumable: true, + attempts: 1, + lastError: "", + fullStatus: "Entpackt - Done (<1s)", + createdAt, + updatedAt: createdAt + }; + + const manager = new DownloadManager( + { + ...defaultSettings(), + token: "rd-token", + outputDir: path.join(root, "downloads"), + extractDir: path.join(root, "extract"), + autoExtract: true, + autoRename4sf4sj: false, + collectMkvToLibrary: false, + enableIntegrityCheck: false, + cleanupMode: "delete", + completedCleanupPolicy: "never" + }, + session, + createStoragePaths(path.join(root, "state")) + ); + + await new Promise((resolve) => setTimeout(resolve, 400)); + + expect(manager.getSnapshot().session.items[itemId]?.status).toBe("completed"); + expect(manager.getSnapshot().session.items[itemId]?.fullStatus).toBe("Entpackt - Done (<1s)"); + expect(manager.getSnapshot().session.packages[packageId]?.status).toBe("completed"); + }, 20000); + + it("stops deferred post-extraction cleanup after package reset", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-")); + tempDirs.push(root); + + const sharedDir = path.join(root, "shared"); + fs.mkdirSync(sharedDir, { recursive: true }); + fs.writeFileSync(path.join(sharedDir, "episode.part01.rar"), "archive", "utf8"); + + const session = emptySession(); + const packageId = "deferred-reset-pkg"; + const itemId = "deferred-reset-item"; + const createdAt = Date.now() - 20_000; + session.packageOrder = [packageId]; + session.packages[packageId] = { + id: packageId, + name: "Deferred Reset", + outputDir: sharedDir, + extractDir: sharedDir, + status: "completed", + itemIds: [itemId], + cancelled: false, + enabled: true, + createdAt, + updatedAt: createdAt + }; + session.items[itemId] = { + id: itemId, + packageId, + url: "https://dummy/deferred-reset", + provider: "realdebrid", + status: "completed", + retries: 0, + speedBps: 0, + downloadedBytes: 123, + totalBytes: 123, + progressPercent: 100, + fileName: "episode.part01.rar", + targetPath: path.join(sharedDir, "episode.part01.rar"), + resumable: true, + attempts: 1, + lastError: "", + fullStatus: "Fertig (123 B)", + createdAt, + updatedAt: createdAt + }; + + const manager = new DownloadManager( + { + ...defaultSettings(), + token: "rd-token", + outputDir: path.join(root, "downloads"), + extractDir: path.join(root, "extract"), + autoExtract: true, + cleanupMode: "delete" + }, + session, + createStoragePaths(path.join(root, "state")) + ); + + let renameStarted = false; + let releaseRename = (): void => {}; + const renameGate = new Promise((resolve) => { + releaseRename = resolve; + }); + const internal = manager as any; + internal.autoRenameExtractedVideoFiles = vi.fn(async () => { + renameStarted = true; + await renameGate; + return 0; + }); + const cleanupRemainingArchiveArtifacts = vi.fn(async () => 0); + internal.cleanupRemainingArchiveArtifacts = cleanupRemainingArchiveArtifacts; + + const deferredPromise = internal.runDeferredPostExtraction( + packageId, + internal.session.packages[packageId], + 1, + 0, + true, + 1 + ); + + await waitFor(() => renameStarted, 4000); + manager.resetPackage(packageId); + releaseRename(); + await deferredPromise; + + expect(cleanupRemainingArchiveArtifacts).not.toHaveBeenCalled(); + const snapshot = manager.getSnapshot(); + expect(snapshot.session.packages[packageId]?.status).toBe("queued"); + expect(snapshot.session.items[itemId]?.status).toBe("queued"); + }); + + it("does not let cancelled cleanup delete archives for a re-added package in the same folder", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-")); + tempDirs.push(root); + + const packageName = "Cancel Cleanup"; + const outputDir = path.join(root, "downloads", packageName); + fs.mkdirSync(outputDir, { recursive: true }); + const archivePath = path.join(outputDir, "episode.part01.rar"); + fs.writeFileSync(archivePath, "archive", "utf8"); + + const session = emptySession(); + const packageId = "cancel-cleanup-pkg"; + const itemId = "cancel-cleanup-item"; + const createdAt = Date.now() - 20_000; + session.packageOrder = [packageId]; + session.packages[packageId] = { + id: packageId, + name: packageName, + outputDir, + extractDir: path.join(root, "extract", packageName), + status: "queued", + itemIds: [itemId], + cancelled: false, + enabled: true, + createdAt, + updatedAt: createdAt + }; + session.items[itemId] = { + id: itemId, + packageId, + url: "https://dummy/episode.part01.rar", + provider: null, + status: "queued", + retries: 0, + speedBps: 0, + downloadedBytes: 0, + totalBytes: null, + progressPercent: 0, + fileName: "episode.part01.rar", + targetPath: archivePath, + resumable: true, + attempts: 0, + lastError: "", + fullStatus: "Wartet", + createdAt, + updatedAt: createdAt + }; + + const manager = new DownloadManager( + { + ...defaultSettings(), + token: "rd-token", + outputDir: path.join(root, "downloads"), + extractDir: path.join(root, "extract"), + autoExtract: false + }, + session, + createStoragePaths(path.join(root, "state")) + ); + + manager.cancelPackage(packageId); + manager.addPackages([{ name: packageName, links: ["https://dummy/episode.part01.rar"] }]); + + await waitFor(() => manager.getSnapshot().session.packageOrder.length === 1, 4000); + await new Promise((resolve) => setTimeout(resolve, 100)); + + expect(fs.existsSync(archivePath)).toBe(true); + const snapshot = manager.getSnapshot(); + const remainingPackage = snapshot.session.packages[snapshot.session.packageOrder[0]]; + expect(remainingPackage?.outputDir).toBe(outputDir); + }); + + it("removes link and sample artifacts from extracted output even when deferred post-processing has failures", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-")); + tempDirs.push(root); + + const packageName = "Failed Cleanup"; + const outputDir = path.join(root, "downloads", packageName); + const extractDir = path.join(root, "extract", packageName); + fs.mkdirSync(outputDir, { recursive: true }); + fs.mkdirSync(path.join(extractDir, "sample"), { recursive: true }); + fs.writeFileSync(path.join(extractDir, "episode.links.txt"), "https://example.com/file", "utf8"); + fs.writeFileSync(path.join(extractDir, "sample", "sample.mkv"), "sample-video", "utf8"); + + const session = emptySession(); + const packageId = "failed-cleanup-pkg"; + const itemId = "failed-cleanup-item"; + const createdAt = Date.now() - 20_000; + session.packageOrder = [packageId]; + session.packages[packageId] = { + id: packageId, + name: packageName, + outputDir, + extractDir, + status: "failed", + itemIds: [itemId], + cancelled: false, + enabled: true, + createdAt, + updatedAt: createdAt + }; + session.items[itemId] = { + id: itemId, + packageId, + url: "https://dummy/failed-cleanup", + provider: "realdebrid", + status: "completed", + retries: 0, + speedBps: 0, + downloadedBytes: 100, + totalBytes: 100, + progressPercent: 100, + fileName: "episode.zip", + targetPath: path.join(outputDir, "episode.zip"), + resumable: true, + attempts: 1, + lastError: "", + fullStatus: "Entpackt - Done (1s)", + createdAt, + updatedAt: createdAt + }; + + const manager = new DownloadManager( + { + ...defaultSettings(), + token: "rd-token", + outputDir: path.join(root, "downloads"), + extractDir: path.join(root, "extract"), + autoExtract: true, + autoRename4sf4sj: false, + collectMkvToLibrary: false, + removeLinkFilesAfterExtract: true, + removeSamplesAfterExtract: true, + enableIntegrityCheck: false, + cleanupMode: "delete" + }, + session, + createStoragePaths(path.join(root, "state")) + ); + + await (manager as any).runDeferredPostExtraction( + packageId, + (manager as any).session.packages[packageId], + 1, + 1, + true, + 1 + ); + + expect(fs.existsSync(path.join(extractDir, "episode.links.txt"))).toBe(false); + expect(fs.existsSync(path.join(extractDir, "sample", "sample.mkv"))).toBe(false); + }); + + it("does not delete startup archives when any completed item has an extract error", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-")); + tempDirs.push(root); + + const outputDir = path.join(root, "downloads", "keep-failed-archive"); + const extractDir = path.join(root, "extract", "keep-failed-archive"); + fs.mkdirSync(outputDir, { recursive: true }); + fs.mkdirSync(extractDir, { recursive: true }); + fs.writeFileSync(path.join(extractDir, "episode1.mkv"), "ok", "utf8"); + + const okArchivePath = path.join(outputDir, "episode1.part01.rar"); + const failedArchivePath = path.join(outputDir, "episode2.part01.rar"); + fs.writeFileSync(okArchivePath, Buffer.from("ok-archive")); + fs.writeFileSync(failedArchivePath, Buffer.from("failed-archive")); + + const session = emptySession(); + const packageId = "keep-failed-archive-pkg"; + const itemOkId = "keep-failed-archive-item-ok"; + const itemFailId = "keep-failed-archive-item-fail"; + const createdAt = Date.now() - 20_000; + session.packageOrder = [packageId]; + session.packages[packageId] = { + id: packageId, + name: "keep-failed-archive", + outputDir, + extractDir, + status: "completed", + itemIds: [itemOkId, itemFailId], + cancelled: false, + enabled: true, + createdAt, + updatedAt: createdAt + }; + session.items[itemOkId] = { + id: itemOkId, + packageId, + url: "https://dummy/keep-failed-archive-1", + provider: "realdebrid", + status: "completed", + retries: 0, + speedBps: 0, + downloadedBytes: 10, + totalBytes: 10, + progressPercent: 100, + fileName: "episode1.part01.rar", + targetPath: okArchivePath, + resumable: true, + attempts: 1, + lastError: "", + fullStatus: "Entpackt - Done", + createdAt, + updatedAt: createdAt + }; + session.items[itemFailId] = { + id: itemFailId, + packageId, + url: "https://dummy/keep-failed-archive-2", + provider: "realdebrid", + status: "completed", + retries: 0, + speedBps: 0, + downloadedBytes: 14, + totalBytes: 14, + progressPercent: 100, + fileName: "episode2.part01.rar", + targetPath: failedArchivePath, + resumable: true, + attempts: 1, + lastError: "", + fullStatus: "Entpack-Fehler: Unexpected end of archive", + createdAt, + updatedAt: createdAt + }; + + new DownloadManager( + { + ...defaultSettings(), + token: "rd-token", + outputDir: path.join(root, "downloads"), + extractDir: path.join(root, "extract"), + autoExtract: true, + enableIntegrityCheck: false, + cleanupMode: "delete" + }, + session, + createStoragePaths(path.join(root, "state")) + ); + + await new Promise((resolve) => setTimeout(resolve, 600)); + + expect(fs.existsSync(okArchivePath)).toBe(true); + expect(fs.existsSync(failedArchivePath)).toBe(true); + }); + + it("does not delete stale target file when stopping during unrestrict phase", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-")); + tempDirs.push(root); + + const manager = new DownloadManager( + { + ...defaultSettings(), + token: "rd-token", + outputDir: path.join(root, "downloads"), + extractDir: path.join(root, "extract"), + autoExtract: false, + maxParallel: 1 + }, + emptySession(), + createStoragePaths(path.join(root, "state")) + ); + + manager.addPackages([{ name: "stop-unrestrict", links: ["https://dummy/slow-unrestrict"] }]); + const initialSnapshot = manager.getSnapshot(); + const pkgId = initialSnapshot.session.packageOrder[0]; + const itemId = initialSnapshot.session.packages[pkgId]?.itemIds[0] || ""; + if (!itemId) { + throw new Error("item missing"); + } + + const item = manager.getSnapshot().session.items[itemId]; + const staleTargetPath = path.join(path.dirname(item.targetPath), "existing-before-start.mkv"); + fs.mkdirSync(path.dirname(staleTargetPath), { recursive: true }); + fs.writeFileSync(staleTargetPath, "keep", "utf8"); + + const mutableSession = manager.getSnapshot().session; + if (mutableSession.items[itemId]) { + mutableSession.items[itemId].targetPath = staleTargetPath; + mutableSession.items[itemId].fileName = path.basename(staleTargetPath); + mutableSession.items[itemId].downloadedBytes = 0; + mutableSession.items[itemId].progressPercent = 0; + } + + globalThis.fetch = (async (input: RequestInfo | URL): Promise => { + const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url; + if (url.includes("/unrestrict/link")) { + await new Promise((resolve) => setTimeout(resolve, 260)); + return new Response( + JSON.stringify({ + download: "https://cdn.example/unused.bin", + filename: "new-file.mkv", + filesize: 1024 + }), + { + status: 200, + headers: { "Content-Type": "application/json" } + } + ); + } + return new Response("not-found", { status: 404 }); + }) as typeof fetch; + + manager.start(); + await new Promise((resolve) => setTimeout(resolve, 40)); + manager.stop(); + await waitFor(() => manager.getSnapshot().session.items[itemId]?.status === "cancelled", 12000); + expect(fs.existsSync(staleTargetPath)).toBe(true); + }); + + it("counts re-enabled package items in run summary totals", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-")); + tempDirs.push(root); + + const payload = Buffer.alloc(96 * 1024, 5); + const server = http.createServer((req, res) => { + if ((req.url || "") !== "/slow") { + res.statusCode = 404; + res.end("not-found"); + return; + } + setTimeout(() => { + res.statusCode = 200; + res.setHeader("Accept-Ranges", "bytes"); + res.setHeader("Content-Length", String(payload.length)); + res.end(payload); + }, 180); + }); + + server.listen(0, "127.0.0.1"); + await once(server, "listening"); + const address = server.address(); + if (!address || typeof address === "string") { + throw new Error("server address unavailable"); + } + const directUrl = `http://127.0.0.1:${address.port}/slow`; + + globalThis.fetch = (async (input: RequestInfo | URL): Promise => { + const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url; + if (url.includes("/unrestrict/link")) { + return new Response( + JSON.stringify({ + download: directUrl, + filename: "episode.mkv", + filesize: payload.length + }), + { + status: 200, + headers: { "Content-Type": "application/json" } + } + ); + } + return originalFetch(input); + }) as typeof fetch; + + try { + const manager = new DownloadManager( + { + ...defaultSettings(), + token: "rd-token", + outputDir: path.join(root, "downloads"), + extractDir: path.join(root, "extract"), + autoExtract: false, + maxParallel: 1 + }, + emptySession(), + createStoragePaths(path.join(root, "state")) + ); + + manager.addPackages([ + { name: "pkg-a", links: ["https://dummy/a"] }, + { name: "pkg-b", links: ["https://dummy/b"] } + ]); + + const packageIds = manager.getSnapshot().session.packageOrder; + const packageToToggle = packageIds[0]; + manager.start(); + await new Promise((resolve) => setTimeout(resolve, 40)); + manager.togglePackage(packageToToggle); + manager.togglePackage(packageToToggle); + + await waitFor(() => !manager.getSnapshot().session.running, 25000); + const summary = manager.getSnapshot().summary; + expect(summary?.total).toBe(2); + } finally { + server.close(); + await once(server, "close"); + } + }); + + it("auto-renames extracted 4SF scene files to folder format", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-")); + tempDirs.push(root); + + const packageName = "Asbest.S02.GERMAN.720p.WEB.AVC-4SF"; + const sourceFileName = "4sf-asbest.web.7p-s02e01.mkv"; + const expectedFileName = "Asbest.S02E01.GERMAN.720p.WEB.AVC-4SF.mkv"; + const { session, packageId, itemId, extractDir, originalExtractedPath } = createCompletedArchiveSession(root, packageName, sourceFileName); + + const manager = new DownloadManager( + { + ...defaultSettings(), + token: "rd-token", + outputDir: path.join(root, "downloads"), + extractDir: path.join(root, "extract"), + autoExtract: true, + autoRename4sf4sj: true, + enableIntegrityCheck: false, + cleanupMode: "none" + }, + session, + createStoragePaths(path.join(root, "state")) + ); + + const expectedPath = path.join(extractDir, expectedFileName); + await waitFor(() => fs.existsSync(expectedPath), 12000); + const snapshot = manager.getSnapshot(); + expect(snapshot.session.packages[packageId]?.status).toBe("completed"); + expect(snapshot.session.items[itemId]?.fullStatus.startsWith("Entpackt - Done")).toBe(true); + expect(fs.existsSync(expectedPath)).toBe(true); + expect(fs.existsSync(originalExtractedPath)).toBe(false); + }, 20000); + + it("tracks app runtime for session and all-time statistics", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-")); + tempDirs.push(root); + + const stateDir = path.join(root, "state"); + const storagePaths = createStoragePaths(stateDir); + const manager = new DownloadManager( + { + ...defaultSettings(), + token: "rd-token", + outputDir: path.join(root, "downloads"), + extractDir: path.join(root, "extract"), + totalRuntimeAllTimeMs: 2 * 60 * 60 * 1000 + }, + emptySession(), + storagePaths + ); + + await new Promise((resolve) => setTimeout(resolve, 120)); + + const stats = manager.getStats(); + expect(stats.sessionRuntimeMs).toBeGreaterThanOrEqual(100); + expect(stats.totalRuntimeMs).toBeGreaterThanOrEqual(2 * 60 * 60 * 1000 + 100); + expect(stats.runtimeMeasuredAt).toBeGreaterThan(0); + + manager.persistRuntimeStats(true); + const savedSettings = JSON.parse(fs.readFileSync(storagePaths.configFile, "utf8")) as { totalRuntimeAllTimeMs?: number }; + expect(savedSettings.totalRuntimeAllTimeMs || 0).toBeGreaterThanOrEqual(2 * 60 * 60 * 1000 + 100); + }, 10000); + + it("resets session runtime without affecting all-time runtime", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-")); + tempDirs.push(root); + + const manager = new DownloadManager( + { + ...defaultSettings(), + token: "rd-token", + outputDir: path.join(root, "downloads"), + extractDir: path.join(root, "extract"), + totalRuntimeAllTimeMs: 90 * 60 * 1000 + }, + emptySession(), + createStoragePaths(path.join(root, "state")) + ); + + await new Promise((resolve) => setTimeout(resolve, 120)); + + const beforeReset = manager.getStats(); + expect(beforeReset.sessionRuntimeMs).toBeGreaterThanOrEqual(100); + + manager.resetSessionStats(); + + const afterReset = manager.getStats(); + expect(afterReset.sessionRuntimeMs).toBeLessThan(beforeReset.sessionRuntimeMs); + expect(afterReset.sessionRuntimeMs).toBeLessThan(100); + expect(afterReset.totalRuntimeMs).toBeGreaterThanOrEqual(90 * 60 * 1000); + }, 10000); + + it("writes auto-rename details into rename and item logs", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-")); + tempDirs.push(root); + + const packageName = "Asbest.S02.GERMAN.720p.WEB.AVC-4SF"; + const sourceFileName = "4sf-asbest.web.7p-s02e01.mkv"; + const expectedFileName = "Asbest.S02E01.GERMAN.720p.WEB.AVC-4SF.mkv"; + const { session, itemId, extractDir } = createCompletedArchiveSession(root, packageName, sourceFileName); + const stateDir = path.join(root, "state"); + initItemLogs(stateDir); + initRenameLog(stateDir); + + new DownloadManager( + { + ...defaultSettings(), + token: "rd-token", + outputDir: path.join(root, "downloads"), + extractDir: path.join(root, "extract"), + autoExtract: true, + autoRename4sf4sj: true, + enableIntegrityCheck: false, + cleanupMode: "none" + }, + session, + createStoragePaths(stateDir) + ); + + const expectedPath = path.join(extractDir, expectedFileName); + await waitFor(() => fs.existsSync(expectedPath), 12000); + await new Promise((resolve) => setTimeout(resolve, 350)); + + const renameLogPath = getRenameLogPath(); + expect(renameLogPath).not.toBeNull(); + const renameContent = fs.readFileSync(renameLogPath!, "utf8"); + expect(renameContent).toContain("Auto-Rename durchgeführt"); + expect(renameContent).toContain(`targetPath=${expectedPath}`); + + const itemLogPath = getItemLogPath(itemId); + expect(itemLogPath).not.toBeNull(); + const itemContent = fs.readFileSync(itemLogPath!, "utf8"); + expect(itemContent).toContain("Auto-Rename durchgeführt"); + expect(itemContent).toContain("stage=auto-rename"); + }, 20000); + + it("adds REPACK marker from rp token and supports 4SJ folders", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-")); + tempDirs.push(root); + + const packageName = "Asbest.S02.GERMAN.720p.WEB.AVC-4SJ"; + const sourceFileName = "4sf-asbest.rp.web.7p-s02e01.mkv"; + const expectedFileName = "Asbest.S02E01.GERMAN.REPACK.720p.WEB.AVC-4SJ.mkv"; + const { session, itemId, extractDir, originalExtractedPath } = createCompletedArchiveSession(root, packageName, sourceFileName); + + new DownloadManager( + { + ...defaultSettings(), + token: "rd-token", + outputDir: path.join(root, "downloads"), + extractDir: path.join(root, "extract"), + autoExtract: true, + autoRename4sf4sj: true, + enableIntegrityCheck: false, + cleanupMode: "none" + }, + session, + createStoragePaths(path.join(root, "state")) + ); + + const expectedPath = path.join(extractDir, expectedFileName); + await waitFor(() => fs.existsSync(expectedPath), 12000); + expect(fs.existsSync(expectedPath)).toBe(true); + expect(fs.existsSync(originalExtractedPath)).toBe(false); + }, 20000); + + it("skips auto-rename when no SxxExx token exists in source filename", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-")); + tempDirs.push(root); + + const packageName = "Asbest.S02.GERMAN.720p.WEB.AVC-4SF"; + const sourceFileName = "4sf-asbest.rp.web.7p-episode.mkv"; + const unexpectedName = "Asbest.S02.GERMAN.REPACK.720p.WEB.AVC-4SF.mkv"; + const { session, itemId, extractDir, originalExtractedPath } = createCompletedArchiveSession(root, packageName, sourceFileName); + + const manager = new DownloadManager( + { + ...defaultSettings(), + token: "rd-token", + outputDir: path.join(root, "downloads"), + extractDir: path.join(root, "extract"), + autoExtract: true, + autoRename4sf4sj: true, + enableIntegrityCheck: false, + cleanupMode: "none" + }, + session, + createStoragePaths(path.join(root, "state")) + ); + + await waitFor(() => manager.getSnapshot().session.items[itemId]?.fullStatus.startsWith("Entpackt"), 12000); + expect(fs.existsSync(originalExtractedPath)).toBe(true); + expect(fs.existsSync(path.join(extractDir, unexpectedName))).toBe(false); + }, 20000); + + it("does not rename extracted scene files when auto-rename is disabled", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-")); + tempDirs.push(root); + + const packageName = "Asbest.S02.GERMAN.720p.WEB.AVC-4SF"; + const sourceFileName = "4sf-asbest.web.7p-s02e01.mkv"; + const unexpectedName = "Asbest.S02E01.GERMAN.720p.WEB.AVC-4SF.mkv"; + const { session, itemId, extractDir, originalExtractedPath } = createCompletedArchiveSession(root, packageName, sourceFileName); + + const manager = new DownloadManager( + { + ...defaultSettings(), + token: "rd-token", + outputDir: path.join(root, "downloads"), + extractDir: path.join(root, "extract"), + autoExtract: true, + autoRename4sf4sj: false, + enableIntegrityCheck: false, + cleanupMode: "none" + }, + session, + createStoragePaths(path.join(root, "state")) + ); + + await waitFor(() => manager.getSnapshot().session.items[itemId]?.fullStatus.startsWith("Entpackt"), 12000); + expect(fs.existsSync(originalExtractedPath)).toBe(true); + expect(fs.existsSync(path.join(extractDir, unexpectedName))).toBe(false); + }, 20000); + + it("moves extracted MKV files into a flat library folder per completed package", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-")); + tempDirs.push(root); + + const packageName = "Flat-Pack"; + const sourceFileName = "Season 1/Episode01.mkv"; + const { session, packageId, itemId, originalExtractedPath } = createCompletedArchiveSession(root, packageName, sourceFileName); + const mkvLibraryDir = path.join(root, "mkv-library"); + + const manager = new DownloadManager( + { + ...defaultSettings(), + token: "rd-token", + outputDir: path.join(root, "downloads"), + extractDir: path.join(root, "extract"), + autoExtract: true, + autoRename4sf4sj: false, + collectMkvToLibrary: true, + mkvLibraryDir, + enableIntegrityCheck: false, + cleanupMode: "none" + }, + session, + createStoragePaths(path.join(root, "state")) + ); + + const flattenedPath = path.join(mkvLibraryDir, "Episode01.mkv"); + await waitFor(() => fs.existsSync(flattenedPath), 12000); + + expect(manager.getSnapshot().session.packages[packageId]?.status).toBe("completed"); + expect(manager.getSnapshot().session.items[itemId]?.fullStatus.startsWith("Entpackt - Done")).toBe(true); + expect(fs.existsSync(flattenedPath)).toBe(true); + expect(fs.existsSync(originalExtractedPath)).toBe(false); + }, 20000); + + it("moves extracted AVI files into a flat library folder per completed package", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-")); + tempDirs.push(root); + + const packageName = "Flat-Pack-AVI"; + const sourceFileName = "Season 1/Episode01.avi"; + const { session, packageId, itemId, originalExtractedPath } = createCompletedArchiveSession(root, packageName, sourceFileName); + const mkvLibraryDir = path.join(root, "mkv-library"); + + const manager = new DownloadManager( + { + ...defaultSettings(), + token: "rd-token", + outputDir: path.join(root, "downloads"), + extractDir: path.join(root, "extract"), + autoExtract: true, + autoRename4sf4sj: false, + collectMkvToLibrary: true, + mkvLibraryDir, + enableIntegrityCheck: false, + cleanupMode: "none" + }, + session, + createStoragePaths(path.join(root, "state")) + ); + + const flattenedPath = path.join(mkvLibraryDir, "Episode01.avi"); + await waitFor(() => fs.existsSync(flattenedPath), 12000); + + expect(manager.getSnapshot().session.packages[packageId]?.status).toBe("completed"); + expect(manager.getSnapshot().session.items[itemId]?.fullStatus.startsWith("Entpackt - Done")).toBe(true); + expect(fs.existsSync(flattenedPath)).toBe(true); + expect(fs.existsSync(originalExtractedPath)).toBe(false); + }, 20000); + + it("hybrid collect defers fresh files instead of moving them unrenamed; final pass collects them", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-")); + tempDirs.push(root); + + const packageName = "Fresh.Defer.Test.S01.German.720p.BluRay.x264-GRP"; + const outputDir = path.join(root, "downloads", packageName); + const extractDir = path.join(root, "extract", packageName); + fs.mkdirSync(extractDir, { recursive: true }); + + const mkvName = "grp-freshshow.s01e07-720p.mkv"; + const mkvPath = path.join(extractDir, mkvName); + fs.writeFileSync(mkvPath, Buffer.alloc(4096, 7)); + + const session = emptySession(); + const packageId = `${packageName}-pkg`; + const createdAt = Date.now() - 20_000; + session.packageOrder = [packageId]; + session.packages[packageId] = { + id: packageId, + name: packageName, + outputDir, + extractDir, + status: "downloading", + itemIds: [], + cancelled: false, + enabled: true, + createdAt, + updatedAt: createdAt + }; + + const mkvLibraryDir = path.join(root, "mkv-library"); + const manager = new DownloadManager( + { + ...defaultSettings(), + outputDir: path.join(root, "downloads"), + extractDir: path.join(root, "extract"), + autoExtract: true, + autoRename4sf4sj: false, + collectMkvToLibrary: true, + mkvLibraryDir, + enableIntegrityCheck: false, + cleanupMode: "none" + }, + session, + createStoragePaths(path.join(root, "state")) + ); + (manager as any).fileStabilizeMinAgeMs = 30_000; + + const libPath = path.join(mkvLibraryDir, mkvName); + + await (manager as any).collectMkvFilesToLibrary(packageId, session.packages[packageId], undefined, true); + expect(fs.existsSync(libPath)).toBe(false); + expect(fs.existsSync(mkvPath)).toBe(true); + + await (manager as any).collectMkvFilesToLibrary(packageId, session.packages[packageId], undefined, false); + expect(fs.existsSync(libPath)).toBe(true); + expect(fs.existsSync(mkvPath)).toBe(false); + + void manager; + }, 20000); + + it("collect CLEANS a raw scene file that auto-rename never processed (the 17-file library bug)", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-")); + tempDirs.push(root); + + const packageName = "Herzflimmern.Die.Klinik.am.See.S07.German.720p.Webrip.x264-TVARCHiV"; + const outputDir = path.join(root, "downloads", packageName); + const extractDir = path.join(root, "extract", packageName); + const episodeFolder = "Herzflimmern.Die.Klinik.am.See.S07E12.German.720p.Webrip.x264-TVARCHiV"; + const epDir = path.join(extractDir, episodeFolder); + fs.mkdirSync(epDir, { recursive: true }); + const rawName = "tvarchiv.herzflimmern.die.klinik.am.see.s07e12-720.mkv"; + const rawPath = path.join(epDir, rawName); + fs.writeFileSync(rawPath, Buffer.alloc(4096, 7)); + + const session = emptySession(); + const packageId = `${packageName}-pkg`; + const createdAt = Date.now() - 60_000; + session.packageOrder = [packageId]; + session.packages[packageId] = { + id: packageId, + name: packageName, + outputDir, + extractDir, + status: "completed", + itemIds: [], + cancelled: false, + enabled: true, + createdAt, + updatedAt: createdAt + }; + + const mkvLibraryDir = path.join(root, "mkv-library"); + const manager = new DownloadManager( + { + ...defaultSettings(), + outputDir: path.join(root, "downloads"), + extractDir: path.join(root, "extract"), + autoExtract: true, + autoRename4sf4sj: true, + collectMkvToLibrary: true, + mkvLibraryDir, + enableIntegrityCheck: false, + cleanupMode: "none" + }, + session, + createStoragePaths(path.join(root, "state")) + ); + + await (manager as any).collectMkvFilesToLibrary(packageId, session.packages[packageId], undefined, false); + + const cleanLibPath = path.join(mkvLibraryDir, `${episodeFolder}.mkv`); + const rawLibPath = path.join(mkvLibraryDir, rawName); + expect(fs.existsSync(cleanLibPath)).toBe(true); + expect(fs.existsSync(rawLibPath)).toBe(false); + expect(fs.existsSync(rawPath)).toBe(false); + + void manager; + }, 20000); + + it("collect cleans a raw file sitting OUTSIDE extractDir (Downloader-Unfertig case) AND its .srt follows the rename", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-")); + tempDirs.push(root); + + const packageName = "Fritzie.-.Der.Himmel.muss.warten.S04.GERMAN.720p.WEB.AVC-4SF"; + const outputDir = path.join(root, "downloads", packageName); + const extractDir = path.join(root, "extract", packageName); + fs.mkdirSync(outputDir, { recursive: true }); + + const rawName = "4sf-fritzie.himmel.muss.warten.web.7p-s04e01.mkv"; + const rawSrt = "4sf-fritzie.himmel.muss.warten.web.7p-s04e01.de.srt"; + fs.writeFileSync(path.join(outputDir, rawName), Buffer.alloc(4096, 4)); + fs.writeFileSync(path.join(outputDir, rawSrt), Buffer.from("1\n00:00:01,000 --> 00:00:02,000\nhi\n")); + + const session = emptySession(); + const packageId = `${packageName}-pkg`; + const createdAt = Date.now() - 60_000; + session.packageOrder = [packageId]; + session.packages[packageId] = { + id: packageId, + name: packageName, + outputDir, + extractDir, + status: "completed", + itemIds: [], + cancelled: false, + enabled: true, + createdAt, + updatedAt: createdAt + }; + + const mkvLibraryDir = path.join(root, "mkv-library"); + const manager = new DownloadManager( + { + ...defaultSettings(), + outputDir: path.join(root, "downloads"), + extractDir: path.join(root, "extract"), + autoExtract: true, + autoRename4sf4sj: true, + collectMkvToLibrary: true, + mkvLibraryDir, + enableIntegrityCheck: false, + cleanupMode: "none" + }, + session, + createStoragePaths(path.join(root, "state")) + ); + + await (manager as any).collectMkvFilesToLibrary(packageId, session.packages[packageId], undefined, false); + + const cleanBase = "Fritzie.-.Der.Himmel.muss.warten.S04E01.GERMAN.720p.WEB.AVC-4SF"; + expect(fs.existsSync(path.join(mkvLibraryDir, `${cleanBase}.mkv`))).toBe(true); + expect(fs.existsSync(path.join(mkvLibraryDir, rawName))).toBe(false); + expect(fs.existsSync(path.join(mkvLibraryDir, `${cleanBase}.de.srt`))).toBe(true); + + void manager; + }, 20000); + + it("collect MOVES a numbered episode whose TITLE is a bonus keyword (Revenge S04E19 'Interview')", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-")); + tempDirs.push(root); + + const packageName = "Revenge.2011.S04.GERMAN.DL.720p.WEB.x264-TSCC"; + const outputDir = path.join(root, "downloads", packageName); + const extractDir = path.join(root, "extract", packageName); + const episodeFolder = "Revenge.2011.S04E19.Interview.GERMAN.DL.720p.WEB.x264-TSCC"; + const epDir = path.join(extractDir, episodeFolder); + fs.mkdirSync(epDir, { recursive: true }); + const epName = `${episodeFolder}.mkv`; + fs.writeFileSync(path.join(epDir, epName), Buffer.alloc(4096, 9)); + + const session = emptySession(); + const packageId = `${packageName}-pkg`; + const createdAt = Date.now() - 60_000; + session.packageOrder = [packageId]; + session.packages[packageId] = { + id: packageId, + name: packageName, + outputDir, + extractDir, + status: "completed", + itemIds: [], + cancelled: false, + enabled: true, + createdAt, + updatedAt: createdAt + }; + + const mkvLibraryDir = path.join(root, "mkv-library"); + const manager = new DownloadManager( + { + ...defaultSettings(), + outputDir: path.join(root, "downloads"), + extractDir: path.join(root, "extract"), + autoExtract: true, + autoRename4sf4sj: true, + collectMkvToLibrary: true, + mkvLibraryDir, + enableIntegrityCheck: false, + cleanupMode: "none" + }, + session, + createStoragePaths(path.join(root, "state")) + ); + + await (manager as any).collectMkvFilesToLibrary(packageId, session.packages[packageId], undefined, false); + + expect(fs.existsSync(path.join(mkvLibraryDir, epName))).toBe(true); + expect(fs.existsSync(path.join(epDir, epName))).toBe(false); + + void manager; + }, 20000); + + it("collect STILL skips genuine bonus/extras with NO episode token (Making.Of) — proves the filter isn't disabled", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-")); + tempDirs.push(root); + + const packageName = "Some.Show.S01.GERMAN.720p.WEB.x264-GRP"; + const outputDir = path.join(root, "downloads", packageName); + const extractDir = path.join(root, "extract", packageName); + fs.mkdirSync(extractDir, { recursive: true }); + const epName = "Some.Show.S01E01.GERMAN.720p.WEB.x264-GRP.mkv"; + const bonusName = "Some.Show.Making.Of.GERMAN.720p.WEB.x264-GRP.mkv"; + fs.writeFileSync(path.join(extractDir, epName), Buffer.alloc(4096, 1)); + fs.writeFileSync(path.join(extractDir, bonusName), Buffer.alloc(4096, 2)); + + const session = emptySession(); + const packageId = `${packageName}-pkg`; + const createdAt = Date.now() - 60_000; + session.packageOrder = [packageId]; + session.packages[packageId] = { + id: packageId, + name: packageName, + outputDir, + extractDir, + status: "completed", + itemIds: [], + cancelled: false, + enabled: true, + createdAt, + updatedAt: createdAt + }; + + const mkvLibraryDir = path.join(root, "mkv-library"); + const manager = new DownloadManager( + { + ...defaultSettings(), + outputDir: path.join(root, "downloads"), + extractDir: path.join(root, "extract"), + autoExtract: true, + autoRename4sf4sj: true, + collectMkvToLibrary: true, + mkvLibraryDir, + enableIntegrityCheck: false, + cleanupMode: "none" + }, + session, + createStoragePaths(path.join(root, "state")) + ); + + await (manager as any).collectMkvFilesToLibrary(packageId, session.packages[packageId], undefined, false); + + expect(fs.existsSync(path.join(mkvLibraryDir, epName))).toBe(true); + expect(fs.existsSync(path.join(mkvLibraryDir, bonusName))).toBe(false); + expect(fs.existsSync(path.join(extractDir, bonusName))).toBe(true); + + void manager; + }, 20000); + + it("collect CLEANS a raw .avi whose folder is a complete episode name WITHOUT a -GROUP suffix (safari S04E08a)", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-")); + tempDirs.push(root); + + const packageName = "c284d9d9072eaf3ac314d05f951dd115"; + const outputDir = path.join(root, "downloads", packageName); + const extractDir = path.join(root, "extract", packageName); + const mk = (folder: string, raw: string) => { + const epDir = path.join(extractDir, folder); + fs.mkdirSync(epDir, { recursive: true }); + fs.writeFileSync(path.join(epDir, raw), Buffer.alloc(4096, 8)); + }; + const folderA = "Fluss-Monster.S04E08a.Am.Essequibo.Teil.1.German.DOKU.SATRiP.XviD"; + const folderB = "Fluss-Monster.S04E08b.Am.Essequibo.Teil.2.German.DOKU.SATRiP.XviD"; + mk(folderA, "safari-fm-s04e08a.avi"); + mk(folderB, "safari-fm-s04e08b.avi"); + + const session = emptySession(); + const packageId = `${packageName}-pkg`; + const createdAt = Date.now() - 60_000; + session.packageOrder = [packageId]; + session.packages[packageId] = { + id: packageId, + name: packageName, + outputDir, + extractDir, + status: "completed", + itemIds: [], + cancelled: false, + enabled: true, + createdAt, + updatedAt: createdAt + }; + + const mkvLibraryDir = path.join(root, "mkv-library"); + const manager = new DownloadManager( + { + ...defaultSettings(), + outputDir: path.join(root, "downloads"), + extractDir: path.join(root, "extract"), + autoExtract: true, + autoRename4sf4sj: true, + collectMkvToLibrary: true, + mkvLibraryDir, + enableIntegrityCheck: false, + cleanupMode: "none" + }, + session, + createStoragePaths(path.join(root, "state")) + ); + + await (manager as any).collectMkvFilesToLibrary(packageId, session.packages[packageId], undefined, false); + + expect(fs.existsSync(path.join(mkvLibraryDir, `${folderA}.avi`))).toBe(true); + expect(fs.existsSync(path.join(mkvLibraryDir, `${folderB}.avi`))).toBe(true); + expect(fs.existsSync(path.join(mkvLibraryDir, "safari-fm-s04e08a.avi"))).toBe(false); + expect(fs.existsSync(path.join(mkvLibraryDir, "safari-fm-s04e08b.avi"))).toBe(false); + + void manager; + }, 20000); + + it("collect KEEPS the clean SxxExx name and does NOT mangle it via an episode-title folder (Taken S01E01)", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-")); + tempDirs.push(root); + + const packageName = "Steven.Spielbergs.Taken.S01.German.720p.HDTV.x264-GTVG"; + const outputDir = path.join(root, "downloads", packageName); + const extractDir = path.join(root, "extract", packageName); + const epFolder = "Steven.Spielbergs.Taken.E01.Hinter.dem.Himmel.German.720p.HDTV.x264-GTVG"; + const cleanName = "Steven.Spielbergs.Taken.S01E01.German.720p.HDTV.x264-GTVG.mkv"; + const epDir = path.join(extractDir, epFolder); + fs.mkdirSync(epDir, { recursive: true }); + fs.writeFileSync(path.join(epDir, cleanName), Buffer.alloc(4096, 5)); + + const session = emptySession(); + const packageId = `${packageName}-pkg`; + const createdAt = Date.now() - 60_000; + session.packageOrder = [packageId]; + session.packages[packageId] = { + id: packageId, + name: packageName, + outputDir, + extractDir, + status: "completed", + itemIds: [], + cancelled: false, + enabled: true, + createdAt, + updatedAt: createdAt + }; + + const mkvLibraryDir = path.join(root, "mkv-library"); + const manager = new DownloadManager( + { + ...defaultSettings(), + outputDir: path.join(root, "downloads"), + extractDir: path.join(root, "extract"), + autoExtract: true, + autoRename4sf4sj: true, + collectMkvToLibrary: true, + mkvLibraryDir, + enableIntegrityCheck: false, + cleanupMode: "none" + }, + session, + createStoragePaths(path.join(root, "state")) + ); + + await (manager as any).collectMkvFilesToLibrary(packageId, session.packages[packageId], undefined, false); + + expect(fs.existsSync(path.join(mkvLibraryDir, cleanName))).toBe(true); + const mangled = `${epFolder}.S01E01.mkv`; + expect(fs.existsSync(path.join(mkvLibraryDir, mangled))).toBe(false); + const inLib = fs.readdirSync(mkvLibraryDir); + expect(inLib.some((n) => /Hinter\.dem\.Himmel/i.test(n))).toBe(false); + + void manager; + }, 20000); + + it("deferred final pass renames fresh files before collecting them (no scene names in library)", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-")); + tempDirs.push(root); + + const packageName = "Test.Show.S02.GERMAN.WS.720p.HDTV.x264-aWake"; + const outputDir = path.join(root, "downloads", packageName); + const extractDir = path.join(root, "extract", packageName); + const epFolder = path.join(extractDir, "Test.Show.S02E05.Title.GERMAN.WS.720p.HDTV.x264-aWake"); + fs.mkdirSync(epFolder, { recursive: true }); + + const sceneName = "awa-testshow02e05hd.mkv"; + const scenePath = path.join(epFolder, sceneName); + fs.writeFileSync(scenePath, Buffer.alloc(4096, 5)); + + const session = emptySession(); + const packageId = `${packageName}-pkg`; + const createdAt = Date.now() - 20_000; + session.packageOrder = [packageId]; + session.packages[packageId] = { + id: packageId, + name: packageName, + outputDir, + extractDir, + status: "completed", + itemIds: [], + cancelled: false, + enabled: true, + createdAt, + updatedAt: createdAt + }; + + const mkvLibraryDir = path.join(root, "mkv-library"); + const manager = new DownloadManager( + { + ...defaultSettings(), + outputDir: path.join(root, "downloads"), + extractDir: path.join(root, "extract"), + autoExtract: true, + autoRename4sf4sj: true, + collectMkvToLibrary: true, + mkvLibraryDir, + enableIntegrityCheck: false, + cleanupMode: "none" + }, + session, + createStoragePaths(path.join(root, "state")) + ); + (manager as any).fileStabilizeMinAgeMs = 30_000; + + const expectedBase = "Test.Show.S02E05.Title.GERMAN.WS.720p.HDTV.x264-aWake"; + const renamedLibPath = path.join(mkvLibraryDir, `${expectedBase}.mkv`); + const sceneLibPath = path.join(mkvLibraryDir, sceneName); + + await (manager as any).autoRenameExtractedVideoFiles(extractDir, session.packages[packageId], undefined, true); + await (manager as any).collectMkvFilesToLibrary(packageId, session.packages[packageId], undefined, false); + + expect(fs.existsSync(renamedLibPath)).toBe(true); + expect(fs.existsSync(sceneLibPath)).toBe(false); + + void manager; + }, 20000); + + it("deferred post-extraction wiring renames fresh files end-to-end (treatFilesAsStable reaches the rename)", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-")); + tempDirs.push(root); + + const packageName = "Test.Show.S02.GERMAN.WS.720p.HDTV.x264-aWake"; + const outputDir = path.join(root, "downloads", packageName); + const extractDir = path.join(root, "extract", packageName); + const epFolder = path.join(extractDir, "Test.Show.S02E05.Title.GERMAN.WS.720p.HDTV.x264-aWake"); + fs.mkdirSync(epFolder, { recursive: true }); + fs.mkdirSync(outputDir, { recursive: true }); + + const sceneName = "awa-testshow02e05hd.mkv"; + fs.writeFileSync(path.join(epFolder, sceneName), Buffer.alloc(4096, 5)); + + const session = emptySession(); + const packageId = `${packageName}-pkg`; + const createdAt = Date.now() - 20_000; + session.packageOrder = [packageId]; + session.packages[packageId] = { + id: packageId, + name: packageName, + outputDir, + extractDir, + status: "completed", + itemIds: [], + cancelled: false, + enabled: true, + createdAt, + updatedAt: createdAt + }; + + const mkvLibraryDir = path.join(root, "mkv-library"); + const manager = new DownloadManager( + { + ...defaultSettings(), + outputDir: path.join(root, "downloads"), + extractDir: path.join(root, "extract"), + autoExtract: true, + autoRename4sf4sj: true, + collectMkvToLibrary: true, + mkvLibraryDir, + enableIntegrityCheck: false, + cleanupMode: "none" + }, + session, + createStoragePaths(path.join(root, "state")) + ); + (manager as any).fileStabilizeMinAgeMs = 30_000; + + const expectedBase = "Test.Show.S02E05.Title.GERMAN.WS.720p.HDTV.x264-aWake"; + await (manager as any).runDeferredPostExtraction(packageId, session.packages[packageId], 1, 0, true, 1); + + expect(fs.existsSync(path.join(mkvLibraryDir, `${expectedBase}.mkv`))).toBe(true); + expect(fs.existsSync(path.join(mkvLibraryDir, sceneName))).toBe(false); + + void manager; + }, 20000); + + it("moves direct MKV download from outputDir to library when no archive present (Mega-Debrid flow)", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-")); + tempDirs.push(root); + + const packageName = "Mega-Direct-Pack"; + const outputDir = path.join(root, "downloads", packageName); + const extractDir = path.join(root, "extract", packageName); + fs.mkdirSync(outputDir, { recursive: true }); + + const directMkvName = "Direct.Show.S01E01.German.1080p.WEB.x264-DIRECT.mkv"; + const directMkvPath = path.join(outputDir, directMkvName); + fs.writeFileSync(directMkvPath, Buffer.alloc(2048, 1)); + const directMkvSize = fs.statSync(directMkvPath).size; + + const session = emptySession(); + const packageId = `${packageName}-pkg`; + const itemId = `${packageName}-item`; + const createdAt = Date.now() - 20_000; + session.packageOrder = [packageId]; + session.packages[packageId] = { + id: packageId, + name: packageName, + outputDir, + extractDir, + status: "completed", + itemIds: [itemId], + cancelled: false, + enabled: true, + createdAt, + updatedAt: createdAt + }; + session.items[itemId] = { + id: itemId, + packageId, + url: `https://mega.nz/file/${packageName}`, + provider: "megadebrid-api", + status: "completed", + retries: 0, + speedBps: 0, + downloadedBytes: directMkvSize, + totalBytes: directMkvSize, + progressPercent: 100, + fileName: directMkvName, + targetPath: directMkvPath, + resumable: true, + attempts: 1, + lastError: "", + fullStatus: "Fertig", + createdAt, + updatedAt: createdAt + }; + + const mkvLibraryDir = path.join(root, "mkv-library"); + + const manager = new DownloadManager( + { + ...defaultSettings(), + outputDir: path.join(root, "downloads"), + extractDir: path.join(root, "extract"), + autoExtract: true, + autoRename4sf4sj: false, + collectMkvToLibrary: true, + mkvLibraryDir, + enableIntegrityCheck: false, + cleanupMode: "none" + }, + session, + createStoragePaths(path.join(root, "state")) + ); + + const libraryPath = path.join(mkvLibraryDir, directMkvName); + await waitFor(() => fs.existsSync(libraryPath), 12000); + + expect(fs.existsSync(libraryPath)).toBe(true); + expect(fs.readFileSync(libraryPath).length).toBe(directMkvSize); + expect(fs.existsSync(directMkvPath)).toBe(false); + + void manager; + }, 20000); + + it("does NOT delete pending RAR archive sets in outputDir when collecting MKVs from extractDir", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-")); + tempDirs.push(root); + + const packageName = "Ugly.Americans.MultiSeason-Pack"; + const outputDir = path.join(root, "downloads", packageName); + const extractDir = path.join(root, "extract", packageName); + fs.mkdirSync(outputDir, { recursive: true }); + fs.mkdirSync(extractDir, { recursive: true }); + + const s02Parts = [ + "Ugly.Americans.S02.COMPLETE.German.part1.rar", + "Ugly.Americans.S02.COMPLETE.German.part2.rar", + "Ugly.Americans.S02.COMPLETE.German.part3.rar" + ]; + for (const part of s02Parts) { + fs.writeFileSync(path.join(outputDir, part), Buffer.alloc(1024, 7)); + } + fs.writeFileSync(path.join(outputDir, "info.nfo"), Buffer.from("nfo")); + + const s01Mkvs = [ + "Ugly.Americans.S01E01.German.mkv", + "Ugly.Americans.S01E02.German.mkv" + ]; + for (const mkv of s01Mkvs) { + fs.writeFileSync(path.join(extractDir, mkv), Buffer.alloc(4096, 9)); + } + + const session = emptySession(); + const packageId = `${packageName}-pkg`; + const createdAt = Date.now() - 20_000; + session.packageOrder = [packageId]; + session.packages[packageId] = { + id: packageId, + name: packageName, + outputDir, + extractDir, + status: "downloading", + itemIds: [], + cancelled: false, + enabled: true, + createdAt, + updatedAt: createdAt + }; + + const mkvLibraryDir = path.join(root, "mkv-library"); + + const manager = new DownloadManager( + { + ...defaultSettings(), + outputDir: path.join(root, "downloads"), + extractDir: path.join(root, "extract"), + autoExtract: true, + autoRename4sf4sj: false, + collectMkvToLibrary: true, + mkvLibraryDir, + enableIntegrityCheck: false, + cleanupMode: "delete" + }, + session, + createStoragePaths(path.join(root, "state")) + ); + + await (manager as any).collectMkvFilesToLibrary(packageId, session.packages[packageId]); + + for (const mkv of s01Mkvs) { + expect(fs.existsSync(path.join(mkvLibraryDir, mkv))).toBe(true); + } + for (const part of s02Parts) { + expect(fs.existsSync(path.join(outputDir, part))).toBe(true); + } + + void manager; + }, 20000); + + it("does NOT move bonus files from Extras subdirectory to flat library", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-")); + tempDirs.push(root); + + const packageName = "Breaking.Bad.S04.GERMAN.5.1.DL.BluRay.720p.x264-TSCC"; + const outputDir = path.join(root, "downloads", packageName); + const extractDir = path.join(root, "extract", packageName); + fs.mkdirSync(outputDir, { recursive: true }); + + const zip = new AdmZip(); + zip.addFile("Breaking.Bad.S04E01.GERMAN.5.1.DL.BluRay.720p.x264-TSCC.mkv", Buffer.from("episode-data")); + zip.addFile("Breaking.Bad.S04Extras.720p.BluRay.x264-TSCC/Schrotflinte.mkv", Buffer.from("bonus-1")); + zip.addFile("Breaking.Bad.S04Extras.720p.BluRay.x264-TSCC/Die.Autoexplosion.mkv", Buffer.from("bonus-2")); + zip.addFile("Breaking.Bad.S04Extras.720p.BluRay.x264-TSCC/White.House.mkv", Buffer.from("bonus-3")); + const archivePath = path.join(outputDir, "episode.zip"); + zip.writeZip(archivePath); + const archiveSize = fs.statSync(archivePath).size; + + const session = emptySession(); + const packageId = `${packageName}-pkg`; + const itemId = `${packageName}-item`; + const createdAt = Date.now() - 20_000; + session.packageOrder = [packageId]; + session.packages[packageId] = { + id: packageId, + name: packageName, + outputDir, + extractDir, + status: "downloading", + itemIds: [itemId], + cancelled: false, + enabled: true, + createdAt, + updatedAt: createdAt + }; + session.items[itemId] = { + id: itemId, + packageId, + url: `https://dummy/${packageName}`, + provider: "realdebrid", + status: "completed", + retries: 0, + speedBps: 0, + downloadedBytes: archiveSize, + totalBytes: archiveSize, + progressPercent: 100, + fileName: "episode.zip", + targetPath: archivePath, + resumable: true, + attempts: 1, + lastError: "", + fullStatus: "Fertig (100 MB)", + createdAt, + updatedAt: createdAt + }; + + const mkvLibraryDir = path.join(root, "mkv-library"); + + new DownloadManager( + { + ...defaultSettings(), + token: "rd-token", + outputDir: path.join(root, "downloads"), + extractDir: path.join(root, "extract"), + autoExtract: true, + autoRename4sf4sj: false, + collectMkvToLibrary: true, + mkvLibraryDir, + enableIntegrityCheck: false, + cleanupMode: "none" + }, + session, + createStoragePaths(path.join(root, "state")) + ); + + const flattenedEpisode = path.join(mkvLibraryDir, "Breaking.Bad.S04E01.GERMAN.5.1.DL.BluRay.720p.x264-TSCC.mkv"); + await waitFor(() => fs.existsSync(flattenedEpisode), 12000); + + expect(fs.existsSync(path.join(mkvLibraryDir, "Schrotflinte.mkv"))).toBe(false); + expect(fs.existsSync(path.join(mkvLibraryDir, "Die.Autoexplosion.mkv"))).toBe(false); + expect(fs.existsSync(path.join(mkvLibraryDir, "White.House.mkv"))).toBe(false); + + const extrasDir = path.join(extractDir, "Breaking.Bad.S04Extras.720p.BluRay.x264-TSCC"); + expect(fs.existsSync(path.join(extrasDir, "Schrotflinte.mkv"))).toBe(true); + expect(fs.existsSync(path.join(extrasDir, "Die.Autoexplosion.mkv"))).toBe(true); + expect(fs.existsSync(path.join(extrasDir, "White.House.mkv"))).toBe(true); + + expect(fs.existsSync(flattenedEpisode)).toBe(true); + }, 20000); + + it("detects dot-separated bonus subdirectories (Making.Of, Behind.The.Scenes)", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-")); + tempDirs.push(root); + + const packageName = "Breaking.Bad.S05.GERMAN.DL.720p.BluRay.x264-TSCC"; + const outputDir = path.join(root, "downloads", packageName); + const extractDir = path.join(root, "extract", packageName); + fs.mkdirSync(outputDir, { recursive: true }); + + const zip = new AdmZip(); + zip.addFile("Breaking.Bad.S05E01.GERMAN.DL.720p.BluRay.x264-TSCC.mkv", Buffer.from("real-episode")); + zip.addFile("Breaking.Bad.S05.Making.Of/SomeBonusClip.mkv", Buffer.from("bonus-1")); + zip.addFile("Breaking.Bad.S05.Behind.The.Scenes/AnotherClip.mkv", Buffer.from("bonus-2")); + zip.addFile("Breaking.Bad.S05.Deleted.Scenes/DeletedClip.mkv", Buffer.from("bonus-3")); + zip.addFile("Breaking.Bad.S05.Gag.Reel/GagClip.mkv", Buffer.from("bonus-4")); + const archivePath = path.join(outputDir, "episode.zip"); + zip.writeZip(archivePath); + const archiveSize = fs.statSync(archivePath).size; + + const session = emptySession(); + const packageId = `${packageName}-pkg`; + const itemId = `${packageName}-item`; + const createdAt = Date.now() - 20_000; + session.packageOrder = [packageId]; + session.packages[packageId] = { + id: packageId, + name: packageName, + outputDir, + extractDir, + status: "downloading", + itemIds: [itemId], + cancelled: false, + enabled: true, + createdAt, + updatedAt: createdAt + }; + session.items[itemId] = { + id: itemId, + packageId, + url: `https://dummy/${packageName}`, + provider: "realdebrid", + status: "completed", + retries: 0, + speedBps: 0, + downloadedBytes: archiveSize, + totalBytes: archiveSize, + progressPercent: 100, + fileName: "episode.zip", + targetPath: archivePath, + resumable: true, + attempts: 1, + lastError: "", + fullStatus: "Fertig (100 MB)", + createdAt, + updatedAt: createdAt + }; + + const mkvLibraryDir = path.join(root, "mkv-library"); + + new DownloadManager( + { + ...defaultSettings(), + token: "rd-token", + outputDir: path.join(root, "downloads"), + extractDir: path.join(root, "extract"), + autoExtract: true, + autoRename4sf4sj: false, + collectMkvToLibrary: true, + mkvLibraryDir, + enableIntegrityCheck: false, + cleanupMode: "none" + }, + session, + createStoragePaths(path.join(root, "state")) + ); + + const flattenedEpisode = path.join(mkvLibraryDir, "Breaking.Bad.S05E01.GERMAN.DL.720p.BluRay.x264-TSCC.mkv"); + await waitFor(() => fs.existsSync(flattenedEpisode), 12000); + + expect(fs.existsSync(path.join(mkvLibraryDir, "SomeBonusClip.mkv"))).toBe(false); + expect(fs.existsSync(path.join(mkvLibraryDir, "AnotherClip.mkv"))).toBe(false); + expect(fs.existsSync(path.join(mkvLibraryDir, "DeletedClip.mkv"))).toBe(false); + expect(fs.existsSync(path.join(mkvLibraryDir, "GagClip.mkv"))).toBe(false); + + expect(fs.existsSync(path.join(extractDir, "Breaking.Bad.S05.Making.Of", "SomeBonusClip.mkv"))).toBe(true); + expect(fs.existsSync(path.join(extractDir, "Breaking.Bad.S05.Behind.The.Scenes", "AnotherClip.mkv"))).toBe(true); + expect(fs.existsSync(path.join(extractDir, "Breaking.Bad.S05.Deleted.Scenes", "DeletedClip.mkv"))).toBe(true); + expect(fs.existsSync(path.join(extractDir, "Breaking.Bad.S05.Gag.Reel", "GagClip.mkv"))).toBe(true); + }, 20000); + + it("keeps existing MKV names and appends a suffix while flattening", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-")); + tempDirs.push(root); + + const packageName = "Flat-Collision"; + const sourceFileName = "Season 1/Episode01.mkv"; + const { session } = createCompletedArchiveSession(root, packageName, sourceFileName); + const mkvLibraryDir = path.join(root, "mkv-library"); + fs.mkdirSync(mkvLibraryDir, { recursive: true }); + const existingPath = path.join(mkvLibraryDir, "Episode01.mkv"); + fs.writeFileSync(existingPath, "already-here", "utf8"); + + new DownloadManager( + { + ...defaultSettings(), + token: "rd-token", + outputDir: path.join(root, "downloads"), + extractDir: path.join(root, "extract"), + autoExtract: true, + autoRename4sf4sj: false, + collectMkvToLibrary: true, + mkvLibraryDir, + enableIntegrityCheck: false, + cleanupMode: "none" + }, + session, + createStoragePaths(path.join(root, "state")) + ); + + const suffixedPath = path.join(mkvLibraryDir, "Episode01 (2).mkv"); + await waitFor(() => fs.existsSync(suffixedPath), 12000); + + expect(fs.existsSync(existingPath)).toBe(true); + expect(fs.readFileSync(existingPath, "utf8")).toBe("already-here"); + expect(fs.existsSync(suffixedPath)).toBe(true); + }, 20000); + + it("removes empty package folders after MKV flattening even with desktop.ini or thumbs.db", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-")); + tempDirs.push(root); + + const packageName = "Gotham.S03.GERMAN.5.1.DL.AC3.720p.BDRiP.x264-TvR"; + const outputDir = path.join(root, "downloads", packageName); + const extractDir = path.join(root, "extract", packageName); + fs.mkdirSync(outputDir, { recursive: true }); + + const nestedFolder = "Gotham.S03E11.Ein.Ungeheuer.namens.Eifersucht.GERMAN.5.1.DL.AC3.720p.BDRiP.x264-TvR"; + const sourceFileName = `${nestedFolder}/tvr-gotham-s03e11-720p.mkv`; + const zip = new AdmZip(); + zip.addFile(sourceFileName, Buffer.from("video")); + zip.addFile(`${nestedFolder}/tvr-gotham-s03-720p.nfo`, Buffer.from("info")); + zip.addFile(`${nestedFolder}/Thumbs.db`, Buffer.from("thumbs")); + zip.addFile("desktop.ini", Buffer.from("system")); + const archivePath = path.join(outputDir, "episode.zip"); + zip.writeZip(archivePath); + const archiveSize = fs.statSync(archivePath).size; + + const session = emptySession(); + const packageId = `${packageName}-pkg`; + const itemId = `${packageName}-item`; + const createdAt = Date.now() - 20_000; + session.packageOrder = [packageId]; + session.packages[packageId] = { + id: packageId, + name: packageName, + outputDir, + extractDir, + status: "downloading", + itemIds: [itemId], + cancelled: false, + enabled: true, + createdAt, + updatedAt: createdAt + }; + session.items[itemId] = { + id: itemId, + packageId, + url: "https://dummy/gotham", + provider: "realdebrid", + status: "completed", + retries: 0, + speedBps: 0, + downloadedBytes: archiveSize, + totalBytes: archiveSize, + progressPercent: 100, + fileName: "episode.zip", + targetPath: archivePath, + resumable: true, + attempts: 1, + lastError: "", + fullStatus: "Fertig", + createdAt, + updatedAt: createdAt + }; + + const mkvLibraryDir = path.join(root, "mkv-library"); + new DownloadManager( + { + ...defaultSettings(), + token: "rd-token", + outputDir: path.join(root, "downloads"), + extractDir: path.join(root, "extract"), + autoExtract: true, + autoRename4sf4sj: false, + collectMkvToLibrary: true, + mkvLibraryDir, + enableIntegrityCheck: false, + cleanupMode: "none" + }, + session, + createStoragePaths(path.join(root, "state")) + ); + + const flattenedPath = path.join(mkvLibraryDir, "tvr-gotham-s03e11-720p.mkv"); + await waitFor(() => fs.existsSync(flattenedPath), 12000); + + expect(fs.existsSync(flattenedPath)).toBe(true); + expect(fs.existsSync(extractDir)).toBe(false); + }, 20000); + + it("cleans duplicate-skipped MKV source trees including leftover sample files", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-")); + tempDirs.push(root); + + const packageName = "Flat-Duplicate-Cleanup"; + const outputDir = path.join(root, "downloads", packageName); + const extractDir = path.join(root, "extract", packageName); + fs.mkdirSync(outputDir, { recursive: true }); + + const zip = new AdmZip(); + zip.addFile("Season 1/Episode01.mkv", Buffer.from("video")); + zip.addFile("Season 1/sample.txt", Buffer.from("sample")); + const archivePath = path.join(outputDir, "episode.zip"); + zip.writeZip(archivePath); + const archiveSize = fs.statSync(archivePath).size; + + const session = emptySession(); + const packageId = `${packageName}-pkg`; + const itemId = `${packageName}-item`; + const createdAt = Date.now() - 20_000; + session.packageOrder = [packageId]; + session.packages[packageId] = { + id: packageId, + name: packageName, + outputDir, + extractDir, + status: "downloading", + itemIds: [itemId], + cancelled: false, + enabled: true, + createdAt, + updatedAt: createdAt + }; + session.items[itemId] = { + id: itemId, + packageId, + url: "https://dummy/flat-duplicate-cleanup", + provider: "realdebrid", + status: "completed", + retries: 0, + speedBps: 0, + downloadedBytes: archiveSize, + totalBytes: archiveSize, + progressPercent: 100, + fileName: "episode.zip", + targetPath: archivePath, + resumable: true, + attempts: 1, + lastError: "", + fullStatus: "Fertig", + createdAt, + updatedAt: createdAt + }; + + const mkvLibraryDir = path.join(root, "mkv-library"); + fs.mkdirSync(mkvLibraryDir, { recursive: true }); + fs.writeFileSync(path.join(mkvLibraryDir, "Episode01.mkv"), Buffer.from("video")); + + new DownloadManager( + { + ...defaultSettings(), + token: "rd-token", + outputDir: path.join(root, "downloads"), + extractDir: path.join(root, "extract"), + autoExtract: true, + autoRename4sf4sj: false, + collectMkvToLibrary: true, + mkvLibraryDir, + enableIntegrityCheck: false, + cleanupMode: "none" + }, + session, + createStoragePaths(path.join(root, "state")) + ); + + await waitFor(() => !fs.existsSync(extractDir), 12000); + + expect(fs.existsSync(path.join(mkvLibraryDir, "Episode01.mkv"))).toBe(true); + expect(fs.existsSync(extractDir)).toBe(false); + }, 20000); + + it("cleans duplicate-skipped MKV source trees with link and residual artifacts", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-")); + tempDirs.push(root); + + const packageName = "Flat-Duplicate-Cleanup-Extended"; + const { + session, + extractDir + } = createCompletedArchiveSessionFromArchive(root, packageName, [ + { name: "Season 1/Episode01.mkv", data: Buffer.from("video") }, + { name: "Season 1/episode.links.txt", data: Buffer.from("https://example.com/file") }, + { name: "Season 1/info.nfo", data: Buffer.from("info") }, + { name: "Season 1/sample/sample.mkv", data: Buffer.from("sample-video") }, + { name: "Season 1/sample/readme.txt", data: Buffer.from("sample-text") } + ]); + + const mkvLibraryDir = path.join(root, "mkv-library"); + fs.mkdirSync(mkvLibraryDir, { recursive: true }); + fs.writeFileSync(path.join(mkvLibraryDir, "Episode01.mkv"), Buffer.from("video")); + + new DownloadManager( + { + ...defaultSettings(), + token: "rd-token", + outputDir: path.join(root, "downloads"), + extractDir: path.join(root, "extract"), + autoExtract: true, + autoRename4sf4sj: false, + collectMkvToLibrary: true, + mkvLibraryDir, + removeLinkFilesAfterExtract: true, + removeSamplesAfterExtract: true, + enableIntegrityCheck: false, + cleanupMode: "delete" + }, + session, + createStoragePaths(path.join(root, "state")) + ); + + await waitFor(() => !fs.existsSync(extractDir), 12000); + + expect(fs.existsSync(path.join(mkvLibraryDir, "Episode01.mkv"))).toBe(true); + expect(fs.existsSync(extractDir)).toBe(false); + }, 20000); + + it("waits for deferred archive cleanup before package_done removal without MKV collection", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-")); + tempDirs.push(root); + + const zip = new AdmZip(); + zip.addFile("Episode01.mkv", Buffer.from("video")); + zip.addFile("padding.bin", crypto.randomBytes(8 * 1024)); + const archiveBinary = zip.toBuffer(); + + const server = http.createServer((req, res) => { + if ((req.url || "") !== "/cleanup-archives-only") { + res.statusCode = 404; + res.end("not-found"); + return; + } + res.statusCode = 200; + res.setHeader("Accept-Ranges", "bytes"); + res.setHeader("Content-Length", String(archiveBinary.length)); + res.end(archiveBinary); + }); + + server.listen(0, "127.0.0.1"); + await once(server, "listening"); + + const address = server.address(); + if (!address || typeof address === "string") { + throw new Error("server address unavailable"); + } + const directUrl = `http://127.0.0.1:${address.port}/cleanup-archives-only`; + + globalThis.fetch = async (input: RequestInfo | URL, init?: RequestInit): Promise => { + const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url; + if (url.includes("/unrestrict/link")) { + return new Response( + JSON.stringify({ + download: directUrl, + filename: "cleanup-archives-only.zip", + filesize: archiveBinary.length + }), + { + status: 200, + headers: { "Content-Type": "application/json" } + } + ); + } + return originalFetch(input, init); + }; + + try { + const outputRoot = path.join(root, "downloads"); + const manager = new DownloadManager( + { + ...defaultSettings(), + token: "rd-token", + outputDir: outputRoot, + extractDir: path.join(root, "extract"), + autoExtract: true, + autoRename4sf4sj: false, + collectMkvToLibrary: false, + enableIntegrityCheck: false, + cleanupMode: "delete", + completedCleanupPolicy: "package_done" + }, + emptySession(), + createStoragePaths(path.join(root, "state")) + ); + + manager.addPackages([{ name: "cleanup-archives-only", links: ["https://dummy/cleanup-archives-only"] }]); + await manager.start(); + await waitFor(() => !manager.getSnapshot().session.running, 30000); + await waitFor(() => manager.getSnapshot().session.packageOrder.length === 0, 12000); + + expect(fs.existsSync(path.join(outputRoot, "cleanup-archives-only", "cleanup-archives-only.zip"))).toBe(false); + expect(Object.keys(manager.getSnapshot().session.items)).toHaveLength(0); + } finally { + server.close(); + await once(server, "close"); + } + }, 35000); + + it("throws a controlled error for invalid queue import JSON", () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-")); + tempDirs.push(root); + + const manager = new DownloadManager( + { + ...defaultSettings(), + token: "rd-token", + outputDir: path.join(root, "downloads"), + extractDir: path.join(root, "extract") + }, + emptySession(), + createStoragePaths(path.join(root, "state")) + ); + + expect(() => manager.importQueue("{not-json")).toThrow(/Ungultige Queue-Datei/i); + }); + + it("imports structured text exports and preserves package names and file hints", () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-")); + tempDirs.push(root); + + const manager = new DownloadManager( + { + ...defaultSettings(), + token: "rd-token", + outputDir: path.join(root, "downloads"), + extractDir: path.join(root, "extract") + }, + emptySession(), + createStoragePaths(path.join(root, "state")) + ); + + const result = manager.importQueue([ + "# rd-link-export: 1", + "# package: Dave Staffel 1", + "# file: Dave.S01E01.rar", + "https://example.com/e01", + "# file: Dave.S01E02.rar", + "https://example.com/e02" + ].join("\n")); + + expect(result).toEqual({ addedPackages: 1, addedLinks: 2 }); + + const snapshot = manager.getSnapshot(); + const packageId = snapshot.session.packageOrder[0]; + const pkg = snapshot.session.packages[packageId]; + expect(pkg?.name).toBe("Dave Staffel 1"); + const importedItems = pkg?.itemIds.map((itemId) => snapshot.session.items[itemId]); + expect(importedItems?.map((item) => item?.fileName)).toEqual(["Dave.S01E01.rar", "Dave.S01E02.rar"]); + }); + + it("applies global speed limit path when global mode is enabled", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-")); + tempDirs.push(root); + + const manager = new DownloadManager( + { + ...defaultSettings(), + token: "rd-token", + outputDir: path.join(root, "downloads"), + extractDir: path.join(root, "extract"), + speedLimitEnabled: true, + speedLimitMode: "global", + speedLimitKbps: 512 + }, + emptySession(), + createStoragePaths(path.join(root, "state")) + ); + + const internal = manager as unknown as { + applySpeedLimit: (chunkBytes: number, localWindowBytes: number, localWindowStarted: number) => Promise; + globalSpeedLimitNextAt: number; + }; + + const start = Date.now(); + await internal.applySpeedLimit(1024, 0, start); + expect(internal.globalSpeedLimitNextAt).toBeGreaterThan(start); + }); + + it("resets speed window head when start finds no runnable items", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-")); + tempDirs.push(root); + + const manager = new DownloadManager( + { + ...defaultSettings(), + token: "rd-token", + outputDir: path.join(root, "downloads"), + extractDir: path.join(root, "extract") + }, + emptySession(), + createStoragePaths(path.join(root, "state")) + ); + + const internal = manager as unknown as { + speedEvents: Array<{ at: number; bytes: number }>; + speedEventsHead: number; + speedBytesLastWindow: number; + }; + internal.speedEvents = [{ at: Date.now() - 10_000, bytes: 999 }]; + internal.speedEventsHead = 5; + internal.speedBytesLastWindow = 999; + + await manager.start(); + expect(internal.speedEventsHead).toBe(0); + expect(internal.speedEvents.length).toBe(0); + expect(internal.speedBytesLastWindow).toBe(0); + }); + + it("does not trigger global stall abort while write-buffer is disk-blocked", () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-")); + tempDirs.push(root); + const previousGlobalWatchdog = process.env.RD_GLOBAL_STALL_TIMEOUT_MS; + process.env.RD_GLOBAL_STALL_TIMEOUT_MS = "2500"; + + try { + const manager = new DownloadManager( + { + ...defaultSettings(), + token: "rd-token", + outputDir: path.join(root, "downloads"), + extractDir: path.join(root, "extract") + }, + emptySession(), + createStoragePaths(path.join(root, "state")) + ); + + manager.addPackages([{ name: "disk-block-guard", links: ["https://dummy/disk-block-guard"] }]); + const snapshot = manager.getSnapshot(); + const packageId = snapshot.session.packageOrder[0] || ""; + const itemId = snapshot.session.packages[packageId]?.itemIds[0] || ""; + + const internal = manager as unknown as any; + internal.session.running = true; + internal.session.paused = false; + internal.session.reconnectUntil = 0; + internal.session.totalDownloadedBytes = 0; + internal.session.items[itemId].status = "downloading"; + internal.lastGlobalProgressBytes = 0; + internal.lastGlobalProgressAt = Date.now() - 10000; + + const abortController = new AbortController(); + internal.activeTasks.set(itemId, { + itemId, + packageId, + abortController, + abortReason: "none", + resumable: true, + nonResumableCounted: false, + blockedOnDiskWrite: true, + blockedOnDiskSince: Date.now() - 5000 + }); + + internal.runGlobalStallWatchdog(Date.now()); + + expect(abortController.signal.aborted).toBe(false); + expect(internal.lastGlobalProgressAt).toBeGreaterThan(Date.now() - 2000); + } finally { + if (previousGlobalWatchdog === undefined) { + delete process.env.RD_GLOBAL_STALL_TIMEOUT_MS; + } else { + process.env.RD_GLOBAL_STALL_TIMEOUT_MS = previousGlobalWatchdog; + } + } + }); + + it("cleans run tracking when start conflict is skipped", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-")); + tempDirs.push(root); + + const manager = new DownloadManager( + { + ...defaultSettings(), + token: "rd-token", + outputDir: path.join(root, "downloads"), + extractDir: path.join(root, "extract") + }, + emptySession(), + createStoragePaths(path.join(root, "state")) + ); + + manager.addPackages([{ name: "conflict-skip", links: ["https://dummy/skip"] }]); + const snapshot = manager.getSnapshot(); + const packageId = snapshot.session.packageOrder[0]; + const itemId = snapshot.session.packages[packageId]?.itemIds[0] || ""; + + const internal = manager as unknown as { + runItemIds: Set; + runPackageIds: Set; + runOutcomes: Map; + }; + internal.runItemIds.add(itemId); + internal.runPackageIds.add(packageId); + internal.runOutcomes.set(itemId, "completed"); + + const result = await manager.resolveStartConflict(packageId, "skip"); + expect(result.skipped).toBe(true); + expect(internal.runItemIds.has(itemId)).toBe(false); + expect(internal.runPackageIds.has(packageId)).toBe(false); + expect(internal.runOutcomes.has(itemId)).toBe(false); + }); + + it("clears stale run outcomes on overwrite conflict resolution", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-")); + tempDirs.push(root); + + const manager = new DownloadManager( + { + ...defaultSettings(), + token: "rd-token", + outputDir: path.join(root, "downloads"), + extractDir: path.join(root, "extract") + }, + emptySession(), + createStoragePaths(path.join(root, "state")) + ); + + manager.addPackages([{ name: "conflict-overwrite", links: ["https://dummy/overwrite"] }]); + const snapshot = manager.getSnapshot(); + const packageId = snapshot.session.packageOrder[0]; + const itemId = snapshot.session.packages[packageId]?.itemIds[0] || ""; + + const internal = manager as unknown as { + runOutcomes: Map; + }; + internal.runOutcomes.set(itemId, "failed"); + + const result = await manager.resolveStartConflict(packageId, "overwrite"); + expect(result.overwritten).toBe(true); + expect(internal.runOutcomes.has(itemId)).toBe(false); + expect(manager.getSnapshot().session.items[itemId]?.status).toBe("queued"); + }); + + it("clears speed display buffers when run finishes", () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-")); + tempDirs.push(root); + + const manager = new DownloadManager( + { + ...defaultSettings(), + token: "rd-token", + outputDir: path.join(root, "downloads"), + extractDir: path.join(root, "extract") + }, + emptySession(), + createStoragePaths(path.join(root, "state")) + ); + + const internal = manager as unknown as { + runItemIds: Set; + runOutcomes: Map; + runCompletedPackages: Set; + session: { runStartedAt: number; totalDownloadedBytes: number; running: boolean; paused: boolean }; + speedEvents: Array<{ at: number; bytes: number }>; + speedEventsHead: number; + speedBytesLastWindow: number; + finishRun: () => void; + }; + + internal.session.running = true; + internal.session.paused = false; + internal.session.runStartedAt = Date.now() - 2000; + internal.session.totalDownloadedBytes = 4096; + internal.runItemIds = new Set(["x"]); + internal.runOutcomes = new Map([["x", "completed"]]); + internal.runCompletedPackages = new Set(); + internal.speedEvents = [{ at: Date.now(), bytes: 4096 }]; + internal.speedEventsHead = 1; + internal.speedBytesLastWindow = 4096; + + internal.finishRun(); + + expect(internal.speedEvents.length).toBe(0); + expect(internal.speedEventsHead).toBe(0); + expect(internal.speedBytesLastWindow).toBe(0); + }); + + it("tracks daily usage on the actual provider key without touching other providers", () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-")); + tempDirs.push(root); + + const manager = new DownloadManager( + { + ...defaultSettings(), + megaLogin: "mega-user", + megaPassword: "mega-pass", + megaCredentials: "mega-user:mega-pass", + megaDebridApiEnabled: true, + providerDailyUsageDay: getProviderUsageDayKey(), + providerDailyUsageBytes: { realdebrid: 512 }, + providerTotalUsageBytes: { realdebrid: 2048 } + }, + emptySession(), + createStoragePaths(path.join(root, "state")) + ); + + const internal = manager as unknown as { + recordProviderDownloadedBytes: (provider: "megadebrid", bytes: number) => void; + settings: ReturnType; + }; + + internal.recordProviderDownloadedBytes("megadebrid", 1024); + + expect(internal.settings.providerDailyUsageBytes.realdebrid).toBe(512); + expect(internal.settings.providerDailyUsageBytes["megadebrid-api"]).toBe(1024); + expect((internal.settings.providerDailyUsageBytes as Record).megadebrid).toBeUndefined(); + expect(internal.settings.providerTotalUsageBytes.realdebrid).toBe(2048); + expect(internal.settings.providerTotalUsageBytes["megadebrid-api"]).toBe(1024); + expect((internal.settings.providerTotalUsageBytes as Record).megadebrid).toBeUndefined(); + }); + + it("tracks daily usage on the actual Debrid-Link key without touching other keys", () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-")); + tempDirs.push(root); + const [firstKey, secondKey] = parseDebridLinkApiKeys("dl-key-one\ndl-key-two"); + + const manager = new DownloadManager( + { + ...defaultSettings(), + debridLinkApiKeys: "dl-key-one\ndl-key-two", + providerDailyUsageDay: getProviderUsageDayKey(), + providerDailyUsageBytes: { debridlink: 256 }, + providerTotalUsageBytes: { debridlink: 4096 }, + debridLinkApiKeyDailyUsageBytes: { [secondKey.id]: 512 }, + debridLinkApiKeyTotalUsageBytes: { [secondKey.id]: 2048 } + }, + emptySession(), + createStoragePaths(path.join(root, "state")) + ); + + const internal = manager as unknown as { + recordProviderDownloadedBytes: (provider: "debridlink", bytes: number, providerAccountId?: string) => void; + settings: ReturnType; + }; + + internal.recordProviderDownloadedBytes("debridlink", 1024, firstKey.id); + + expect(internal.settings.providerDailyUsageBytes.debridlink).toBe(1280); + expect(internal.settings.providerTotalUsageBytes.debridlink).toBe(5120); + expect(internal.settings.debridLinkApiKeyDailyUsageBytes[firstKey.id]).toBe(1024); + expect(internal.settings.debridLinkApiKeyDailyUsageBytes[secondKey.id]).toBe(512); + expect(internal.settings.debridLinkApiKeyTotalUsageBytes[firstKey.id]).toBe(1024); + expect(internal.settings.debridLinkApiKeyTotalUsageBytes[secondKey.id]).toBe(2048); + }); + + it("does not hang when rapid stop, disable provider, start", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-")); + tempDirs.push(root); + const binary = Buffer.alloc(256 * 1024, 7); + + const server = http.createServer((req, res) => { + if ((req.url || "") !== "/slow-dl") { + res.statusCode = 404; + res.end("not-found"); + return; + } + res.statusCode = 200; + res.setHeader("Accept-Ranges", "bytes"); + res.setHeader("Content-Length", String(binary.length)); + res.write(binary.subarray(0, Math.floor(binary.length / 4))); + const timer = setTimeout(() => { + if (!res.writableEnded && !res.destroyed) { + res.end(binary.subarray(Math.floor(binary.length / 4))); + } + }, 5000); + res.on("close", () => clearTimeout(timer)); + }); + + server.listen(0, "127.0.0.1"); + await once(server, "listening"); + + const address = server.address(); + if (!address || typeof address === "string") { + throw new Error("server address unavailable"); + } + const directUrl = `http://127.0.0.1:${address.port}/slow-dl`; + + globalThis.fetch = async (input: RequestInfo | URL, init?: RequestInit): Promise => { + const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url; + if (url.includes("/unrestrict/link")) { + return new Response( + JSON.stringify({ + download: directUrl, + filename: "test-file.bin", + filesize: binary.length + }), + { + status: 200, + headers: { "Content-Type": "application/json" } + } + ); + } + return originalFetch(input, init); + }; + + const settings = { + ...defaultSettings(), + token: "rd-token", + outputDir: path.join(root, "downloads"), + extractDir: path.join(root, "extract"), + autoExtract: false, + maxParallel: 1, + autoReconnect: false, + retryLimit: 1 + }; + + try { + const manager = new DownloadManager( + settings, + emptySession(), + createStoragePaths(path.join(root, "state")) + ); + + manager.addPackages([{ name: "hang-test", links: ["https://dummy/hang-test"] }]); + + await manager.start(); + await waitFor(() => { + const items = Object.values(manager.getSnapshot().session.items); + return items.some((item) => item.status === "downloading"); + }, 12000); + + manager.stop(); + + manager.setSettings({ + ...settings, + disabledProviders: ["realdebrid"] + }); + + const startPromise = manager.start(); + const timeout = new Promise<"timeout">((resolve) => setTimeout(() => resolve("timeout"), 8000)); + const result = await Promise.race([startPromise.then(() => "ok" as const), timeout]); + expect(result).toBe("ok"); + } finally { + server.close(); + await once(server, "close"); + } + }, 30000); + + it("bulk-adds large DLC containers without initializing per-item logs (avoids 1-2 min sync-FS freeze)", () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-bulk-add-")); + tempDirs.push(root); + const stateDir = path.join(root, "state"); + fs.mkdirSync(stateDir, { recursive: true }); + initPackageLogs(stateDir); + initItemLogs(stateDir); + const manager = new DownloadManager( + { + ...defaultSettings(), + token: "rd-token", + outputDir: path.join(root, "downloads"), + extractDir: path.join(root, "extract"), + autoExtract: false, + autoReconnect: false + }, + emptySession(), + createStoragePaths(stateDir) + ); + + const packages = Array.from({ length: 60 }, (_, pkgIdx) => ({ + name: `bulk-pkg-${pkgIdx}`, + links: Array.from({ length: 25 }, (_, linkIdx) => `https://dummy/bulk-${pkgIdx}-${linkIdx}.rar`) + })); + + const tStart = Date.now(); + const result = manager.addPackages(packages); + const elapsedMs = Date.now() - tStart; + + expect(result.addedPackages).toBe(60); + expect(result.addedLinks).toBe(1500); + + expect(elapsedMs).toBeLessThan(5000); + + const itemLogsDir = path.join(stateDir, "item-logs"); + const itemLogFiles = fs.existsSync(itemLogsDir) + ? fs.readdirSync(itemLogsDir).filter((f) => f.startsWith("item_") && f.endsWith(".txt")) + : []; + expect(itemLogFiles.length).toBe(0); + + const packageLogsDir = path.join(stateDir, "package-logs"); + const pkgLogFiles = fs.readdirSync(packageLogsDir).filter((f) => f.startsWith("package_") && f.endsWith(".txt")); + expect(pkgLogFiles.length).toBe(60); + }); + + it("serializes parallel auto-rename invocations for the same package (no Ziel existiert / ENOENT race)", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-rename-race-")); + tempDirs.push(root); + const stateDir = path.join(root, "state"); + fs.mkdirSync(stateDir, { recursive: true }); + initPackageLogs(stateDir); + initItemLogs(stateDir); + initRenameLog(stateDir); + + const extractDir = path.join(root, "extracted"); + const episodes = [ + { folder: "Test.Show.S02E01.Pilot.GERMAN.WS.720p.HDTV.x264-aWake", file: "awa-testshow02e01hd.mkv" }, + { folder: "Test.Show.S02E02.Second.GERMAN.WS.720p.HDTV.x264-aWake", file: "awa-testshow02e02hd.mkv" }, + { folder: "Test.Show.S02E03.Third.GERMAN.WS.720p.HDTV.x264-aWake", file: "awa-testshow02e03hd.mkv" } + ]; + for (const ep of episodes) { + const dir = path.join(extractDir, ep.folder); + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(path.join(dir, ep.file), Buffer.alloc(1024, 0)); + } + + const manager = new DownloadManager( + { + ...defaultSettings(), + token: "rd-token", + outputDir: path.join(root, "downloads"), + extractDir, + autoExtract: false, + autoReconnect: false, + autoRename4sf4sj: true + }, + emptySession(), + createStoragePaths(stateDir) + ); + + const pkg: any = { + id: "race-pkg-1", + name: "Test.Show.S02.GERMAN.WS.720p.HDTV.x264-aWake", + outputDir: path.join(root, "downloads", "Test.Show.S02.GERMAN.WS.720p.HDTV.x264-aWake"), + extractDir, + status: "completed", + itemIds: [], + cancelled: false, + enabled: true, + priority: "normal", + createdAt: 0, + updatedAt: 0, + downloadStartedAt: 0, + downloadCompletedAt: 0 + }; + + const [n1, n2] = await Promise.all([ + (manager as any).autoRenameExtractedVideoFiles(extractDir, pkg), + (manager as any).autoRenameExtractedVideoFiles(extractDir, pkg) + ]); + + expect(typeof n1).toBe("number"); + expect(typeof n2).toBe("number"); + expect(n1 + n2).toBe(3); + + for (const ep of episodes) { + const dir = path.join(extractDir, ep.folder); + const files = fs.readdirSync(dir); + const renamedFile = `${ep.folder}.mkv`; + expect(files).toContain(renamedFile); + expect(files).not.toContain(ep.file); + } + }); + + it("serializes rename and mkvMove across hybrid + deferred pipes (no ENOENT from cross-pipe race)", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-crosspipe-")); + tempDirs.push(root); + const stateDir = path.join(root, "state"); + fs.mkdirSync(stateDir, { recursive: true }); + + const manager = new DownloadManager( + { ...defaultSettings(), token: "rd-token", outputDir: path.join(root, "out"), extractDir: path.join(root, "extract") }, + emptySession(), + createStoragePaths(stateDir) + ); + + const pkgId = "crosspipe-pkg-1"; + let concurrent = 0; + let maxConcurrent = 0; + const op = async (ms: number): Promise => { + concurrent += 1; + maxConcurrent = Math.max(maxConcurrent, concurrent); + await new Promise((r) => setTimeout(r, ms)); + concurrent -= 1; + return `done-${ms}`; + }; + + const [r1, r2, r3, r4] = await Promise.all([ + (manager as any).chainPackageFileOp(pkgId, () => op(40)), + (manager as any).chainPackageFileOp(pkgId, () => op(20)), + (manager as any).chainPackageFileOp(pkgId, () => op(30)), + (manager as any).chainPackageFileOp(pkgId, () => op(10)) + ]); + + expect(r1).toBe("done-40"); + expect(r2).toBe("done-20"); + expect(r3).toBe("done-30"); + expect(r4).toBe("done-10"); + expect(maxConcurrent).toBe(1); + expect((manager as any).packageFileOpChain.has(pkgId)).toBe(false); + }); + + it("auto-rename refuses to scan when extractDir overlaps with mkvLibraryDir", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-overlap-")); + tempDirs.push(root); + const sharedDir = path.join(root, "library"); + fs.mkdirSync(path.join(sharedDir, "EpisodeFolder"), { recursive: true }); + fs.writeFileSync(path.join(sharedDir, "EpisodeFolder", "obfus.mkv"), Buffer.alloc(1024, 0)); + + const manager = new DownloadManager( + { + ...defaultSettings(), + token: "rd-token", + outputDir: path.join(root, "out"), + extractDir: sharedDir, + mkvLibraryDir: sharedDir, + autoRename4sf4sj: true + }, + emptySession(), + createStoragePaths(path.join(root, "state")) + ); + const pkg: any = { + id: "overlap-pkg", + name: "Overlap.Test.S01.GERMAN.x264-aWake", + outputDir: path.join(root, "out", "Overlap.Test"), + extractDir: sharedDir, + status: "completed", + itemIds: [], + cancelled: false, + enabled: true, + priority: "normal", + createdAt: 0, + updatedAt: 0, + downloadStartedAt: 0, + downloadCompletedAt: 0 + }; + + const renamed = await (manager as any).autoRenameExtractedVideoFiles(sharedDir, pkg); + expect(renamed).toBe(0); + expect(fs.existsSync(path.join(sharedDir, "EpisodeFolder", "obfus.mkv"))).toBe(true); + }); + + it("mkv-move moves SUBTITLES to library but NOT .nfo metadata files", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-mvcompanion-")); + tempDirs.push(root); + const extractDir = path.join(root, "extract"); + const libDir = path.join(root, "library"); + fs.mkdirSync(extractDir, { recursive: true }); + fs.mkdirSync(libDir, { recursive: true }); + const epFolder = path.join(extractDir, "Show.S01E01.GERMAN.x264-GROUP"); + fs.mkdirSync(epFolder, { recursive: true }); + fs.writeFileSync(path.join(epFolder, "Show.S01E01.GERMAN.x264-GROUP.mkv"), Buffer.alloc(1024, 0)); + fs.writeFileSync(path.join(epFolder, "Show.S01E01.GERMAN.x264-GROUP.srt"), "subs"); + fs.writeFileSync(path.join(epFolder, "Show.S01E01.GERMAN.x264-GROUP.nfo"), "info"); + + const manager = new DownloadManager( + { + ...defaultSettings(), + token: "rd-token", + outputDir: path.join(root, "out"), + extractDir, + autoExtract: true, + collectMkvToLibrary: true, + mkvLibraryDir: libDir + }, + emptySession(), + createStoragePaths(path.join(root, "state")) + ); + const pkg: any = { + id: "movecomp-pkg", + name: "Show.S01.GERMAN.x264-GROUP", + outputDir: path.join(root, "out", "Show"), + extractDir, + status: "completed", itemIds: [], cancelled: false, enabled: true, priority: "normal", + createdAt: 0, updatedAt: 0, downloadStartedAt: 0, downloadCompletedAt: 0 + }; + + await (manager as any).collectMkvFilesToLibrary("movecomp-pkg", pkg); + + const libFiles = fs.readdirSync(libDir); + expect(libFiles).toContain("Show.S01E01.GERMAN.x264-GROUP.mkv"); + expect(libFiles).toContain("Show.S01E01.GERMAN.x264-GROUP.srt"); + expect(libFiles).not.toContain("Show.S01E01.GERMAN.x264-GROUP.nfo"); + }); + + it("auto-rename also renames matching subtitle / .nfo companion files", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-companion-")); + tempDirs.push(root); + const extractDir = path.join(root, "extract"); + const epFolder = path.join(extractDir, "Test.Show.S02E05.Title.GERMAN.WS.720p.HDTV.x264-aWake"); + fs.mkdirSync(epFolder, { recursive: true }); + fs.writeFileSync(path.join(epFolder, "awa-testshow02e05hd.mkv"), Buffer.alloc(1024, 0)); + fs.writeFileSync(path.join(epFolder, "awa-testshow02e05hd.srt"), "subtitle"); + fs.writeFileSync(path.join(epFolder, "awa-testshow02e05hd.de.srt"), "german subtitle"); + fs.writeFileSync(path.join(epFolder, "awa-testshow02e05hd.nfo"), "info"); + + const manager = new DownloadManager( + { ...defaultSettings(), token: "rd-token", outputDir: path.join(root, "out"), extractDir, autoRename4sf4sj: true }, + emptySession(), + createStoragePaths(path.join(root, "state")) + ); + const pkg: any = { + id: "companion-pkg", + name: "Test.Show.S02.GERMAN.WS.720p.HDTV.x264-aWake", + outputDir: path.join(root, "out", "Test.Show.S02.GERMAN.WS.720p.HDTV.x264-aWake"), + extractDir, + status: "completed", itemIds: [], cancelled: false, enabled: true, priority: "normal", + createdAt: 0, updatedAt: 0, downloadStartedAt: 0, downloadCompletedAt: 0 + }; + + const renamed = await (manager as any).autoRenameExtractedVideoFiles(extractDir, pkg); + expect(renamed).toBe(1); + const expectedBase = "Test.Show.S02E05.Title.GERMAN.WS.720p.HDTV.x264-aWake"; + const files = fs.readdirSync(epFolder); + expect(files).toContain(`${expectedBase}.mkv`); + expect(files).not.toContain("awa-testshow02e05hd.mkv"); + expect(files).toContain(`${expectedBase}.srt`); + expect(files).toContain(`${expectedBase}.de.srt`); + expect(files).toContain(`${expectedBase}.nfo`); + expect(files).not.toContain("awa-testshow02e05hd.srt"); + expect(files).not.toContain("awa-testshow02e05hd.de.srt"); + expect(files).not.toContain("awa-testshow02e05hd.nfo"); + }); + + it("auto-rename appends a numeric suffix when target already exists (no silent skip)", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-collision-")); + tempDirs.push(root); + const extractDir = path.join(root, "extract"); + const epFolder = path.join(extractDir, "Test.Show.S02E05.Title.GERMAN.WS.720p.HDTV.x264-aWake"); + fs.mkdirSync(epFolder, { recursive: true }); + fs.writeFileSync(path.join(epFolder, "awa-testshow02e05hd.mkv"), Buffer.alloc(1024, 0)); + fs.writeFileSync(path.join(epFolder, "awa-testshow02e05hd.alt.mkv"), Buffer.alloc(2048, 0)); + + const manager = new DownloadManager( + { ...defaultSettings(), token: "rd-token", outputDir: path.join(root, "out"), extractDir, autoRename4sf4sj: true }, + emptySession(), + createStoragePaths(path.join(root, "state")) + ); + const pkg: any = { + id: "collision-pkg", + name: "Test.Show.S02.GERMAN.WS.720p.HDTV.x264-aWake", + outputDir: path.join(root, "out", "Test.Show.S02.GERMAN.WS.720p.HDTV.x264-aWake"), + extractDir, + status: "completed", itemIds: [], cancelled: false, enabled: true, priority: "normal", + createdAt: 0, updatedAt: 0, downloadStartedAt: 0, downloadCompletedAt: 0 + }; + + const renamed = await (manager as any).autoRenameExtractedVideoFiles(extractDir, pkg); + expect(renamed).toBe(2); + const expectedBase = "Test.Show.S02E05.Title.GERMAN.WS.720p.HDTV.x264-aWake"; + const files = fs.readdirSync(epFolder).sort(); + expect(files).toContain(`${expectedBase}.mkv`); + expect(files).toContain(`${expectedBase}.2.mkv`); + expect(files).not.toContain("awa-testshow02e05hd.mkv"); + expect(files).not.toContain("awa-testshow02e05hd.alt.mkv"); + }); + + it("chainPackageFileOp recovers from a failed op so subsequent ops still run", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-chain-recover-")); + tempDirs.push(root); + const manager = new DownloadManager( + { ...defaultSettings(), token: "rd-token", outputDir: path.join(root, "out"), extractDir: path.join(root, "extract") }, + emptySession(), + createStoragePaths(path.join(root, "state")) + ); + const pkgId = "recover-pkg"; + const [failed, nextResult] = await Promise.allSettled([ + (manager as any).chainPackageFileOp(pkgId, async () => { throw new Error("boom"); }), + (manager as any).chainPackageFileOp(pkgId, async () => "ok") + ]); + expect(failed.status).toBe("rejected"); + expect(nextResult.status).toBe("fulfilled"); + if (nextResult.status === "fulfilled") { + expect(nextResult.value).toBe("ok"); + } + }); +}); + +describe("start conflict guard + selective resume", () => { + function makeItem(id: string, packageId: string, status: string, fileName: string): any { + return { + id, packageId, url: `https://hoster.example/${id}`, provider: "realdebrid", + status, retries: 0, speedBps: 0, downloadedBytes: status === "completed" ? 100 : 0, + totalBytes: status === "completed" ? 100 : null, progressPercent: status === "completed" ? 100 : 0, + fileName, targetPath: "", resumable: true, attempts: 0, lastError: "", fullStatus: "", + createdAt: Date.now(), updatedAt: Date.now() + }; + } + + it("does not flag a partially-downloaded package whose extract dir holds its own completed output", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-startconflict-own-")); + tempDirs.push(root); + const storagePaths = createStoragePaths(path.join(root, "state")); + initPackageLogs(storagePaths.baseDir); + initItemLogs(storagePaths.baseDir); + + const session = emptySession(); + const packageId = "pkg-own-output"; + const extractDir = path.join(root, "extract", "OwnOutput"); + fs.mkdirSync(extractDir, { recursive: true }); + fs.writeFileSync(path.join(extractDir, "episode01.mkv"), Buffer.alloc(64, 7)); + session.packageOrder = [packageId]; + session.packages[packageId] = { + id: packageId, name: "OwnOutput", + outputDir: path.join(root, "downloads", "OwnOutput"), extractDir, + status: "queued", itemIds: ["own-done", "own-pending"], cancelled: false, enabled: true, + createdAt: Date.now(), updatedAt: Date.now() + } as any; + session.items["own-done"] = makeItem("own-done", packageId, "completed", "done.rar"); + session.items["own-pending"] = makeItem("own-pending", packageId, "queued", "pending.rar"); + + const manager = new DownloadManager( + { ...defaultSettings(), token: "rd-token", outputDir: path.join(root, "downloads"), extractDir: path.join(root, "extract") }, + session, storagePaths + ); + + const conflicts = await manager.getStartConflicts(); + expect(conflicts.map((c) => c.packageId)).not.toContain(packageId); + }); + + it("flags a fresh package when its package-specific extract dir already holds files and it has no completed items", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-startconflict-fresh-")); + tempDirs.push(root); + const storagePaths = createStoragePaths(path.join(root, "state")); + initPackageLogs(storagePaths.baseDir); + initItemLogs(storagePaths.baseDir); + + const session = emptySession(); + const packageId = "pkg-fresh-conflict"; + const extractDir = path.join(root, "extract", "FreshConflict"); + fs.mkdirSync(extractDir, { recursive: true }); + fs.writeFileSync(path.join(extractDir, "old-from-previous-run.mkv"), Buffer.alloc(64, 9)); + session.packageOrder = [packageId]; + session.packages[packageId] = { + id: packageId, name: "FreshConflict", + outputDir: path.join(root, "downloads", "FreshConflict"), extractDir, + status: "queued", itemIds: ["fresh-pending"], cancelled: false, enabled: true, + createdAt: Date.now(), updatedAt: Date.now() + } as any; + session.items["fresh-pending"] = makeItem("fresh-pending", packageId, "queued", "fresh.rar"); + + const manager = new DownloadManager( + { ...defaultSettings(), token: "rd-token", outputDir: path.join(root, "downloads"), extractDir: path.join(root, "extract") }, + session, storagePaths + ); + + const conflicts = await manager.getStartConflicts(); + expect(conflicts.map((c) => c.packageId)).toContain(packageId); + }); + + it("start() holds excluded packages out of the run set and runs the rest", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-selective-resume-")); + tempDirs.push(root); + const storagePaths = createStoragePaths(path.join(root, "state")); + initPackageLogs(storagePaths.baseDir); + initItemLogs(storagePaths.baseDir); + + const session = emptySession(); + const runId = "pkg-run"; + const holdId = "pkg-hold"; + fs.mkdirSync(path.join(root, "downloads", "RunMe"), { recursive: true }); + fs.mkdirSync(path.join(root, "downloads", "HoldMe"), { recursive: true }); + session.packageOrder = [runId, holdId]; + session.packages[runId] = { + id: runId, name: "RunMe", + outputDir: path.join(root, "downloads", "RunMe"), extractDir: path.join(root, "extract", "RunMe"), + status: "queued", itemIds: ["run-item"], cancelled: false, enabled: true, + createdAt: Date.now(), updatedAt: Date.now() + } as any; + session.packages[holdId] = { + id: holdId, name: "HoldMe", + outputDir: path.join(root, "downloads", "HoldMe"), extractDir: path.join(root, "extract", "HoldMe"), + status: "queued", itemIds: ["hold-item"], cancelled: false, enabled: true, + createdAt: Date.now(), updatedAt: Date.now() + } as any; + session.items["run-item"] = makeItem("run-item", runId, "queued", "run.rar"); + session.items["hold-item"] = makeItem("hold-item", holdId, "queued", "hold.rar"); + + const manager = new DownloadManager( + { ...defaultSettings(), token: "rd-token", maxParallel: 2, outputDir: path.join(root, "downloads"), extractDir: path.join(root, "extract") }, + session, storagePaths + ); + (manager as any).debridService.unrestrictLink = () => new Promise(() => {}); + + await manager.start({ excludePackageIds: new Set([holdId]) }); + + expect((manager as any).runPackageIds.has(runId)).toBe(true); + expect((manager as any).runPackageIds.has(holdId)).toBe(false); + expect((manager as any).runItemIds.has("run-item")).toBe(true); + expect((manager as any).runItemIds.has("hold-item")).toBe(false); + expect(session.items["hold-item"].status).toBe("queued"); + + manager.stop(); + }); +}); + +describe("mega-debrid api/web resolution overlap gate", () => { + function megaApiSettings(root: string): any { + return { + ...defaultSettings(), + megaLogin: "u", megaPassword: "p", megaCredentials: "u:p", + megaDebridApiEnabled: true, megaDebridWebEnabled: true, megaDebridPreferApi: true, + outputDir: path.join(root, "downloads"), extractDir: path.join(root, "extract") + }; + } + + function megaItem(id: string, status: string): any { + return { + id, packageId: "pkg", url: `https://rapidgator.net/file/${id}`, provider: "megadebrid-api", + status, retries: 0, speedBps: 0, downloadedBytes: 0, totalBytes: null, progressPercent: 0, + fileName: `${id}.rar`, targetPath: "", resumable: true, attempts: 0, lastError: "", fullStatus: "", + createdAt: Date.now(), updatedAt: Date.now() + }; + } + + function addValidating(manager: DownloadManager, session: any, ids: string[]): void { + for (const id of ids) { + session.items[id] = megaItem(id, "validating"); + (manager as any).activeTasks.set(id, { + itemId: id, packageId: "pkg", abortController: new AbortController(), abortReason: "none", + resumable: true, nonResumableCounted: false, blockedOnDiskWrite: false, blockedOnDiskSince: 0 + }); + } + } + + function buildManager(root: string, session: any): DownloadManager { + return new DownloadManager(megaApiSettings(root), session, createStoragePaths(path.join(root, "state"))); + } + + it("lets the first mega resolve start when nothing is in flight", () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-overlap-0-")); + tempDirs.push(root); + const session = emptySession(); + const candidate = megaItem("cand", "queued"); + session.items["cand"] = candidate; + const manager = buildManager(root, session); + expect((manager as any).shouldDelayStartForItem(candidate)).toBe(false); + }); + + it("serializes a second API resolve while the first is still in its API phase (no concurrent API)", () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-overlap-1-")); + tempDirs.push(root); + const session = emptySession(); + const candidate = megaItem("cand", "queued"); + session.items["cand"] = candidate; + const manager = buildManager(root, session); + addValidating(manager, session, ["a"]); + expect((manager as any).shouldDelayStartForItem(candidate)).toBe(true); + }); + + it("allows one API resolve to overlap once the first has moved to its web phase", () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-overlap-2-")); + tempDirs.push(root); + const session = emptySession(); + const candidate = megaItem("cand", "queued"); + session.items["cand"] = candidate; + const manager = buildManager(root, session); + addValidating(manager, session, ["a"]); + primeMegaDebridInFlightForTests("acc:web", 1); + expect((manager as any).shouldDelayStartForItem(candidate)).toBe(false); + }); + + it("caps the overlap at one API plus one web (no third concurrent resolve)", () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-overlap-3-")); + tempDirs.push(root); + const session = emptySession(); + const candidate = megaItem("cand", "queued"); + session.items["cand"] = candidate; + const manager = buildManager(root, session); + addValidating(manager, session, ["a", "b"]); + primeMegaDebridInFlightForTests("acc:web", 1); + expect((manager as any).shouldDelayStartForItem(candidate)).toBe(true); + }); +}); diff --git a/tests/error-ring.test.ts b/tests/error-ring.test.ts new file mode 100644 index 0000000..4b97e0e --- /dev/null +++ b/tests/error-ring.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, it } from "vitest"; +import { createErrorRing } from "../src/main/error-ring"; + +describe("createErrorRing", () => { + it("keeps entries in insertion order", () => { + const ring = createErrorRing(10); + ring.push({ ts: "t1", level: "ERROR", message: "a" }); + ring.push({ ts: "t2", level: "WARN", message: "b" }); + expect(ring.snapshot().map((e) => e.message)).toEqual(["a", "b"]); + expect(ring.size()).toBe(2); + }); + + it("caps at capacity by dropping the oldest", () => { + const ring = createErrorRing(3); + for (const m of ["a", "b", "c", "d", "e"]) { + ring.push({ ts: m, level: "ERROR", message: m }); + } + expect(ring.snapshot().map((e) => e.message)).toEqual(["c", "d", "e"]); + expect(ring.size()).toBe(3); + }); + + it("snapshot returns a copy, not the live buffer", () => { + const ring = createErrorRing(5); + ring.push({ ts: "t", level: "WARN", message: "x" }); + const snap = ring.snapshot(); + snap.push({ ts: "t2", level: "ERROR", message: "injected" }); + expect(ring.snapshot().map((e) => e.message)).toEqual(["x"]); + }); + + it("clear empties the ring", () => { + const ring = createErrorRing(5); + ring.push({ ts: "t", level: "ERROR", message: "x" }); + ring.clear(); + expect(ring.snapshot()).toEqual([]); + expect(ring.size()).toBe(0); + }); + + it("coerces a non-positive capacity to at least 1", () => { + const ring = createErrorRing(0); + ring.push({ ts: "t1", level: "ERROR", message: "a" }); + ring.push({ ts: "t2", level: "ERROR", message: "b" }); + expect(ring.snapshot().map((e) => e.message)).toEqual(["b"]); + }); +}); diff --git a/tests/extractor-jvm.test.ts b/tests/extractor-jvm.test.ts new file mode 100644 index 0000000..334b95c --- /dev/null +++ b/tests/extractor-jvm.test.ts @@ -0,0 +1,196 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { spawnSync } from "node:child_process"; +import AdmZip from "adm-zip"; +import { afterEach, describe, expect, it } from "vitest"; +import { extractPackageArchives } from "../src/main/extractor"; + +const tempDirs: string[] = []; +const originalBackend = process.env.RD_EXTRACT_BACKEND; + +function hasJavaRuntime(): boolean { + const result = spawnSync("java", ["-version"], { stdio: "ignore" }); + return result.status === 0; +} + +function hasJvmExtractorRuntime(): boolean { + const root = path.join(process.cwd(), "resources", "extractor-jvm"); + const classesMain = path.join(root, "classes", "com", "sucukdeluxe", "extractor", "JBindExtractorMain.class"); + const requiredLibs = [ + path.join(root, "lib", "sevenzipjbinding.jar"), + path.join(root, "lib", "sevenzipjbinding-all-platforms.jar"), + path.join(root, "lib", "zip4j.jar") + ]; + return fs.existsSync(classesMain) && requiredLibs.every((libPath) => fs.existsSync(libPath)); +} + +afterEach(() => { + for (const dir of tempDirs.splice(0)) { + fs.rmSync(dir, { recursive: true, force: true }); + } + if (originalBackend === undefined) { + delete process.env.RD_EXTRACT_BACKEND; + } else { + process.env.RD_EXTRACT_BACKEND = originalBackend; + } +}); + +describe.skipIf(!hasJavaRuntime() || !hasJvmExtractorRuntime())("extractor jvm backend", () => { + it("extracts zip archives through SevenZipJBinding backend", async () => { + process.env.RD_EXTRACT_BACKEND = "jvm"; + + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-jvm-extract-")); + tempDirs.push(root); + const packageDir = path.join(root, "pkg"); + const targetDir = path.join(root, "out"); + fs.mkdirSync(packageDir, { recursive: true }); + + const zipPath = path.join(packageDir, "release.zip"); + const zip = new AdmZip(); + zip.addFile("episode.txt", Buffer.from("ok")); + zip.writeZip(zipPath); + + const result = await extractPackageArchives({ + packageDir, + targetDir, + cleanupMode: "none", + conflictMode: "overwrite", + removeLinks: false, + removeSamples: false + }); + + expect(result.extracted).toBe(1); + expect(result.failed).toBe(0); + expect(fs.existsSync(path.join(targetDir, "episode.txt"))).toBe(true); + }); + + it("emits progress callbacks with archiveName and percent", async () => { + process.env.RD_EXTRACT_BACKEND = "jvm"; + + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-jvm-progress-")); + tempDirs.push(root); + const packageDir = path.join(root, "pkg"); + const targetDir = path.join(root, "out"); + fs.mkdirSync(packageDir, { recursive: true }); + + const zipPath = path.join(packageDir, "progress-test.zip"); + const zip = new AdmZip(); + zip.addFile("file1.txt", Buffer.from("Hello World ".repeat(100))); + zip.addFile("file2.txt", Buffer.from("Another file ".repeat(100))); + zip.writeZip(zipPath); + + const progressUpdates: Array<{ + archiveName: string; + percent: number; + phase: string; + archivePercent?: number; + }> = []; + + const result = await extractPackageArchives({ + packageDir, + targetDir, + cleanupMode: "none", + conflictMode: "overwrite", + removeLinks: false, + removeSamples: false, + onProgress: (update) => { + progressUpdates.push({ + archiveName: update.archiveName, + percent: update.percent, + phase: update.phase, + archivePercent: update.archivePercent, + }); + }, + }); + + expect(result.extracted).toBe(1); + expect(result.failed).toBe(0); + + const phases = new Set(progressUpdates.map((u) => u.phase)); + expect(phases.has("preparing")).toBe(true); + expect(phases.has("extracting")).toBe(true); + + const extracting = progressUpdates.filter((u) => u.phase === "extracting" && u.archiveName === "progress-test.zip"); + expect(extracting.length).toBeGreaterThan(0); + + const lastExtracting = extracting[extracting.length - 1]; + expect(lastExtracting.archivePercent).toBe(100); + + expect(fs.existsSync(path.join(targetDir, "file1.txt"))).toBe(true); + expect(fs.existsSync(path.join(targetDir, "file2.txt"))).toBe(true); + }); + + it("extracts multiple archives sequentially with progress for each", async () => { + process.env.RD_EXTRACT_BACKEND = "jvm"; + + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-jvm-multi-")); + tempDirs.push(root); + const packageDir = path.join(root, "pkg"); + const targetDir = path.join(root, "out"); + fs.mkdirSync(packageDir, { recursive: true }); + + const zip1 = new AdmZip(); + zip1.addFile("episode01.txt", Buffer.from("ep1 content")); + zip1.writeZip(path.join(packageDir, "archive1.zip")); + + const zip2 = new AdmZip(); + zip2.addFile("episode02.txt", Buffer.from("ep2 content")); + zip2.writeZip(path.join(packageDir, "archive2.zip")); + + const archiveNames = new Set(); + + const result = await extractPackageArchives({ + packageDir, + targetDir, + cleanupMode: "none", + conflictMode: "overwrite", + removeLinks: false, + removeSamples: false, + onProgress: (update) => { + if (update.phase === "extracting" && update.archiveName) { + archiveNames.add(update.archiveName); + } + }, + }); + + expect(result.extracted).toBe(2); + expect(result.failed).toBe(0); + expect(archiveNames.has("archive1.zip")).toBe(true); + expect(archiveNames.has("archive2.zip")).toBe(true); + expect(fs.existsSync(path.join(targetDir, "episode01.txt"))).toBe(true); + expect(fs.existsSync(path.join(targetDir, "episode02.txt"))).toBe(true); + }); + + it("respects ask/skip conflict mode in jvm backend", async () => { + process.env.RD_EXTRACT_BACKEND = "jvm"; + + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-jvm-extract-")); + tempDirs.push(root); + const packageDir = path.join(root, "pkg"); + const targetDir = path.join(root, "out"); + fs.mkdirSync(packageDir, { recursive: true }); + fs.mkdirSync(targetDir, { recursive: true }); + + const zipPath = path.join(packageDir, "conflict.zip"); + const zip = new AdmZip(); + zip.addFile("same.txt", Buffer.from("new")); + zip.writeZip(zipPath); + + const existingPath = path.join(targetDir, "same.txt"); + fs.writeFileSync(existingPath, "old", "utf8"); + + const result = await extractPackageArchives({ + packageDir, + targetDir, + cleanupMode: "none", + conflictMode: "ask", + removeLinks: false, + removeSamples: false + }); + + expect(result.extracted).toBe(1); + expect(result.failed).toBe(0); + expect(fs.readFileSync(existingPath, "utf8")).toBe("old"); + }); +}); diff --git a/tests/extractor.test.ts b/tests/extractor.test.ts new file mode 100644 index 0000000..3dec775 --- /dev/null +++ b/tests/extractor.test.ts @@ -0,0 +1,1287 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import AdmZip from "adm-zip"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { + buildExternalExtractArgs, + cleanErrorText, + collectArchiveCleanupTargets, + extractPackageArchives, + type ExtractArchiveFailureInfo, + archiveFilenamePasswords, + detectArchiveSignature, + classifyExtractionError, + ExtractionError, + selectZipFallbackError, + shouldSerialRetryParallelFailures, + findArchiveCandidates, + orderExtractorCandidatesForArchive, + resolveExtractorBackendModeForArchive, + resolveExtractorBackendMode, + shouldFallbackLegacyRarToJvm, +} from "../src/main/extractor"; + +const tempDirs: string[] = []; +const originalExtractBackend = process.env.RD_EXTRACT_BACKEND; +const originalStatfs = fs.promises.statfs; + +beforeEach(() => { + process.env.RD_EXTRACT_BACKEND = "legacy"; +}); + +afterEach(() => { + for (const dir of tempDirs.splice(0)) { + fs.rmSync(dir, { recursive: true, force: true }); + } + if (originalExtractBackend === undefined) { + delete process.env.RD_EXTRACT_BACKEND; + } else { + process.env.RD_EXTRACT_BACKEND = originalExtractBackend; + } + (fs.promises as any).statfs = originalStatfs; +}); + +describe("extractor", () => { + it("maps external extractor args by conflict mode", () => { + const overwriteArgs = buildExternalExtractArgs("WinRAR.exe", "archive.rar", "C:\\target", "overwrite"); + expect(overwriteArgs.slice(0, 4)).toEqual(["x", "-o+", "-p-", "-y"]); + expect(overwriteArgs).toContain("-idc"); + expect(overwriteArgs.some((value) => /^-mt\d+$/i.test(value))).toBe(true); + expect(overwriteArgs[overwriteArgs.length - 2]).toBe("archive.rar"); + expect(overwriteArgs[overwriteArgs.length - 1]).toBe("C:\\target\\"); + + const askArgs = buildExternalExtractArgs("WinRAR.exe", "archive.rar", "C:\\target", "ask", "serienfans.org"); + expect(askArgs.slice(0, 4)).toEqual(["x", "-o-", "-pserienfans.org", "-y"]); + expect(askArgs).toContain("-idc"); + expect(askArgs.some((value) => /^-mt\d+$/i.test(value))).toBe(true); + expect(askArgs[askArgs.length - 2]).toBe("archive.rar"); + expect(askArgs[askArgs.length - 1]).toBe("C:\\target\\"); + + const compatibilityArgs = buildExternalExtractArgs("WinRAR.exe", "archive.rar", "C:\\target", "overwrite", "", false); + expect(compatibilityArgs).not.toContain("-idc"); + expect(compatibilityArgs.some((value) => /^-mt\d+$/i.test(value))).toBe(false); + + const unrarRename = buildExternalExtractArgs("unrar", "archive.rar", "C:\\target", "rename"); + expect(unrarRename[0]).toBe("x"); + expect(unrarRename[1]).toBe("-or"); + expect(unrarRename[2]).toBe("-p-"); + expect(unrarRename[3]).toBe("-y"); + expect(unrarRename[unrarRename.length - 2]).toBe("archive.rar"); + + const rarCliArgs = buildExternalExtractArgs("Rar.exe", "archive.rar", "C:\\target", "overwrite", "serienjunkies.org"); + expect(rarCliArgs.slice(0, 4)).toEqual(["x", "-o+", "-pserienjunkies.org", "-y"]); + expect(rarCliArgs[rarCliArgs.length - 2]).toBe("archive.rar"); + expect(rarCliArgs[rarCliArgs.length - 1]).toBe("C:\\target\\"); + }); + + it("deletes only successfully extracted archives", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-extract-")); + tempDirs.push(root); + const packageDir = path.join(root, "pkg"); + const targetDir = path.join(root, "out"); + fs.mkdirSync(packageDir, { recursive: true }); + + const validZipPath = path.join(packageDir, "ok.zip"); + const invalidZipPath = path.join(packageDir, "bad.zip"); + + const zip = new AdmZip(); + zip.addFile("release.txt", Buffer.from("ok")); + zip.writeZip(validZipPath); + fs.writeFileSync(invalidZipPath, "not-a-zip", "utf8"); + + const result = await extractPackageArchives({ + packageDir, + targetDir, + cleanupMode: "delete", + conflictMode: "overwrite", + removeLinks: false, + removeSamples: false + }); + + expect(result.extracted).toBe(1); + expect(result.failed).toBe(1); + expect(fs.existsSync(validZipPath)).toBe(false); + expect(fs.existsSync(invalidZipPath)).toBe(true); + expect(fs.existsSync(path.join(targetDir, "release.txt"))).toBe(true); + }); + + it("collects companion rar parts for cleanup", () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-extract-")); + tempDirs.push(root); + const packageDir = path.join(root, "pkg"); + fs.mkdirSync(packageDir, { recursive: true }); + + const part1 = path.join(packageDir, "show.s01e01.part01.rar"); + const part2 = path.join(packageDir, "show.s01e01.part02.rar"); + const part3 = path.join(packageDir, "show.s01e01.part03.rar"); + const other = path.join(packageDir, "other.s01e01.part01.rar"); + + fs.writeFileSync(part1, "a", "utf8"); + fs.writeFileSync(part2, "b", "utf8"); + fs.writeFileSync(part3, "c", "utf8"); + fs.writeFileSync(other, "x", "utf8"); + + const targets = new Set(collectArchiveCleanupTargets(part1)); + expect(targets.has(part1)).toBe(true); + expect(targets.has(part2)).toBe(true); + expect(targets.has(part3)).toBe(true); + expect(targets.has(other)).toBe(false); + }); + + it("collects split 7z companion parts for cleanup", () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-extract-")); + tempDirs.push(root); + const packageDir = path.join(root, "pkg"); + fs.mkdirSync(packageDir, { recursive: true }); + + const part1 = path.join(packageDir, "release.7z.001"); + const part2 = path.join(packageDir, "release.7z.002"); + const part3 = path.join(packageDir, "release.7z.003"); + const other = path.join(packageDir, "other.7z.001"); + + fs.writeFileSync(part1, "a", "utf8"); + fs.writeFileSync(part2, "b", "utf8"); + fs.writeFileSync(part3, "c", "utf8"); + fs.writeFileSync(other, "x", "utf8"); + + const targets = new Set(collectArchiveCleanupTargets(part1)); + expect(targets.has(part1)).toBe(true); + expect(targets.has(part2)).toBe(true); + expect(targets.has(part3)).toBe(true); + expect(targets.has(other)).toBe(false); + }); + + it("extracts archives in natural episode order", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-extract-")); + tempDirs.push(root); + const packageDir = path.join(root, "pkg"); + const targetDir = path.join(root, "out"); + fs.mkdirSync(packageDir, { recursive: true }); + + const zip10 = new AdmZip(); + zip10.addFile("e10.txt", Buffer.from("10")); + zip10.writeZip(path.join(packageDir, "Show.S01E10.zip")); + + const zip2 = new AdmZip(); + zip2.addFile("e02.txt", Buffer.from("02")); + zip2.writeZip(path.join(packageDir, "Show.S01E02.zip")); + + const zip1 = new AdmZip(); + zip1.addFile("e01.txt", Buffer.from("01")); + zip1.writeZip(path.join(packageDir, "Show.S01E01.zip")); + + const seenOrder: string[] = []; + await extractPackageArchives({ + packageDir, + targetDir, + cleanupMode: "none", + conflictMode: "overwrite", + removeLinks: false, + removeSamples: false, + onProgress: (update) => { + if (update.phase !== "extracting" || !update.archiveName) { + return; + } + if (seenOrder[seenOrder.length - 1] === update.archiveName) { + return; + } + seenOrder.push(update.archiveName); + } + }); + + expect(seenOrder.slice(0, 3)).toEqual([ + "Show.S01E01.zip", + "Show.S01E02.zip", + "Show.S01E10.zip" + ]); + }); + + it("deletes split zip companion parts when cleanup is enabled", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-extract-")); + tempDirs.push(root); + const packageDir = path.join(root, "pkg"); + const targetDir = path.join(root, "out"); + fs.mkdirSync(packageDir, { recursive: true }); + + const zipPath = path.join(packageDir, "season.zip"); + const z01Path = path.join(packageDir, "season.z01"); + const z02Path = path.join(packageDir, "season.z02"); + const otherPath = path.join(packageDir, "other.z01"); + + const zip = new AdmZip(); + zip.addFile("episode.txt", Buffer.from("ok")); + zip.writeZip(zipPath); + fs.writeFileSync(z01Path, "part1", "utf8"); + fs.writeFileSync(z02Path, "part2", "utf8"); + fs.writeFileSync(otherPath, "keep", "utf8"); + + const result = await extractPackageArchives({ + packageDir, + targetDir, + cleanupMode: "delete", + conflictMode: "overwrite", + removeLinks: false, + removeSamples: false + }); + + expect(result.extracted).toBe(1); + expect(result.failed).toBe(0); + expect(fs.existsSync(zipPath)).toBe(false); + expect(fs.existsSync(z01Path)).toBe(false); + expect(fs.existsSync(z02Path)).toBe(false); + expect(fs.existsSync(otherPath)).toBe(true); + expect(fs.existsSync(path.join(targetDir, "episode.txt"))).toBe(true); + }); + + it("removes empty package directory after archive cleanup", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-extract-")); + tempDirs.push(root); + const packageDir = path.join(root, "pkg"); + const targetDir = path.join(root, "out"); + fs.mkdirSync(packageDir, { recursive: true }); + + const zipPath = path.join(packageDir, "release.zip"); + const zip = new AdmZip(); + zip.addFile("video.mkv", Buffer.from("ok")); + zip.writeZip(zipPath); + + const result = await extractPackageArchives({ + packageDir, + targetDir, + cleanupMode: "delete", + conflictMode: "overwrite", + removeLinks: false, + removeSamples: false + }); + + expect(result.extracted).toBe(1); + expect(result.failed).toBe(0); + expect(fs.existsSync(packageDir)).toBe(false); + expect(fs.existsSync(path.join(targetDir, "video.mkv"))).toBe(true); + }); + + it("keeps package directory when non-archive files remain", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-extract-")); + tempDirs.push(root); + const packageDir = path.join(root, "pkg"); + const targetDir = path.join(root, "out"); + fs.mkdirSync(packageDir, { recursive: true }); + + const zipPath = path.join(packageDir, "release.zip"); + const keepPath = path.join(packageDir, "notes.nfo"); + const zip = new AdmZip(); + zip.addFile("video.mkv", Buffer.from("ok")); + zip.writeZip(zipPath); + fs.writeFileSync(keepPath, "keep", "utf8"); + + const result = await extractPackageArchives({ + packageDir, + targetDir, + cleanupMode: "delete", + conflictMode: "overwrite", + removeLinks: false, + removeSamples: false + }); + + expect(result.extracted).toBe(1); + expect(result.failed).toBe(0); + expect(fs.existsSync(packageDir)).toBe(true); + expect(fs.existsSync(keepPath)).toBe(true); + expect(fs.existsSync(path.join(targetDir, "video.mkv"))).toBe(true); + }); + + it("reports extraction progress from 0 to 100", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-extract-")); + tempDirs.push(root); + const packageDir = path.join(root, "pkg"); + const targetDir = path.join(root, "out"); + fs.mkdirSync(packageDir, { recursive: true }); + + const zip1 = new AdmZip(); + zip1.addFile("a.txt", Buffer.from("a")); + zip1.writeZip(path.join(packageDir, "a.zip")); + + const zip2 = new AdmZip(); + zip2.addFile("b.txt", Buffer.from("b")); + zip2.writeZip(path.join(packageDir, "b.zip")); + + const updates: number[] = []; + const result = await extractPackageArchives({ + packageDir, + targetDir, + cleanupMode: "none", + conflictMode: "overwrite", + removeLinks: false, + removeSamples: false, + onProgress: (update) => { + updates.push(update.percent); + } + }); + + expect(result.extracted).toBe(2); + expect(result.failed).toBe(0); + expect(updates[0]).toBe(0); + expect(updates.some((value) => value > 0 && value < 100)).toBe(true); + expect(updates[updates.length - 1]).toBe(100); + }); + + it("treats ask conflict mode as skip in zip extraction", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-extract-")); + tempDirs.push(root); + const packageDir = path.join(root, "pkg"); + const targetDir = path.join(root, "out"); + fs.mkdirSync(packageDir, { recursive: true }); + fs.mkdirSync(targetDir, { recursive: true }); + + const zipPath = path.join(packageDir, "conflict.zip"); + const zip = new AdmZip(); + zip.addFile("same.txt", Buffer.from("new")); + zip.writeZip(zipPath); + + const existingPath = path.join(targetDir, "same.txt"); + fs.writeFileSync(existingPath, "old", "utf8"); + + const result = await extractPackageArchives({ + packageDir, + targetDir, + cleanupMode: "none", + conflictMode: "ask", + removeLinks: false, + removeSamples: false + }); + + expect(result.extracted).toBe(1); + expect(result.failed).toBe(0); + expect(fs.readFileSync(existingPath, "utf8")).toBe("old"); + }); + + it("does not keep empty target dir when extraction fails", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-extract-")); + tempDirs.push(root); + const packageDir = path.join(root, "pkg"); + const targetDir = path.join(root, "out"); + fs.mkdirSync(packageDir, { recursive: true }); + + fs.writeFileSync(path.join(packageDir, "broken.zip"), "not-a-zip", "utf8"); + const result = await extractPackageArchives({ + packageDir, + targetDir, + cleanupMode: "none", + conflictMode: "overwrite", + removeLinks: false, + removeSamples: false + }); + + expect(result.extracted).toBe(0); + expect(result.failed).toBe(1); + expect(fs.existsSync(targetDir)).toBe(false); + }); + + it("resumes extraction from persisted progress file", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-extract-")); + tempDirs.push(root); + const packageDir = path.join(root, "pkg"); + const targetDir = path.join(root, "out"); + fs.mkdirSync(packageDir, { recursive: true }); + + const zipA = new AdmZip(); + zipA.addFile("a.txt", Buffer.from("a")); + zipA.writeZip(path.join(packageDir, "a.zip")); + + const zipB = new AdmZip(); + zipB.addFile("b.txt", Buffer.from("b")); + zipB.writeZip(path.join(packageDir, "b.zip")); + + fs.writeFileSync(path.join(packageDir, ".rd_extract_progress.json"), JSON.stringify({ completedArchives: ["a.zip"] }), "utf8"); + + const result = await extractPackageArchives({ + packageDir, + targetDir, + cleanupMode: "none", + conflictMode: "overwrite", + removeLinks: false, + removeSamples: false + }); + + expect(result.extracted).toBe(2); + expect(result.failed).toBe(0); + expect(fs.existsSync(path.join(targetDir, "b.txt"))).toBe(true); + expect(fs.existsSync(path.join(packageDir, ".rd_extract_progress.json"))).toBe(false); + }); + + it("aborts extraction immediately when abort signal is set", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-extract-")); + tempDirs.push(root); + const packageDir = path.join(root, "pkg"); + const targetDir = path.join(root, "out"); + fs.mkdirSync(packageDir, { recursive: true }); + + const zip = new AdmZip(); + zip.addFile("file.txt", Buffer.from("x")); + zip.writeZip(path.join(packageDir, "file.zip")); + + const controller = new AbortController(); + controller.abort(); + + await expect(extractPackageArchives({ + packageDir, + targetDir, + cleanupMode: "none", + conflictMode: "overwrite", + removeLinks: false, + removeSamples: false, + signal: controller.signal + })).rejects.toThrow("aborted:extract"); + }); + + it("handles missing package source directory without throwing", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-extract-")); + tempDirs.push(root); + const packageDir = path.join(root, "pkg-missing"); + const targetDir = path.join(root, "out"); + fs.mkdirSync(targetDir, { recursive: true }); + fs.writeFileSync(path.join(targetDir, "video.mkv"), "ok", "utf8"); + + const result = await extractPackageArchives({ + packageDir, + targetDir, + cleanupMode: "none", + conflictMode: "overwrite", + removeLinks: false, + removeSamples: false + }); + + expect(result.failed).toBe(0); + expect(result.extracted).toBe(0); + }); + + it("rejects zip entries with path traversal", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-extract-")); + tempDirs.push(root); + const packageDir = path.join(root, "pkg"); + const targetDir = path.join(root, "out"); + fs.mkdirSync(packageDir, { recursive: true }); + + const zip = new AdmZip(); + zip.addFile("safe.txt", Buffer.from("safe")); + zip.addFile("../escaped.txt", Buffer.from("malicious")); + zip.writeZip(path.join(packageDir, "traversal.zip")); + + const result = await extractPackageArchives({ + packageDir, + targetDir, + cleanupMode: "none", + conflictMode: "overwrite", + removeLinks: false, + removeSamples: false + }); + + expect(result.extracted).toBe(1); + expect(fs.existsSync(path.join(targetDir, "safe.txt"))).toBe(true); + expect(fs.existsSync(path.join(root, "escaped.txt"))).toBe(false); + }); + + it("builds external extract args for 7z-style extractor", () => { + const args7z = buildExternalExtractArgs("7z.exe", "archive.7z", "C:\\target", "overwrite"); + expect(args7z[0]).toBe("x"); + expect(args7z).toContain("-y"); + expect(args7z).toContain("-aoa"); + expect(args7z).toContain("-p"); + expect(args7z).toContain("archive.7z"); + expect(args7z).toContain("-oC:\\target"); + }); + + it("builds 7z args with skip conflict mode", () => { + const args = buildExternalExtractArgs("7z", "archive.zip", "/out", "skip"); + expect(args).toContain("-aos"); + }); + + it("builds 7z args with rename conflict mode", () => { + const args = buildExternalExtractArgs("7z", "archive.zip", "/out", "rename"); + expect(args).toContain("-aou"); + }); + + it("builds 7z args with password", () => { + const args = buildExternalExtractArgs("7z", "archive.7z", "/out", "overwrite", "secretpass"); + expect(args).toContain("-psecretpass"); + }); + + it("builds WinRAR args with empty password uses -p-", () => { + const args = buildExternalExtractArgs("WinRAR.exe", "archive.rar", "/out", "overwrite", ""); + expect(args).toContain("-p-"); + }); + + it("builds WinRAR args with skip conflict mode uses -o-", () => { + const args = buildExternalExtractArgs("WinRAR.exe", "archive.rar", "/out", "skip"); + expect(args[1]).toBe("-o-"); + }); + + it("collects split zip companion parts for cleanup", () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-extract-")); + tempDirs.push(root); + const packageDir = path.join(root, "pkg"); + fs.mkdirSync(packageDir, { recursive: true }); + + const mainZip = path.join(packageDir, "release.zip"); + const z01 = path.join(packageDir, "release.z01"); + const z02 = path.join(packageDir, "release.z02"); + const otherZip = path.join(packageDir, "other.zip"); + + fs.writeFileSync(mainZip, "a", "utf8"); + fs.writeFileSync(z01, "b", "utf8"); + fs.writeFileSync(z02, "c", "utf8"); + fs.writeFileSync(otherZip, "x", "utf8"); + + const targets = new Set(collectArchiveCleanupTargets(mainZip)); + expect(targets.has(mainZip)).toBe(true); + expect(targets.has(z01)).toBe(true); + expect(targets.has(z02)).toBe(true); + expect(targets.has(otherZip)).toBe(false); + }); + + it("collects numbered split zip parts (.zip.001, .zip.002) for cleanup", () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-extract-")); + tempDirs.push(root); + const packageDir = path.join(root, "pkg"); + fs.mkdirSync(packageDir, { recursive: true }); + + const part1 = path.join(packageDir, "movie.zip.001"); + const part2 = path.join(packageDir, "movie.zip.002"); + const part3 = path.join(packageDir, "movie.zip.003"); + const mainZip = path.join(packageDir, "movie.zip"); + const other = path.join(packageDir, "other.zip.001"); + + fs.writeFileSync(part1, "a", "utf8"); + fs.writeFileSync(part2, "b", "utf8"); + fs.writeFileSync(part3, "c", "utf8"); + fs.writeFileSync(mainZip, "d", "utf8"); + fs.writeFileSync(other, "x", "utf8"); + + const targets = new Set(collectArchiveCleanupTargets(part1)); + expect(targets.has(part1)).toBe(true); + expect(targets.has(part2)).toBe(true); + expect(targets.has(part3)).toBe(true); + expect(targets.has(mainZip)).toBe(true); + expect(targets.has(other)).toBe(false); + }); + + it("collects old-style rar split parts (.r00, .r01) for cleanup", () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-extract-")); + tempDirs.push(root); + const packageDir = path.join(root, "pkg"); + fs.mkdirSync(packageDir, { recursive: true }); + + const mainRar = path.join(packageDir, "show.rar"); + const r00 = path.join(packageDir, "show.r00"); + const r01 = path.join(packageDir, "show.r01"); + const r02 = path.join(packageDir, "show.r02"); + + fs.writeFileSync(mainRar, "a", "utf8"); + fs.writeFileSync(r00, "b", "utf8"); + fs.writeFileSync(r01, "c", "utf8"); + fs.writeFileSync(r02, "d", "utf8"); + + const targets = new Set(collectArchiveCleanupTargets(mainRar)); + expect(targets.has(mainRar)).toBe(true); + expect(targets.has(r00)).toBe(true); + expect(targets.has(r01)).toBe(true); + expect(targets.has(r02)).toBe(true); + }); + + it("preserves the original ZIP size guard error when no external extractor is available", () => { + const internalError = new Error("ZIP-Eintrag zu groß für sichere Speicher-Extraktion"); + const externalError = new ExtractionError("Kein nativer Entpacker gefunden", "no_extractor"); + + expect(selectZipFallbackError(internalError, externalError)).toBe(internalError); + }); + + it("preserves the original ZIP size guard error for an unsupported external archive format", () => { + const internalError = new Error("ZIP-Eintrag zu groß für sichere Speicher-Extraktion"); + const externalError = new ExtractionError("Is not archive", "unsupported_format"); + + expect(selectZipFallbackError(internalError, externalError)).toBe(internalError); + }); + + it("returns other external ZIP fallback errors unchanged", () => { + const internalError = new Error("ZIP-Eintrag zu groß für sichere Speicher-Extraktion"); + const externalError = new ExtractionError("CRC failed", "crc_error"); + + expect(selectZipFallbackError(internalError, externalError)).toBe(externalError); + }); + + it.skipIf(process.platform !== "win32")("matches resume-state archive names case-insensitively on Windows", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-extract-")); + tempDirs.push(root); + const packageDir = path.join(root, "pkg"); + const targetDir = path.join(root, "out"); + fs.mkdirSync(packageDir, { recursive: true }); + + const archivePath = path.join(packageDir, "episode.zip"); + fs.writeFileSync(archivePath, "not-a-zip", "utf8"); + fs.writeFileSync(path.join(packageDir, ".rd_extract_progress.json"), JSON.stringify({ completedArchives: ["EPISODE.ZIP"] }), "utf8"); + + const result = await extractPackageArchives({ + packageDir, + targetDir, + cleanupMode: "none", + conflictMode: "overwrite", + removeLinks: false, + removeSamples: false + }); + + expect(result.extracted).toBe(1); + expect(result.failed).toBe(0); + }); + + describe("disk space check", () => { + it("aborts extraction when disk space is insufficient", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-diskspace-")); + tempDirs.push(root); + const packageDir = path.join(root, "pkg"); + const targetDir = path.join(root, "out"); + fs.mkdirSync(packageDir, { recursive: true }); + fs.mkdirSync(targetDir, { recursive: true }); + + const zip = new AdmZip(); + zip.addFile("test.txt", Buffer.alloc(1024, 0x41)); + zip.writeZip(path.join(packageDir, "test.zip")); + + (fs.promises as any).statfs = async () => ({ bfree: 1, bsize: 1 }); + + await expect( + extractPackageArchives({ + packageDir, + targetDir, + cleanupMode: "none" as any, + conflictMode: "overwrite" as any, + removeLinks: false, + removeSamples: false, + }) + ).rejects.toThrow(/Nicht genug Speicherplatz/); + }); + + it("proceeds when disk space is sufficient", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-diskspace-ok-")); + tempDirs.push(root); + const packageDir = path.join(root, "pkg"); + const targetDir = path.join(root, "out"); + fs.mkdirSync(packageDir, { recursive: true }); + fs.mkdirSync(targetDir, { recursive: true }); + + const zip = new AdmZip(); + zip.addFile("test.txt", Buffer.alloc(1024, 0x41)); + zip.writeZip(path.join(packageDir, "test.zip")); + + const result = await extractPackageArchives({ + packageDir, + targetDir, + cleanupMode: "none" as any, + conflictMode: "overwrite" as any, + removeLinks: false, + removeSamples: false, + }); + expect(result.extracted).toBe(1); + expect(result.failed).toBe(0); + }); + }); + + describe("nested extraction", () => { + it("extracts archives found inside extracted output", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-nested-")); + tempDirs.push(root); + const packageDir = path.join(root, "pkg"); + const targetDir = path.join(root, "out"); + fs.mkdirSync(packageDir, { recursive: true }); + fs.mkdirSync(targetDir, { recursive: true }); + + const innerZip = new AdmZip(); + innerZip.addFile("deep.txt", Buffer.from("deep content")); + + const outerZip = new AdmZip(); + outerZip.addFile("inner.zip", innerZip.toBuffer()); + outerZip.writeZip(path.join(packageDir, "outer.zip")); + + const result = await extractPackageArchives({ + packageDir, + targetDir, + cleanupMode: "none" as any, + conflictMode: "overwrite" as any, + removeLinks: false, + removeSamples: false, + }); + + expect(result.extracted).toBe(2); + expect(result.failed).toBe(0); + expect(fs.existsSync(path.join(targetDir, "deep.txt"))).toBe(true); + }); + + it("does not extract blacklisted extensions like .iso", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-nested-bl-")); + tempDirs.push(root); + const packageDir = path.join(root, "pkg"); + const targetDir = path.join(root, "out"); + fs.mkdirSync(packageDir, { recursive: true }); + fs.mkdirSync(targetDir, { recursive: true }); + + const zip = new AdmZip(); + zip.addFile("disc.iso", Buffer.alloc(64, 0)); + zip.addFile("readme.txt", Buffer.from("hello")); + zip.writeZip(path.join(packageDir, "package.zip")); + + const result = await extractPackageArchives({ + packageDir, + targetDir, + cleanupMode: "none" as any, + conflictMode: "overwrite" as any, + removeLinks: false, + removeSamples: false, + }); + + expect(result.extracted).toBe(1); + expect(fs.existsSync(path.join(targetDir, "disc.iso"))).toBe(true); + expect(fs.existsSync(path.join(targetDir, "readme.txt"))).toBe(true); + }); + }); + + describe("archiveFilenamePasswords", () => { + it("extracts stem and spaced variant from archive name", () => { + const result = archiveFilenamePasswords("MyRelease.S01E01.rar"); + expect(result).toContain("MyRelease.S01E01"); + expect(result).toContain("MyRelease S01E01"); + }); + + it("strips multipart rar suffix", () => { + const result = archiveFilenamePasswords("Show.S02E03.part01.rar"); + expect(result).toContain("Show.S02E03"); + expect(result).toContain("Show S02E03"); + }); + + it("strips .zip.001 suffix", () => { + const result = archiveFilenamePasswords("Movie.2024.zip.001"); + expect(result).toContain("Movie.2024"); + }); + + it("strips .tar.gz suffix", () => { + const result = archiveFilenamePasswords("backup.tar.gz"); + expect(result).toContain("backup"); + }); + + it("returns empty array for empty input", () => { + expect(archiveFilenamePasswords("")).toEqual([]); + }); + + it("returns single entry when no dots/underscores", () => { + const result = archiveFilenamePasswords("simple.zip"); + expect(result).toEqual(["simple"]); + }); + + it("replaces underscores with spaces", () => { + const result = archiveFilenamePasswords("my_archive_name.7z"); + expect(result).toContain("my_archive_name"); + expect(result).toContain("my archive name"); + }); + }); + + describe(".rev cleanup", () => { + it("collects .rev files for single RAR cleanup", () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-rev-")); + tempDirs.push(root); + const packageDir = path.join(root, "pkg"); + fs.mkdirSync(packageDir, { recursive: true }); + + const mainRar = path.join(packageDir, "show.rar"); + const rev = path.join(packageDir, "show.rev"); + const r00 = path.join(packageDir, "show.r00"); + + fs.writeFileSync(mainRar, "a", "utf8"); + fs.writeFileSync(rev, "b", "utf8"); + fs.writeFileSync(r00, "c", "utf8"); + + const targets = new Set(collectArchiveCleanupTargets(mainRar)); + expect(targets.has(mainRar)).toBe(true); + expect(targets.has(rev)).toBe(true); + expect(targets.has(r00)).toBe(true); + }); + + it("collects .rev files for multipart RAR cleanup", () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-rev-mp-")); + tempDirs.push(root); + const packageDir = path.join(root, "pkg"); + fs.mkdirSync(packageDir, { recursive: true }); + + const part1 = path.join(packageDir, "show.part01.rar"); + const part2 = path.join(packageDir, "show.part02.rar"); + const rev = path.join(packageDir, "show.rev"); + + fs.writeFileSync(part1, "a", "utf8"); + fs.writeFileSync(part2, "b", "utf8"); + fs.writeFileSync(rev, "c", "utf8"); + + const targets = new Set(collectArchiveCleanupTargets(part1)); + expect(targets.has(part1)).toBe(true); + expect(targets.has(part2)).toBe(true); + expect(targets.has(rev)).toBe(true); + }); + }); + + describe("generic .001 split cleanup", () => { + it("collects all numbered parts for generic splits", () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-split-")); + tempDirs.push(root); + const packageDir = path.join(root, "pkg"); + fs.mkdirSync(packageDir, { recursive: true }); + + const p001 = path.join(packageDir, "movie.001"); + const p002 = path.join(packageDir, "movie.002"); + const p003 = path.join(packageDir, "movie.003"); + const other = path.join(packageDir, "other.001"); + + fs.writeFileSync(p001, "a", "utf8"); + fs.writeFileSync(p002, "b", "utf8"); + fs.writeFileSync(p003, "c", "utf8"); + fs.writeFileSync(other, "x", "utf8"); + + const targets = new Set(collectArchiveCleanupTargets(p001)); + expect(targets.has(p001)).toBe(true); + expect(targets.has(p002)).toBe(true); + expect(targets.has(p003)).toBe(true); + expect(targets.has(other)).toBe(false); + }); + + it("does NOT delete a non-archive .00x family that sits beside a real archive (no-signature data-loss guard)", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-split-noarch-")); + tempDirs.push(root); + const packageDir = path.join(root, "pkg"); + const targetDir = path.join(root, "out"); + fs.mkdirSync(packageDir, { recursive: true }); + + const realZip = new AdmZip(); + realZip.addFile("release.txt", Buffer.from("ok")); + realZip.writeZip(path.join(packageDir, "movie.zip")); + + const d001 = path.join(packageDir, "mydata.001"); + const d002 = path.join(packageDir, "mydata.002"); + const d003 = path.join(packageDir, "mydata.003"); + fs.writeFileSync(d001, "raw user split data, not an archive at all 0123456789", "utf8"); + fs.writeFileSync(d002, "second raw chunk, also no archive magic bytes here", "utf8"); + fs.writeFileSync(d003, "third raw chunk likewise plain content payload", "utf8"); + + const result = await extractPackageArchives({ + packageDir, + targetDir, + cleanupMode: "delete", + conflictMode: "overwrite", + removeLinks: false, + removeSamples: false + }); + + expect(result.failed).toBe(0); + expect(fs.existsSync(path.join(targetDir, "release.txt"))).toBe(true); + expect(fs.existsSync(d001)).toBe(true); + expect(fs.existsSync(d002)).toBe(true); + expect(fs.existsSync(d003)).toBe(true); + }); + }); + + describe("detectArchiveSignature", () => { + it("detects RAR signature", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-sig-")); + tempDirs.push(root); + const filePath = path.join(root, "test.rar"); + fs.writeFileSync(filePath, Buffer.from("526172211a0700", "hex")); + const sig = await detectArchiveSignature(filePath); + expect(sig).toBe("rar"); + }); + + it("detects ZIP signature", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-sig-")); + tempDirs.push(root); + const filePath = path.join(root, "test.zip"); + fs.writeFileSync(filePath, Buffer.from("504b030414000000", "hex")); + const sig = await detectArchiveSignature(filePath); + expect(sig).toBe("zip"); + }); + + it("detects 7z signature", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-sig-")); + tempDirs.push(root); + const filePath = path.join(root, "test.7z"); + fs.writeFileSync(filePath, Buffer.from("377abcaf271c0004", "hex")); + const sig = await detectArchiveSignature(filePath); + expect(sig).toBe("7z"); + }); + + it("returns null for non-archive files", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-sig-")); + tempDirs.push(root); + const filePath = path.join(root, "test.txt"); + fs.writeFileSync(filePath, "Hello World", "utf8"); + const sig = await detectArchiveSignature(filePath); + expect(sig).toBeNull(); + }); + + it("returns null for non-existent file", async () => { + const sig = await detectArchiveSignature("/nonexistent/file.rar"); + expect(sig).toBeNull(); + }); + }); + + describe("findArchiveCandidates extended formats", () => { + it("finds .tar.gz files", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-tar-")); + tempDirs.push(root); + const packageDir = path.join(root, "pkg"); + fs.mkdirSync(packageDir, { recursive: true }); + + fs.writeFileSync(path.join(packageDir, "backup.tar.gz"), "data", "utf8"); + fs.writeFileSync(path.join(packageDir, "readme.txt"), "info", "utf8"); + + const candidates = await findArchiveCandidates(packageDir); + expect(candidates.map((c) => path.basename(c))).toContain("backup.tar.gz"); + }); + + it("finds .tar.bz2 files", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-tar-")); + tempDirs.push(root); + const packageDir = path.join(root, "pkg"); + fs.mkdirSync(packageDir, { recursive: true }); + + fs.writeFileSync(path.join(packageDir, "archive.tar.bz2"), "data", "utf8"); + + const candidates = await findArchiveCandidates(packageDir); + expect(candidates.map((c) => path.basename(c))).toContain("archive.tar.bz2"); + }); + + it("finds generic .001 split files", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-split-")); + tempDirs.push(root); + const packageDir = path.join(root, "pkg"); + fs.mkdirSync(packageDir, { recursive: true }); + + fs.writeFileSync(path.join(packageDir, "movie.001"), "data", "utf8"); + fs.writeFileSync(path.join(packageDir, "movie.002"), "data", "utf8"); + + const candidates = await findArchiveCandidates(packageDir); + const names = candidates.map((c) => path.basename(c)); + expect(names).toContain("movie.001"); + expect(names).not.toContain("movie.002"); + }); + + it("does not duplicate .zip.001 as generic split", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dedup-")); + tempDirs.push(root); + const packageDir = path.join(root, "pkg"); + fs.mkdirSync(packageDir, { recursive: true }); + + fs.writeFileSync(path.join(packageDir, "movie.zip.001"), "data", "utf8"); + fs.writeFileSync(path.join(packageDir, "movie.zip.002"), "data", "utf8"); + + const candidates = await findArchiveCandidates(packageDir); + const names = candidates.map((c) => path.basename(c)); + expect(names.filter((n) => n === "movie.zip.001")).toHaveLength(1); + }); + + it("ignores duplicate-suffixed multipart rar volumes as standalone candidates", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-rar-dup-")); + tempDirs.push(root); + const packageDir = path.join(root, "pkg"); + fs.mkdirSync(packageDir, { recursive: true }); + + fs.writeFileSync(path.join(packageDir, "Sanctuary720-01x07.part1.rar"), "data", "utf8"); + fs.writeFileSync(path.join(packageDir, "Sanctuary720-01x07.part2.rar"), "data", "utf8"); + fs.writeFileSync(path.join(packageDir, "Sanctuary720-01x07.part1 (1).rar"), "data", "utf8"); + fs.writeFileSync(path.join(packageDir, "Sanctuary720-01x07.part2 (1).rar"), "data", "utf8"); + fs.writeFileSync(path.join(packageDir, "Sanctuary720-01x07.part5 (1).rar"), "data", "utf8"); + + const candidates = await findArchiveCandidates(packageDir); + const names = candidates.map((c) => path.basename(c)); + + expect(names).toContain("Sanctuary720-01x07.part1.rar"); + expect(names).not.toContain("Sanctuary720-01x07.part1 (1).rar"); + expect(names).not.toContain("Sanctuary720-01x07.part2 (1).rar"); + expect(names).not.toContain("Sanctuary720-01x07.part5 (1).rar"); + }); + + it("keeps single rar files with duplicate suffix as valid candidates", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-single-rar-dup-")); + tempDirs.push(root); + const packageDir = path.join(root, "pkg"); + fs.mkdirSync(packageDir, { recursive: true }); + + fs.writeFileSync(path.join(packageDir, "Movie (1).rar"), "data", "utf8"); + + const candidates = await findArchiveCandidates(packageDir); + expect(candidates.map((c) => path.basename(c))).toContain("Movie (1).rar"); + }); + }); + + describe("classifyExtractionError", () => { + it("classifies CRC errors", () => { + expect(classifyExtractionError("CRC failed for file.txt")).toBe("crc_error"); + expect(classifyExtractionError("Checksum error in data")).toBe("crc_error"); + }); + + it("classifies wrong password", () => { + expect(classifyExtractionError("Wrong password")).toBe("wrong_password"); + expect(classifyExtractionError("Falsches Passwort")).toBe("wrong_password"); + }); + + it("classifies missing parts", () => { + expect(classifyExtractionError("Missing volume: part2.rar")).toBe("missing_parts"); + expect(classifyExtractionError("Unexpected end of archive")).toBe("missing_parts"); + }); + + it("classifies unsupported format", () => { + expect(classifyExtractionError("kein RAR-Archiv")).toBe("unsupported_format"); + expect(classifyExtractionError("UNSUPPORTEDMETHOD")).toBe("unsupported_format"); + }); + + it("classifies native 7-Zip Cannot open the file as archive errors as unsupported format", () => { + expect(classifyExtractionError( + new Error("Open ERROR: Cannot open the file as [zip] archive ERRORS: Is not archive") + )).toBe("unsupported_format"); + }); + + it("classifies disk full", () => { + expect(classifyExtractionError("Nicht genug Speicherplatz")).toBe("disk_full"); + expect(classifyExtractionError("No space left on device")).toBe("disk_full"); + }); + + it("classifies timeout", () => { + expect(classifyExtractionError("Entpacken Timeout nach 360s")).toBe("timeout"); + }); + + it("classifies abort", () => { + expect(classifyExtractionError("aborted:extract")).toBe("aborted"); + }); + + it("classifies no extractor", () => { + expect(classifyExtractionError("WinRAR/UnRAR nicht gefunden")).toBe("no_extractor"); + }); + + it("prioritizes checksum errors over embedded wrong-password wording", () => { + expect(classifyExtractionError("Checksum error in the encrypted file. Corrupt file or wrong password.")).toBe("crc_error"); + }); + + it("returns unknown for unrecognized errors", () => { + expect(classifyExtractionError("something weird happened")).toBe("unknown"); + }); + + it("keeps important tail markers when long extractor output is trimmed", () => { + const noisy = `Extracting from archive.rar ${"x".repeat(700)} Unexpected end of archive`; + const cleaned = cleanErrorText(noisy); + expect(cleaned).toContain("Unexpected end of archive"); + expect(classifyExtractionError(cleaned)).toBe("missing_parts"); + }); + }); + + describe("shouldSerialRetryParallelFailures", () => { + it("keeps serial recovery enabled after mixed parallel results", () => { + expect(shouldSerialRetryParallelFailures(1, ["wrong_password"])).toBe(true); + expect(shouldSerialRetryParallelFailures(2, ["missing_parts"])).toBe(true); + }); + + it("only retries a total parallel wipe-out for contention-like failures", () => { + expect(shouldSerialRetryParallelFailures(0, ["crc_error", "wrong_password", "unknown"])).toBe(true); + expect(shouldSerialRetryParallelFailures(0, ["missing_parts"])).toBe(false); + expect(shouldSerialRetryParallelFailures(0, ["unsupported_format", "crc_error"])).toBe(false); + }); + }); + + describe("password discovery", () => { + it("reports per-archive failures through onArchiveFailure", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-extract-failure-")); + tempDirs.push(root); + const packageDir = path.join(root, "pkg"); + const targetDir = path.join(root, "out"); + fs.mkdirSync(packageDir, { recursive: true }); + + fs.writeFileSync(path.join(packageDir, "broken.zip"), "not-a-zip", "utf8"); + const failures: ExtractArchiveFailureInfo[] = []; + + const result = await extractPackageArchives({ + packageDir, + targetDir, + cleanupMode: "none", + conflictMode: "overwrite", + removeLinks: false, + removeSamples: false, + onArchiveFailure: (failure) => { + failures.push(failure); + } + }); + + expect(result.extracted).toBe(0); + expect(result.failed).toBe(1); + expect(failures).toHaveLength(1); + expect(failures[0]?.archiveName).toBe("broken.zip"); + expect(failures[0]?.category).toBe("unsupported_format"); + expect(failures[0]?.suggestRedownload).toBe(false); + }); + + it("extracts first archive serially before parallel pool when multiple passwords", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-pwdisc-")); + tempDirs.push(root); + const packageDir = path.join(root, "pkg"); + const targetDir = path.join(root, "out"); + fs.mkdirSync(packageDir, { recursive: true }); + + for (const name of ["ep01.zip", "ep02.zip", "ep03.zip"]) { + const zip = new AdmZip(); + zip.addFile(`${name}.txt`, Buffer.from(name)); + zip.writeZip(path.join(packageDir, name)); + } + + const seenOrder: string[] = []; + const result = await extractPackageArchives({ + packageDir, + targetDir, + cleanupMode: "none", + conflictMode: "overwrite", + removeLinks: false, + removeSamples: false, + maxParallel: 2, + passwordList: "pw1|pw2|pw3", + onProgress: (update) => { + if (update.phase !== "extracting" || !update.archiveName) return; + if (seenOrder[seenOrder.length - 1] !== update.archiveName) { + seenOrder.push(update.archiveName); + } + } + }); + + expect(result.extracted).toBe(3); + expect(result.failed).toBe(0); + expect(seenOrder[0]).toBe("ep01.zip"); + }); + + it("skips discovery when only one password candidate", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-pwdisc-skip-")); + tempDirs.push(root); + const packageDir = path.join(root, "pkg"); + const targetDir = path.join(root, "out"); + fs.mkdirSync(packageDir, { recursive: true }); + + for (const name of ["a.zip", "b.zip"]) { + const zip = new AdmZip(); + zip.addFile(`${name}.txt`, Buffer.from(name)); + zip.writeZip(path.join(packageDir, name)); + } + + const result = await extractPackageArchives({ + packageDir, + targetDir, + cleanupMode: "none", + conflictMode: "overwrite", + removeLinks: false, + removeSamples: false, + maxParallel: 4 + }); + + expect(result.extracted).toBe(2); + expect(result.failed).toBe(0); + }); + + it("skips discovery when only one archive", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-pwdisc-one-")); + tempDirs.push(root); + const packageDir = path.join(root, "pkg"); + const targetDir = path.join(root, "out"); + fs.mkdirSync(packageDir, { recursive: true }); + + const zip = new AdmZip(); + zip.addFile("single.txt", Buffer.from("single")); + zip.writeZip(path.join(packageDir, "only.zip")); + + const result = await extractPackageArchives({ + packageDir, + targetDir, + cleanupMode: "none", + conflictMode: "overwrite", + removeLinks: false, + removeSamples: false, + maxParallel: 4, + passwordList: "pw1|pw2|pw3" + }); + + expect(result.extracted).toBe(1); + expect(result.failed).toBe(0); + }); + }); + + describe("backend selection", () => { + it("defaults to auto in production when no backend override is set", () => { + expect(resolveExtractorBackendMode(undefined, false)).toBe("auto"); + }); + + it("defaults to legacy in vitest when no backend override is set", () => { + expect(resolveExtractorBackendMode(undefined, true)).toBe("legacy"); + }); + + it("respects explicit backend overrides", () => { + expect(resolveExtractorBackendMode("legacy", false)).toBe("legacy"); + expect(resolveExtractorBackendMode("jvm", false)).toBe("jvm"); + expect(resolveExtractorBackendMode("auto", false)).toBe("auto"); + }); + + it("prefers legacy for rar archives in auto mode on Windows", () => { + expect(resolveExtractorBackendModeForArchive("C:\\Downloads\\episode.part01.rar", undefined, false, "win32")).toBe("legacy"); + expect(resolveExtractorBackendModeForArchive("C:\\Downloads\\episode.r00", undefined, false, "win32")).toBe("legacy"); + }); + + it("falls back from legacy rar to jvm after partial-progress failure in auto mode on Windows", () => { + expect( + shouldFallbackLegacyRarToJvm( + "C:\\Downloads\\episode.part01.rar", + "auto", + "legacy", + "Error: Extracting from C:\\Downloads\\episode.part01.rar", + 38, + "win32" + ) + ).toBe(true); + }); + + it("skips legacy rar to jvm fallback for explicit legacy mode and non-rar cases", () => { + expect(shouldFallbackLegacyRarToJvm("C:\\Downloads\\episode.part01.rar", "legacy", "legacy", "checksum error", 38, "win32")).toBe(false); + expect(shouldFallbackLegacyRarToJvm("C:\\Downloads\\episode.zip", "auto", "legacy", "unknown failure", 38, "win32")).toBe(false); + expect(shouldFallbackLegacyRarToJvm("C:\\Downloads\\episode.part01.rar", "auto", "legacy", "timeout", 38, "win32")).toBe(false); + }); + + it("keeps auto for non-rar archives and respects explicit overrides", () => { + expect(resolveExtractorBackendModeForArchive("C:\\Downloads\\episode.zip", undefined, false, "win32")).toBe("auto"); + expect(resolveExtractorBackendModeForArchive("C:\\Downloads\\episode.part01.rar", "jvm", false, "win32")).toBe("jvm"); + expect(resolveExtractorBackendModeForArchive("C:\\Downloads\\episode.part01.rar", "legacy", false, "win32")).toBe("legacy"); + }); + }); + + describe("orderExtractorCandidatesForArchive", () => { + it("prefers RAR-native CLIs over 7-Zip for rar archives", () => { + const ordered = orderExtractorCandidatesForArchive( + ["7z.exe", "Rar.exe", "UnRAR.exe", "WinRAR.exe"], + "C:\\Downloads\\archive.part01.rar" + ); + expect(ordered.slice(0, 3)).toEqual(["Rar.exe", "UnRAR.exe", "WinRAR.exe"]); + expect(ordered[3]).toBe("7z.exe"); + }); + + it("keeps 7-Zip first for non-rar archives", () => { + const ordered = orderExtractorCandidatesForArchive( + ["UnRAR.exe", "7z.exe", "WinRAR.exe"], + "C:\\Downloads\\archive.zip" + ); + expect(ordered[0]).toBe("7z.exe"); + }); + + it("prefers the remembered command within the matching archive class", () => { + const ordered = orderExtractorCandidatesForArchive( + ["UnRAR.exe", "WinRAR.exe", "7z.exe"], + "C:\\Downloads\\archive.part01.rar", + "WinRAR.exe" + ); + expect(ordered[0]).toBe("WinRAR.exe"); + expect(ordered[1]).toBe("UnRAR.exe"); + }); + }); +}); diff --git a/tests/fs-error.test.ts b/tests/fs-error.test.ts new file mode 100644 index 0000000..f575378 --- /dev/null +++ b/tests/fs-error.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it } from "vitest"; +import { classifyDiskError } from "../src/main/fs-error"; +import { isDebugFlagEnabled } from "../src/main/logger"; + +describe("classifyDiskError", () => { + it("maps ENOSPC from an error code to a disk-full reason", () => { + const err = Object.assign(new Error("write ENOSPC"), { code: "ENOSPC" }); + expect(classifyDiskError(err)).toMatch(/Festplatte voll/); + }); + + it("maps EACCES from a code to a permission reason", () => { + const err = Object.assign(new Error("nope"), { code: "EACCES" }); + expect(classifyDiskError(err)).toMatch(/Zugriff verweigert/); + }); + + it("lower-case codes are normalized", () => { + const err = Object.assign(new Error("x"), { code: "enospc" }); + expect(classifyDiskError(err)).toMatch(/ENOSPC/); + }); + + it("falls back to scanning the message text when no code is present", () => { + expect(classifyDiskError(new Error("operation failed: ENOSPC on volume"))).toMatch(/Festplatte voll/); + }); + + it("handles a plain string error", () => { + expect(classifyDiskError("EROFS: read-only file system")).toMatch(/schreibgeschützt/); + }); + + it("returns null for an unrelated error", () => { + expect(classifyDiskError(new Error("write_drain_timeout"))).toBeNull(); + expect(classifyDiskError(new Error("premature close"))).toBeNull(); + expect(classifyDiskError(null)).toBeNull(); + expect(classifyDiskError(undefined)).toBeNull(); + }); +}); + +describe("isDebugFlagEnabled", () => { + it("is true for affirmative values", () => { + for (const v of ["1", "true", "TRUE", "yes", "on", " on "]) { + expect(isDebugFlagEnabled(v)).toBe(true); + } + }); + + it("is false for empty/negative/garbage values", () => { + for (const v of [undefined, "", "0", "false", "off", "no", "maybe"]) { + expect(isDebugFlagEnabled(v)).toBe(false); + } + }); +}); diff --git a/tests/german-audio-integration.test.ts b/tests/german-audio-integration.test.ts new file mode 100644 index 0000000..ce23d88 --- /dev/null +++ b/tests/german-audio-integration.test.ts @@ -0,0 +1,165 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +// Mock only processVideoFile (the ffmpeg boundary); keep the real pure helpers +// (stripDualLangMarker / hasDualLangMarker / isRemuxableVideoFile) so the +// download-manager's selection + .DL.-rename wiring is exercised for real. +vi.mock("../src/main/video-processor", async (importActual) => { + const actual = await importActual(); + return { ...actual, processVideoFile: vi.fn(), resolveVideoTooling: vi.fn() }; +}); + +import { DownloadManager } from "../src/main/download-manager"; +import { defaultSettings } from "../src/main/constants"; +import { createStoragePaths, emptySession } from "../src/main/storage"; +import { shutdownItemLogs } from "../src/main/item-log"; +import { shutdownPackageLogs } from "../src/main/package-log"; +import { shutdownRenameLog } from "../src/main/rename-log"; +import { processVideoFile, resolveVideoTooling, type VideoProcessResult } from "../src/main/video-processor"; + +const mockedProcess = processVideoFile as unknown as ReturnType; +const mockedTooling = resolveVideoTooling as unknown as ReturnType; +const tempDirs: string[] = []; + +afterEach(() => { + mockedProcess.mockReset(); + mockedTooling.mockReset(); + shutdownItemLogs(); + shutdownPackageLogs(); + shutdownRenameLog(); + for (const dir of tempDirs.splice(0)) { + try { fs.rmSync(dir, { recursive: true, force: true }); } catch { /* ignore */ } + } +}); + +function setup(keepGermanAudioOnly: boolean): { extractDir: string; manager: DownloadManager; pkg: any } { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-ga-")); + tempDirs.push(root); + const extractDir = path.join(root, "extract"); + const stateDir = path.join(root, "state"); + fs.mkdirSync(extractDir, { recursive: true }); + fs.mkdirSync(stateDir, { recursive: true }); + const manager = new DownloadManager( + { + ...defaultSettings(), + token: "rd-token", + keepGermanAudioOnly, + germanAudioMode: "tag", + autoRename4sf4sj: false, + outputDir: path.join(root, "out"), + extractDir, + mkvLibraryDir: path.join(stateDir, "_mkv") + }, + emptySession(), + createStoragePaths(stateDir) + ); + const pkg: any = { + id: "ga-pkg-1", + name: "Test.Show.S01.GERMAN.DL.720p", + outputDir: path.join(root, "out", "Test.Show"), + extractDir, + status: "completed", + itemIds: [], + cancelled: false, + enabled: true, + priority: "normal", + createdAt: 0, + updatedAt: 0 + }; + // Default: ffmpeg/ffprobe "available" so the step proceeds to the (mocked) + // processVideoFile. Tests that need the no-tool path override this. + mockedTooling.mockResolvedValue({ ffmpeg: "ffmpeg", ffprobe: "ffprobe" }); + return { extractDir, manager, pkg }; +} + +const DL_MKV = "Show.S01E01.German.DL.720p.x264.mkv"; +const PLAIN_MKV = "Show.S01E02.German.1080p.x264.mkv"; +const SAMPLE_DL = "Show.sample.DL.mkv"; +const DL_AVI = "Show.S01E03.German.DL.avi"; + +function stage(extractDir: string): void { + for (const f of [DL_MKV, PLAIN_MKV, SAMPLE_DL, DL_AVI]) { + fs.writeFileSync(path.join(extractDir, f), "x"); + } +} + +describe("keepGermanAudioOnly integration", () => { + it("processes only .DL. mkv/mp4 and strips .DL. after a successful remux", async () => { + const { extractDir, manager, pkg } = setup(true); + stage(extractDir); + mockedProcess.mockResolvedValue({ action: "remuxed", reason: "german-tag", totalAudioTracks: 2, keptTrackIndex: 0 } as VideoProcessResult); + + const n = await (manager as any).keepGermanAudioOnlyImpl(extractDir, pkg); + + expect(mockedProcess).toHaveBeenCalledTimes(1); + expect(mockedProcess.mock.calls[0][0]).toBe(path.join(extractDir, DL_MKV)); + expect(n).toBe(1); + + const files = fs.readdirSync(extractDir); + expect(files).toContain("Show.S01E01.German.720p.x264.mkv"); // .DL. stripped + expect(files).not.toContain(DL_MKV); + expect(files).toContain(PLAIN_MKV); // non-.DL. untouched + expect(files).toContain(SAMPLE_DL); // sample skipped + expect(files).toContain(DL_AVI); // avi not remuxable, skipped + }); + + it("does nothing when the setting is off", async () => { + const { extractDir, manager, pkg } = setup(false); + stage(extractDir); + const n = await (manager as any).keepGermanAudioOnlyImpl(extractDir, pkg); + expect(n).toBe(0); + expect(mockedProcess).not.toHaveBeenCalled(); + expect(fs.readdirSync(extractDir)).toContain(DL_MKV); // untouched + }); + + it("leaves the file fully untouched (name included) when no German track is found", async () => { + const { extractDir, manager, pkg } = setup(true); + stage(extractDir); + mockedProcess.mockResolvedValue({ action: "skipped-no-german", reason: "no-german-track", totalAudioTracks: 2 } as VideoProcessResult); + + await (manager as any).keepGermanAudioOnlyImpl(extractDir, pkg); + + expect(mockedProcess).toHaveBeenCalledTimes(1); + expect(fs.readdirSync(extractDir)).toContain(DL_MKV); // NOT renamed -> stays visible as unprocessed + }); + + it("still strips .DL. for a single-audio file (no remux needed)", async () => { + const { extractDir, manager, pkg } = setup(true); + stage(extractDir); + mockedProcess.mockResolvedValue({ action: "kept-single", reason: "single-german", totalAudioTracks: 1, keptTrackIndex: 0 } as VideoProcessResult); + + const n = await (manager as any).keepGermanAudioOnlyImpl(extractDir, pkg); + + expect(n).toBe(0); // not counted as a remux + expect(fs.readdirSync(extractDir)).toContain("Show.S01E01.German.720p.x264.mkv"); + }); + + it("skips up front (no processVideoFile calls) and leaves files untouched when ffmpeg is missing", async () => { + const { extractDir, manager, pkg } = setup(true); + stage(extractDir); + mockedTooling.mockResolvedValue(null); // ffmpeg/ffprobe not found + + const n = await (manager as any).keepGermanAudioOnlyImpl(extractDir, pkg); + + expect(n).toBe(0); + expect(mockedProcess).not.toHaveBeenCalled(); // bailed before touching any file + expect(fs.readdirSync(extractDir)).toContain(DL_MKV); // untouched + expect(pkg.audioStripSummary).toMatchObject({ candidates: 1, skippedNoTool: 1, remuxed: 0 }); + expect(pkg.audioStripSummary.files[0]).toMatchObject({ name: DL_MKV, action: "skipped-no-tool" }); + }); + + it("stores a per-package summary with counts and file details", async () => { + const { extractDir, manager, pkg } = setup(true); + stage(extractDir); + mockedProcess.mockResolvedValue({ action: "skipped-no-german", reason: "no-german-track", totalAudioTracks: 2, audioLanguages: ["eng", "fre"] } as VideoProcessResult); + + await (manager as any).keepGermanAudioOnlyImpl(extractDir, pkg); + + expect(pkg.audioStripSummary).toMatchObject({ candidates: 1, skippedNoGerman: 1, remuxed: 0, failed: 0 }); + expect(pkg.audioStripSummary.files).toHaveLength(1); + expect(pkg.audioStripSummary.files[0]).toMatchObject({ name: DL_MKV, action: "skipped-no-german", reason: "no-german-track", languages: "eng,fre" }); + expect(pkg.updatedAt).toBeGreaterThan(0); // bumped so the snapshot delta picks it up + }); +}); diff --git a/tests/hybrid-collect-race.test.ts b/tests/hybrid-collect-race.test.ts new file mode 100644 index 0000000..036532e --- /dev/null +++ b/tests/hybrid-collect-race.test.ts @@ -0,0 +1,107 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; + +import { DownloadManager } from "../src/main/download-manager"; +import { defaultSettings } from "../src/main/constants"; +import { createStoragePaths, emptySession } from "../src/main/storage"; +import { shutdownItemLogs } from "../src/main/item-log"; +import { shutdownPackageLogs } from "../src/main/package-log"; +import { shutdownRenameLog } from "../src/main/rename-log"; + +const tempDirs: string[] = []; + +afterEach(() => { + shutdownItemLogs(); + shutdownPackageLogs(); + shutdownRenameLog(); + for (const dir of tempDirs.splice(0)) { + try { fs.rmSync(dir, { recursive: true, force: true }); } catch { /* ignore */ } + } +}); + +const DL_MKV = "Show.S01E01.German.DL.720p.x264.mkv"; +const PLAIN_MKV = "Show.S01E02.German.720p.x264.mkv"; +const DL_AVI = "Show.S01E03.German.DL.avi"; + +function setup(keepGermanAudioOnly: boolean): { extractDir: string; libraryDir: string; manager: DownloadManager; pkg: any } { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-race-")); + tempDirs.push(root); + const extractDir = path.join(root, "extract"); + const stateDir = path.join(root, "state"); + const libraryDir = path.join(root, "library"); + fs.mkdirSync(extractDir, { recursive: true }); + fs.mkdirSync(stateDir, { recursive: true }); + const manager = new DownloadManager( + { + ...defaultSettings(), + token: "rd-token", + autoExtract: true, + collectMkvToLibrary: true, + keepGermanAudioOnly, + germanAudioMode: "tag", + autoRename4sf4sj: false, + outputDir: path.join(root, "out"), + extractDir, + mkvLibraryDir: libraryDir + }, + emptySession(), + createStoragePaths(stateDir) + ); + const pkg: any = { + id: "race-pkg-1", + name: "Show.S01.GERMAN.DL.720p", + outputDir: path.join(root, "out", "Show.S01"), + extractDir, + status: "completed", + itemIds: [], + cancelled: false, + enabled: true, + priority: "normal", + createdAt: 0, + updatedAt: 0 + }; + for (const f of [DL_MKV, PLAIN_MKV, DL_AVI]) { + fs.writeFileSync(path.join(extractDir, f), "x"); + } + return { extractDir, libraryDir, manager, pkg }; +} + +function libraryNames(libraryDir: string): string[] { + try { return fs.readdirSync(libraryDir); } catch { return []; } +} + +describe("Hybrid-Sammel Race-Schutz (.DL. noch nicht tonspur-bereinigt)", () => { + it("haelt eine remuxbare .DL.-Datei im Hybrid-Lauf zurueck (keepGermanAudioOnly an)", async () => { + const { extractDir, libraryDir, manager, pkg } = setup(true); + + await (manager as any).collectMkvFilesToLibrary(pkg.id, pkg, undefined, true); + + // Race-Opfer bleibt in extractDir, damit eine spaetere Runde / der Deferred-Pass es bereinigt + expect(fs.existsSync(path.join(extractDir, DL_MKV))).toBe(true); + expect(libraryNames(libraryDir)).not.toContain(DL_MKV); + + // Praezision: bereits bereinigte mkv (kein .DL.) wird gesammelt + expect(fs.existsSync(path.join(extractDir, PLAIN_MKV))).toBe(false); + // Praezision: .DL.avi ist nicht remuxbar -> wird NICHT zurueckgehalten, sondern gesammelt + expect(fs.existsSync(path.join(extractDir, DL_AVI))).toBe(false); + }); + + it("sammelt die remuxbare .DL.-Datei im Deferred-Lauf (deferFreshFiles=false)", async () => { + const { extractDir, libraryDir, manager, pkg } = setup(true); + + await (manager as any).collectMkvFilesToLibrary(pkg.id, pkg, undefined, false); + + expect(fs.existsSync(path.join(extractDir, DL_MKV))).toBe(false); + expect(libraryNames(libraryDir)).toContain(DL_MKV); + }); + + it("haelt nichts zurueck wenn keepGermanAudioOnly aus ist (.DL. ist dann normaler Output)", async () => { + const { extractDir, manager, pkg } = setup(false); + + await (manager as any).collectMkvFilesToLibrary(pkg.id, pkg, undefined, true); + + expect(fs.existsSync(path.join(extractDir, DL_MKV))).toBe(false); + }); +}); diff --git a/tests/integrity.test.ts b/tests/integrity.test.ts new file mode 100644 index 0000000..873bfac --- /dev/null +++ b/tests/integrity.test.ts @@ -0,0 +1,94 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { parseHashLine, readHashManifest, validateFileAgainstManifest } from "../src/main/integrity"; + +const tempDirs: string[] = []; + +afterEach(() => { + for (const dir of tempDirs.splice(0)) { + fs.rmSync(dir, { recursive: true, force: true }); + } +}); + +describe("integrity", () => { + it("parses md5 and sfv lines", () => { + const md = parseHashLine("d41d8cd98f00b204e9800998ecf8427e sample.bin"); + expect(md?.algorithm).toBe("md5"); + const sfv = parseHashLine("sample.bin 1A2B3C4D"); + expect(sfv?.algorithm).toBe("crc32"); + }); + + it("validates file against md5 manifest", async () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-int-")); + tempDirs.push(dir); + const filePath = path.join(dir, "movie.bin"); + fs.writeFileSync(filePath, Buffer.from("hello")); + fs.writeFileSync(path.join(dir, "hash.md5"), "5d41402abc4b2a76b9719d911017c592 movie.bin\n"); + const result = await validateFileAgainstManifest(filePath, dir); + expect(result.ok).toBe(true); + }); + + it("skips manifest files larger than 5MB", () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-int-")); + tempDirs.push(dir); + + const largeContent = "d41d8cd98f00b204e9800998ecf8427e sample.bin\n".repeat(200000); + const manifestPath = path.join(dir, "hashes.md5"); + fs.writeFileSync(manifestPath, largeContent, "utf8"); + + const stat = fs.statSync(manifestPath); + expect(stat.size).toBeGreaterThan(5 * 1024 * 1024); + + const manifest = readHashManifest(dir); + expect(manifest.size).toBe(0); + }); + + it("does not parse SHA256 (64-char hex) as valid hash", () => { + const sha256Line = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 emptyfile.bin"; + const result = parseHashLine(sha256Line); + expect(result).toBeNull(); + }); + + it("parses SHA1 hash lines correctly", () => { + const sha1Line = "da39a3ee5e6b4b0d3255bfef95601890afd80709 emptyfile.bin"; + const result = parseHashLine(sha1Line); + expect(result).not.toBeNull(); + expect(result?.algorithm).toBe("sha1"); + expect(result?.digest).toBe("da39a3ee5e6b4b0d3255bfef95601890afd80709"); + expect(result?.fileName).toBe("emptyfile.bin"); + }); + + it("ignores comment lines in hash manifests", () => { + expect(parseHashLine("; This is a comment")).toBeNull(); + expect(parseHashLine("")).toBeNull(); + expect(parseHashLine(" ")).toBeNull(); + }); + + it("trusts the per-line algorithm over the file extension for a mislabeled manifest (.sfv holding md5 lines)", async () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-int-")); + tempDirs.push(dir); + const filePath = path.join(dir, "movie.bin"); + fs.writeFileSync(filePath, Buffer.from("hello")); + fs.writeFileSync(path.join(dir, "checksums.sfv"), "5d41402abc4b2a76b9719d911017c592 movie.bin\n", "utf8"); + + const manifest = readHashManifest(dir); + expect(manifest.get("movie.bin")?.algorithm).toBe("md5"); + + const result = await validateFileAgainstManifest(filePath, dir); + expect(result.ok).toBe(true); + expect(result.message).toContain("MD5"); + }); + + it("keeps first hash entry when duplicate filename appears across manifests", () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-int-")); + tempDirs.push(dir); + + fs.writeFileSync(path.join(dir, "disc1.md5"), "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa movie.mkv\n", "utf8"); + fs.writeFileSync(path.join(dir, "disc2.md5"), "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb movie.mkv\n", "utf8"); + + const manifest = readHashManifest(dir); + expect(manifest.get("movie.mkv")?.digest).toBe("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"); + }); +}); diff --git a/tests/item-log.test.ts b/tests/item-log.test.ts new file mode 100644 index 0000000..a735390 --- /dev/null +++ b/tests/item-log.test.ts @@ -0,0 +1,85 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { ensureItemLog, getItemLogPath, initItemLogs, logItemEvent, shutdownItemLogs } from "../src/main/item-log"; + +const tempDirs: string[] = []; + +afterEach(() => { + shutdownItemLogs(); + for (const dir of tempDirs.splice(0)) { + fs.rmSync(dir, { recursive: true, force: true }); + } +}); + +describe("item-log", () => { + it("creates a persistent item log file", () => { + const baseDir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-ilog-")); + tempDirs.push(baseDir); + + initItemLogs(baseDir); + const logPath = ensureItemLog({ + itemId: "item-1", + packageId: "pkg-1", + packageName: "Test Paket", + fileName: "episode.part2.rar", + targetPath: "C:\\downloads\\Test Paket\\episode.part2.rar" + }); + + expect(logPath).not.toBeNull(); + expect(fs.existsSync(logPath!)).toBe(true); + + const content = fs.readFileSync(logPath!, "utf8"); + expect(content).toContain("Item-Log Start"); + expect(content).toContain("episode.part2.rar"); + }); + + it("writes detail events into the item log", async () => { + const baseDir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-ilog-")); + tempDirs.push(baseDir); + + initItemLogs(baseDir); + ensureItemLog({ + itemId: "item-2", + packageId: "pkg-2", + packageName: "Detail Paket", + fileName: "episode.part2.rar", + targetPath: "C:\\downloads\\Detail Paket\\episode.part2.rar" + }); + + logItemEvent("item-2", "ERROR", "Entpack-Fehler", { + archive: "episode.part2.rar", + code: "missing_parts", + detail: "Unexpected end of archive" + }); + + await new Promise((resolve) => setTimeout(resolve, 350)); + + const logPath = getItemLogPath("item-2"); + expect(logPath).not.toBeNull(); + const content = fs.readFileSync(logPath!, "utf8"); + expect(content).toContain("Entpack-Fehler"); + expect(content).toContain("archive=episode.part2.rar"); + expect(content).toContain("code=missing_parts"); + }); + + it("keeps traversal-like item ids inside the item log directory", () => { + const baseDir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-ilog-")); + tempDirs.push(baseDir); + + initItemLogs(baseDir); + const logPath = ensureItemLog({ + itemId: "..\\..\\outside", + packageId: "pkg-traversal", + packageName: "Traversal Paket", + fileName: "episode.part2.rar", + targetPath: "C:\\downloads\\Traversal Paket\\episode.part2.rar" + }); + + expect(logPath).not.toBeNull(); + const logsDir = path.resolve(path.join(baseDir, "item-logs")); + const resolvedLogPath = path.resolve(logPath!); + expect(resolvedLogPath === logsDir || resolvedLogPath.startsWith(`${logsDir}${path.sep}`)).toBe(true); + }); +}); diff --git a/tests/link-export.test.ts b/tests/link-export.test.ts new file mode 100644 index 0000000..7195178 --- /dev/null +++ b/tests/link-export.test.ts @@ -0,0 +1,153 @@ +import { describe, expect, it } from "vitest"; +import { buildLinkExportSelection, serializeLinkExportText } from "../src/main/link-export"; +import { parseCollectorInput } from "../src/main/link-parser"; +import type { UiSnapshot } from "../src/shared/types"; + +function buildSnapshot(): UiSnapshot { + return { + settings: {} as UiSnapshot["settings"], + session: { + version: 1, + packageOrder: ["pkg-1", "pkg-2"], + packages: { + "pkg-1": { + id: "pkg-1", + name: "Dave Staffel 1", + outputDir: "C:\\Downloads\\Dave Staffel 1", + extractDir: "C:\\Extract\\Dave Staffel 1", + status: "queued", + itemIds: ["item-1", "item-2"], + cancelled: false, + enabled: true, + priority: "normal", + createdAt: 1, + updatedAt: 1 + }, + "pkg-2": { + id: "pkg-2", + name: "Andere Staffel", + outputDir: "C:\\Downloads\\Andere Staffel", + extractDir: "C:\\Extract\\Andere Staffel", + status: "queued", + itemIds: ["item-3"], + cancelled: false, + enabled: true, + priority: "normal", + createdAt: 1, + updatedAt: 1 + } + }, + items: { + "item-1": { + id: "item-1", + packageId: "pkg-1", + url: "https://example.com/e01", + provider: null, + status: "queued", + retries: 0, + speedBps: 0, + downloadedBytes: 0, + totalBytes: null, + progressPercent: 0, + fileName: "Dave.S01E01.rar", + targetPath: "", + resumable: true, + attempts: 0, + lastError: "", + fullStatus: "Wartet", + createdAt: 1, + updatedAt: 1 + }, + "item-2": { + id: "item-2", + packageId: "pkg-1", + url: "https://example.com/e02", + provider: null, + status: "queued", + retries: 0, + speedBps: 0, + downloadedBytes: 0, + totalBytes: null, + progressPercent: 0, + fileName: "Dave.S01E02.rar", + targetPath: "", + resumable: true, + attempts: 0, + lastError: "", + fullStatus: "Wartet", + createdAt: 1, + updatedAt: 1 + }, + "item-3": { + id: "item-3", + packageId: "pkg-2", + url: "https://example.com/other", + provider: null, + status: "queued", + retries: 0, + speedBps: 0, + downloadedBytes: 0, + totalBytes: null, + progressPercent: 0, + fileName: "Andere.S01E01.rar", + targetPath: "", + resumable: true, + attempts: 0, + lastError: "", + fullStatus: "Wartet", + createdAt: 1, + updatedAt: 1 + } + }, + runStartedAt: 0, + totalDownloadedBytes: 0, + summaryText: "", + reconnectUntil: 0, + reconnectReason: "", + paused: false, + running: false, + updatedAt: 1 + }, + summary: null, + stats: { + totalDownloaded: 0, + totalDownloadedAllTime: 0, + totalFilesSession: 0, + totalFilesAllTime: 0, + totalPackages: 2, + sessionStartedAt: 0, + appSessionStartedAt: 0, + sessionRuntimeMs: 0, + totalRuntimeMs: 0, + runtimeMeasuredAt: 0 + }, + speedText: "", + etaText: "", + canStart: true, + canStop: false, + canPause: false, + clipboardActive: false, + reconnectSeconds: 0, + packageSpeedBps: {} + }; +} + +describe("link-export", () => { + it("keeps original package names when exporting selected items", () => { + const selection = buildLinkExportSelection(buildSnapshot(), [], ["item-1", "item-3"]); + expect(selection.packageCount).toBe(2); + expect(selection.linkCount).toBe(2); + expect(selection.packages.map((pkg) => pkg.name)).toEqual(["Dave Staffel 1", "Andere Staffel"]); + }); + + it("roundtrips exported text back into parsed package inputs", () => { + const selection = buildLinkExportSelection(buildSnapshot(), [], ["item-1", "item-2"]); + const text = serializeLinkExportText(selection.packages); + const reparsed = parseCollectorInput(text, ""); + + expect(reparsed).toHaveLength(1); + expect(reparsed[0]?.name).toBe("Dave Staffel 1"); + expect(reparsed[0]?.links).toEqual(["https://example.com/e01", "https://example.com/e02"]); + expect(reparsed[0]?.fileNames).toEqual(["Dave.S01E01.rar", "Dave.S01E02.rar"]); + }); +}); diff --git a/tests/link-parser.test.ts b/tests/link-parser.test.ts new file mode 100644 index 0000000..c229ecf --- /dev/null +++ b/tests/link-parser.test.ts @@ -0,0 +1,84 @@ +import { describe, expect, it } from "vitest"; +import { mergePackageInputs, parseCollectorInput } from "../src/main/link-parser"; + +describe("link-parser", () => { + describe("mergePackageInputs", () => { + it("merges packages with the same name and preserves order", () => { + const input = [ + { name: "Package A", links: ["http://link1", "http://link2"] }, + { name: "Package B", links: ["http://link3"] }, + { name: "Package A", links: ["http://link4", "http://link1"] }, + { name: "", links: ["http://link5"] } + ]; + + const result = mergePackageInputs(input); + + expect(result).toHaveLength(3); + + const pkgA = result.find(p => p.name === "Package A"); + expect(pkgA?.links).toEqual(["http://link1", "http://link2", "http://link4"]); + + const pkgB = result.find(p => p.name === "Package B"); + expect(pkgB?.links).toEqual(["http://link3"]); + }); + + it("sanitizes names during merge", () => { + const input = [ + { name: "Valid_Name", links: ["http://link1"] }, + { name: "Valid?Name*", links: ["http://link2"] } + ]; + + const result = mergePackageInputs(input); + + expect(result.map(p => p.name).sort()).toEqual(["Valid Name", "Valid_Name"]); + }); + + it("preserves file name hints when merging packages", () => { + const input = [ + { name: "Package A", links: ["http://link1", "http://link2"], fileNames: ["one.rar", "two.rar"] }, + { name: "Package A", links: ["http://link3", "http://link1"], fileNames: ["three.rar", "ignored.rar"] } + ]; + + const result = mergePackageInputs(input); + expect(result).toHaveLength(1); + expect(result[0]?.links).toEqual(["http://link1", "http://link2", "http://link3"]); + expect(result[0]?.fileNames).toEqual(["one.rar", "two.rar", "three.rar"]); + }); + }); + + describe("parseCollectorInput", () => { + it("returns empty array for empty or invalid input", () => { + expect(parseCollectorInput("")).toEqual([]); + expect(parseCollectorInput("just some text without links")).toEqual([]); + expect(parseCollectorInput("ftp://notsupported")).toEqual([]); + }); + + it("parses and merges links from raw text", () => { + const rawText = ` + Here are some links: + http://example.com/part1.rar + http://example.com/part2.rar + + # package: Custom_Name + http://other.com/file1 + http://other.com/file2 + `; + + const result = parseCollectorInput(rawText, "DefaultFallback"); + + expect(result).toHaveLength(2); + + const defaultPkg = result.find(p => p.name === "DefaultFallback"); + expect(defaultPkg?.links).toEqual([ + "http://example.com/part1.rar", + "http://example.com/part2.rar" + ]); + + const customPkg = result.find(p => p.name === "Custom_Name"); + expect(customPkg?.links).toEqual([ + "http://other.com/file1", + "http://other.com/file2" + ]); + }); + }); +}); diff --git a/tests/log-timestamp.test.ts b/tests/log-timestamp.test.ts new file mode 100644 index 0000000..54e9142 --- /dev/null +++ b/tests/log-timestamp.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from "vitest"; +import { logTimestamp } from "../src/main/log-timestamp"; + +describe("logTimestamp", () => { + it("formats local time with an explicit UTC offset (ISO 8601), not a UTC 'Z' string", () => { + const instant = new Date("2026-05-31T17:29:43.605Z"); + const formatted = logTimestamp(instant); + + expect(formatted).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}[+-]\d{2}:\d{2}$/); + expect(formatted.endsWith("Z")).toBe(false); + }); + + it("is parseable back to the exact same instant (offset keeps it unambiguous)", () => { + const instant = new Date("2026-05-31T17:29:43.605Z"); + expect(new Date(logTimestamp(instant)).getTime()).toBe(instant.getTime()); + }); + + it("shows the LOCAL wall-clock hour (machine-timezone-independent assertion)", () => { + const instant = new Date("2026-05-31T17:29:43.605Z"); + const formatted = logTimestamp(instant); + expect(formatted.slice(11, 13)).toBe(String(instant.getHours()).padStart(2, "0")); + }); +}); diff --git a/tests/mega-debrid-errors.test.ts b/tests/mega-debrid-errors.test.ts new file mode 100644 index 0000000..7dcae08 --- /dev/null +++ b/tests/mega-debrid-errors.test.ts @@ -0,0 +1,64 @@ +import { describe, expect, it } from "vitest"; +import { isMegaDebridResolveFailure, germanMegaDebridResolveReason, isMegaDebridTransientResolveFailure } from "../src/shared/mega-debrid-errors"; + +describe("isMegaDebridResolveFailure", () => { + it("detects the real Mega-Debrid French resolve-failure phrase", () => { + expect(isMegaDebridResolveFailure("Mega-Debrid API: Fichier supprimé chez l'hébergeur")).toBe(true); + }); + + it("matches inside the aggregated provider-chain error (api fail | web timeout)", () => { + const aggregated = "Unrestrict fehlgeschlagen: Mega-Debrid API: Mega-Debrid (API): Fichier supprimé chez l'hébergeur | Mega-Debrid Web: Mega-Debrid (Web): Abbruch/Timeout nach 60s"; + expect(isMegaDebridResolveFailure(aggregated)).toBe(true); + }); + + it("matches the de-accented variant", () => { + expect(isMegaDebridResolveFailure("Fichier supprime chez l'hebergeur")).toBe(true); + }); + + it("matches other Mega-Debrid resolve phrases", () => { + expect(isMegaDebridResolveFailure("Fichier introuvable")).toBe(true); + expect(isMegaDebridResolveFailure("Le fichier n'existe plus")).toBe(true); + }); + + it("does NOT match unrelated/transient text", () => { + expect(isMegaDebridResolveFailure("Abbruch/Timeout nach 60s")).toBe(false); + expect(isMegaDebridResolveFailure("Quota/Limit erreicht")).toBe(false); + }); +}); + +describe("germanMegaDebridResolveReason (transient wording, NOT 'tot')", () => { + it("renders 'supprimé' as a transient, retryable German reason", () => { + const reason = germanMegaDebridResolveReason("Mega-Debrid API: Fichier supprimé chez l'hébergeur"); + expect(reason).toBe("Datei beim Hoster gerade nicht abrufbar"); + expect(reason.toLowerCase()).not.toContain("tot"); + expect(reason.toLowerCase()).not.toContain("gelöscht"); + }); + + it("renders not-found phrases in German", () => { + expect(germanMegaDebridResolveReason("Fichier introuvable")).toBe("Datei beim Hoster nicht gefunden"); + }); +}); + +describe("isMegaDebridTransientResolveFailure (matches raw French AND rendered German)", () => { + it("matches the raw French phrase that may reach the download-manager", () => { + expect(isMegaDebridTransientResolveFailure("Mega-Debrid API: Fichier supprimé chez l'hébergeur")).toBe(true); + }); + + it("matches the German rendered reason that classifyAccountFailure produces", () => { + const aggregated = "Mega-Debrid (Account 1/4, ab***@x): Datei beim Hoster gerade nicht abrufbar | Mega-Debrid (Account 2/4, cd***@y): Datei beim Hoster gerade nicht abrufbar"; + expect(isMegaDebridTransientResolveFailure(aggregated)).toBe(true); + }); + + it("matches the German not-found rendered reason", () => { + expect(isMegaDebridTransientResolveFailure("Mega-Debrid (Account 1/4): Datei beim Hoster nicht gefunden")).toBe(true); + }); + + it("does NOT match a Mega-Debrid timeout/abort (that has its own account cooldown path)", () => { + expect(isMegaDebridTransientResolveFailure("Mega-Debrid (Account 1/4): Abbruch/Timeout nach 60s")).toBe(false); + }); + + it("does NOT match unrelated provider errors", () => { + expect(isMegaDebridTransientResolveFailure("AllDebrid: zu viele aktive Downloads")).toBe(false); + expect(isMegaDebridTransientResolveFailure("Debrid-Link: badToken")).toBe(false); + }); +}); diff --git a/tests/mega-public-api.test.ts b/tests/mega-public-api.test.ts new file mode 100644 index 0000000..4335676 --- /dev/null +++ b/tests/mega-public-api.test.ts @@ -0,0 +1,178 @@ +import crypto from "node:crypto"; +import { describe, expect, it, vi, beforeEach, afterEach } from "vitest"; +import { + decryptMegaAttributes, + isMegaFileUrl, + parseMegaUrl, + resolveMegaFilename +} from "../src/main/mega-public-api"; + +function base64Url(buf: Buffer): string { + return buf.toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); +} + +function makeRandomFileKey(): Buffer { + return crypto.randomBytes(32); +} + +function encryptAttributes(jsonAttrs: Record, aesKey: Buffer): string { + const plain = "MEGA" + JSON.stringify(jsonAttrs); + const padded = Buffer.from(plain, "utf8"); + const padLen = (16 - (padded.length % 16)) % 16; + const buf = Buffer.concat([padded, Buffer.alloc(padLen, 0)]); + const cipher = crypto.createCipheriv("aes-128-cbc", aesKey, Buffer.alloc(16)); + cipher.setAutoPadding(false); + const enc = Buffer.concat([cipher.update(buf), cipher.final()]); + return base64Url(enc); +} + +describe("mega-public-api", () => { + describe("isMegaFileUrl", () => { + it("recognizes new format", () => { + expect(isMegaFileUrl("https://mega.nz/file/pZl1wBRQ#BFx-HachDy4o9EgKy90IiLMsw3idHFGaDoJhajK5zzo")).toBe(true); + }); + it("recognizes legacy format", () => { + expect(isMegaFileUrl("https://mega.nz/#!abc123!def456")).toBe(true); + }); + it("recognizes mega.co.nz", () => { + expect(isMegaFileUrl("https://mega.co.nz/file/abc#xyz")).toBe(true); + }); + it("rejects folder URLs", () => { + expect(isMegaFileUrl("https://mega.nz/folder/abc#xyz")).toBe(false); + }); + it("rejects non-mega URLs", () => { + expect(isMegaFileUrl("https://example.com/file/abc#xyz")).toBe(false); + }); + it("rejects garbage", () => { + expect(isMegaFileUrl("")).toBe(false); + expect(isMegaFileUrl("foo")).toBe(false); + }); + }); + + describe("parseMegaUrl", () => { + it("parses new-format URL into id + 32-byte key", () => { + const url = "https://mega.nz/file/pZl1wBRQ#BFx-HachDy4o9EgKy90IiLMsw3idHFGaDoJhajK5zzo"; + const parsed = parseMegaUrl(url); + expect(parsed).not.toBeNull(); + expect(parsed?.id).toBe("pZl1wBRQ"); + expect(parsed?.rawKey.length).toBe(32); + }); + it("parses legacy-format URL", () => { + const id = "abcDEF12"; + const key = makeRandomFileKey(); + const url = `https://mega.nz/#!${id}!${base64Url(key)}`; + const parsed = parseMegaUrl(url); + expect(parsed?.id).toBe(id); + expect(parsed?.rawKey.equals(key)).toBe(true); + }); + it("rejects URL with folder key (16 bytes)", () => { + const url = `https://mega.nz/file/abc#${base64Url(crypto.randomBytes(16))}`; + expect(parseMegaUrl(url)).toBeNull(); + }); + it("rejects malformed URLs", () => { + expect(parseMegaUrl("not-a-url")).toBeNull(); + expect(parseMegaUrl("https://mega.nz/file/abc")).toBeNull(); + }); + }); + + describe("decryptMegaAttributes", () => { + it("round-trips encrypted Mega attributes", () => { + const aesKey = crypto.randomBytes(16); + const original = { n: "Test.S01E01.German.1080p.WEB.x264-DEMO.mkv", c: "ignored" }; + const enc = encryptAttributes(original, aesKey); + const decoded = Buffer.from(enc + "=".repeat((4 - (enc.length % 4)) % 4), "base64"); + const decrypted = decryptMegaAttributes(decoded, aesKey); + expect(decrypted).not.toBeNull(); + expect(decrypted?.n).toBe(original.n); + }); + it("returns null for wrong key", () => { + const aesKey = crypto.randomBytes(16); + const wrongKey = crypto.randomBytes(16); + const enc = encryptAttributes({ n: "x" }, aesKey); + const decoded = Buffer.from(enc + "=".repeat((4 - (enc.length % 4)) % 4), "base64"); + expect(decryptMegaAttributes(decoded, wrongKey)).toBeNull(); + }); + it("returns null for non-multiple-of-16 input", () => { + const aesKey = crypto.randomBytes(16); + expect(decryptMegaAttributes(Buffer.alloc(15), aesKey)).toBeNull(); + }); + it("returns null for wrong key length", () => { + expect(decryptMegaAttributes(Buffer.alloc(16), Buffer.alloc(8))).toBeNull(); + }); + }); + + describe("resolveMegaFilename (mocked fetch)", () => { + let originalFetch: typeof fetch; + + beforeEach(() => { + originalFetch = global.fetch; + }); + + afterEach(() => { + global.fetch = originalFetch; + vi.restoreAllMocks(); + }); + + it("returns filename + size for a valid Mega response", async () => { + const fileKey = makeRandomFileKey(); + const aesKey = fileKey.subarray(0, 16); + const url = `https://mega.nz/file/testId12#${base64Url(fileKey)}`; + const encrypted = encryptAttributes( + { n: "Direct.Show.S01E01.German.1080p.WEB.x264-DIRECT.mkv" }, + aesKey + ); + + global.fetch = vi.fn().mockResolvedValue({ + ok: true, + async json() { + return [{ s: 1234567890, at: encrypted, msd: 1 }]; + } + } as unknown as Response); + + const result = await resolveMegaFilename(url); + expect(result).not.toBeNull(); + expect(result?.name).toBe("Direct.Show.S01E01.German.1080p.WEB.x264-DIRECT.mkv"); + expect(result?.size).toBe(1234567890); + }); + + it("returns null when Mega returns numeric error", async () => { + const fileKey = makeRandomFileKey(); + const url = `https://mega.nz/file/blockedId#${base64Url(fileKey)}`; + global.fetch = vi.fn().mockResolvedValue({ + ok: true, + async json() { + return -9; + } + } as unknown as Response); + + expect(await resolveMegaFilename(url)).toBeNull(); + }); + + it("returns null when response is array with error code", async () => { + const fileKey = makeRandomFileKey(); + const url = `https://mega.nz/file/blockedId#${base64Url(fileKey)}`; + global.fetch = vi.fn().mockResolvedValue({ + ok: true, + async json() { + return [-16]; + } + } as unknown as Response); + + expect(await resolveMegaFilename(url)).toBeNull(); + }); + + it("returns null when fetch throws", async () => { + const fileKey = makeRandomFileKey(); + const url = `https://mega.nz/file/networkFail#${base64Url(fileKey)}`; + global.fetch = vi.fn().mockRejectedValue(new Error("network down")); + expect(await resolveMegaFilename(url)).toBeNull(); + }); + + it("returns null for non-mega URL without making any fetch call", async () => { + const fetchSpy = vi.fn(); + global.fetch = fetchSpy as unknown as typeof fetch; + expect(await resolveMegaFilename("https://example.com/file/abc#xyz")).toBeNull(); + expect(fetchSpy).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/tests/mega-web-fallback.test.ts b/tests/mega-web-fallback.test.ts new file mode 100644 index 0000000..7a852e4 --- /dev/null +++ b/tests/mega-web-fallback.test.ts @@ -0,0 +1,354 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { MegaWebFallback } from "../src/main/mega-web-fallback"; + +const originalFetch = globalThis.fetch; + +describe("mega-web-fallback", () => { + afterEach(() => { + globalThis.fetch = originalFetch; + vi.restoreAllMocks(); + }); + + describe("MegaWebFallback class", () => { + it("returns null when credentials are empty", async () => { + const fallback = new MegaWebFallback(() => ({ login: "", password: "" })); + const result = await fallback.unrestrict("https://mega.debrid/test"); + expect(result).toBeNull(); + }); + + it("logs in, fetches HTML, parses code, and polls AJAX for direct url", async () => { + let fetchCallCount = 0; + globalThis.fetch = vi.fn(async (url: string | URL | Request) => { + const urlStr = String(url); + fetchCallCount += 1; + + if (urlStr.includes("form=login")) { + const headers = new Headers(); + headers.append("set-cookie", "session=goodcookie; path=/"); + return new Response("", { headers, status: 200 }); + } + + if (urlStr.includes("page=debrideur")) { + return new Response('
', { status: 200 }); + } + + if (urlStr.includes("form=debrid")) { + return new Response(` +
+

Link: https://mega.debrid/link1

+ Download +
+ `, { status: 200 }); + } + + if (urlStr.includes("ajax=debrid")) { + return new Response(JSON.stringify({ link: "https://mega.direct/123" }), { status: 200 }); + } + + return new Response("Not found", { status: 404 }); + }) as unknown as typeof fetch; + + const fallback = new MegaWebFallback(() => ({ login: "user", password: "pwd" })); + + const result = await fallback.unrestrict("https://mega.debrid/link1"); + expect(result).not.toBeNull(); + expect(result?.directUrl).toBe("https://mega.direct/123"); + expect(result?.fileName).toBe("link1"); + expect(fetchCallCount).toBe(4); + }); + + it("fails fast on 'Kein Server für diesen Hoster' (account hoster quota) instead of re-login + re-poll", async () => { + let ajaxCalls = 0; + globalThis.fetch = vi.fn(async (url: string | URL | Request) => { + const urlStr = String(url); + if (urlStr.includes("form=login")) { + const headers = new Headers(); + headers.append("set-cookie", "session=goodcookie; path=/"); + return new Response("", { headers, status: 200 }); + } + if (urlStr.includes("page=debrideur")) { + return new Response('
', { status: 200 }); + } + if (urlStr.includes("form=debrid")) { + return new Response(`

Link: https://mega.debrid/l1

d
`, { status: 200 }); + } + if (urlStr.includes("ajax=debrid")) { + ajaxCalls += 1; + return new Response(JSON.stringify({ link: "", text: "Erreur : Kein Server für diesen Hoster verfügbar. Bitte versuchen Sie es später noch einmal." }), { status: 200 }); + } + return new Response("Not found", { status: 404 }); + }) as unknown as typeof fetch; + + const fallback = new MegaWebFallback(() => ({ login: "user", password: "pwd" })); + await expect(fallback.unrestrict("https://mega.debrid/l1")).rejects.toThrow(/kein server für diesen hoster/i); + expect(ajaxCalls).toBe(1); + }); + + it("surfaces 'Kein Server für diesen Hoster' from the debrid PAGE (daily limit, no debrid code) instead of empty", async () => { + let ajaxCalls = 0; + globalThis.fetch = vi.fn(async (url: string | URL | Request) => { + const urlStr = String(url); + if (urlStr.includes("form=login")) { + const headers = new Headers(); + headers.append("set-cookie", "session=goodcookie; path=/"); + return new Response("", { headers, status: 200 }); + } + if (urlStr.includes("page=debrideur")) { + return new Response('
', { status: 200 }); + } + if (urlStr.includes("form=debrid")) { + return new Response('
Erreur : Kein Server für diesen Hoster verfügbar. Bitte versuchen Sie es später noch einmal.
', { status: 200 }); + } + if (urlStr.includes("ajax=debrid")) { + ajaxCalls += 1; + return new Response(JSON.stringify({ link: "https://should.not/happen" }), { status: 200 }); + } + return new Response("Not found", { status: 404 }); + }) as unknown as typeof fetch; + + const fallback = new MegaWebFallback(() => ({ login: "user", password: "pwd" })); + await expect(fallback.unrestrict("https://mega.debrid/l1")).rejects.toThrow(/kein server für diesen hoster/i); + expect(ajaxCalls).toBe(0); + }); + + it("logs in with the per-account credentials passed to unrestrict, not the default", async () => { + const loginsUsed: string[] = []; + globalThis.fetch = vi.fn(async (url: string | URL | Request, opts?: { body?: unknown }) => { + const urlStr = String(url); + if (urlStr.includes("form=login")) { + const params = new URLSearchParams(String(opts?.body ?? "")); + loginsUsed.push(params.get("login") || ""); + const headers = new Headers(); + headers.append("set-cookie", "session=goodcookie; path=/"); + return new Response("", { headers, status: 200 }); + } + if (urlStr.includes("page=debrideur")) { + return new Response('
', { status: 200 }); + } + if (urlStr.includes("form=debrid")) { + return new Response(`

Link: https://mega.debrid/l1

d
`, { status: 200 }); + } + if (urlStr.includes("ajax=debrid")) { + return new Response(JSON.stringify({ link: "https://mega.direct/ok" }), { status: 200 }); + } + return new Response("Not found", { status: 404 }); + }) as unknown as typeof fetch; + + const fallback = new MegaWebFallback(() => ({ login: "defaultacc", password: "defpw" })); + const result = await fallback.unrestrict("https://mega.debrid/l1", undefined, { login: "account2", password: "pw2" }); + expect(result?.directUrl).toBe("https://mega.direct/ok"); + expect(loginsUsed).toContain("account2"); + expect(loginsUsed).not.toContain("defaultacc"); + }); + + it("throws if login fails to set cookie", async () => { + globalThis.fetch = vi.fn(async (url: string | URL | Request) => { + const urlStr = String(url); + if (urlStr.includes("form=login")) { + const headers = new Headers(); + return new Response("", { headers, status: 200 }); + } + return new Response("Not found", { status: 404 }); + }) as unknown as typeof fetch; + + const fallback = new MegaWebFallback(() => ({ login: "bad", password: "bad" })); + + await expect(fallback.unrestrict("http://mega.debrid/file")) + .rejects.toThrow("Mega-Web Login liefert kein Session-Cookie"); + }); + + it("throws if login verify check fails (no form found)", async () => { + globalThis.fetch = vi.fn(async (url: string | URL | Request) => { + const urlStr = String(url); + if (urlStr.includes("form=login")) { + const headers = new Headers(); + headers.append("set-cookie", "session=goodcookie; path=/"); + return new Response("", { headers, status: 200 }); + } + if (urlStr.includes("page=debrideur")) { + return new Response('Nothing here', { status: 200 }); + } + return new Response("Not found", { status: 404 }); + }) as unknown as typeof fetch; + + const fallback = new MegaWebFallback(() => ({ login: "a", password: "b" })); + + await expect(fallback.unrestrict("http://mega.debrid/file")) + .rejects.toThrow("Mega-Web Login ungültig oder Session blockiert"); + }); + + it("returns null if generation fails to find a code", async () => { + let callCount = 0; + globalThis.fetch = vi.fn(async (url: string | URL | Request) => { + const urlStr = String(url); + callCount++; + if (urlStr.includes("form=login")) { + const headers = new Headers(); + headers.append("set-cookie", "session=goodcookie; path=/"); + return new Response("", { headers, status: 200 }); + } + if (urlStr.includes("page=debrideur")) { + return new Response('
', { status: 200 }); + } + if (urlStr.includes("form=debrid")) { + return new Response(`
No links here
`, { status: 200 }); + } + return new Response("Not found", { status: 404 }); + }) as unknown as typeof fetch; + + const fallback = new MegaWebFallback(() => ({ login: "a", password: "b" })); + const result = await fallback.unrestrict("http://mega.debrid/file"); + + expect(result).toBeNull(); + }); + + it("serialisiert gleichzeitige Umwandlungen auf DEMSELBEN Account (kein Doppel-Login)", async () => { + let loginCount = 0; + globalThis.fetch = vi.fn(async (url: string | URL | Request) => { + const u = String(url); + if (u.includes("form=login")) { + loginCount += 1; + await new Promise((r) => setTimeout(r, 15)); + const headers = new Headers(); + headers.append("set-cookie", "session=c; path=/"); + return new Response("", { headers, status: 200 }); + } + if (u.includes("page=debrideur")) return new Response('
', { status: 200 }); + if (u.includes("form=debrid")) return new Response(`

Link: https://mega.debrid/l

d
`, { status: 200 }); + if (u.includes("ajax=debrid")) return new Response(JSON.stringify({ link: "https://mega.direct/ok" }), { status: 200 }); + return new Response("Not found", { status: 404 }); + }) as unknown as typeof fetch; + + const fallback = new MegaWebFallback(() => ({ login: "same", password: "pw" })); + const [r1, r2] = await Promise.all([ + fallback.unrestrict("https://mega.debrid/a", undefined, { login: "same", password: "pw" }), + fallback.unrestrict("https://mega.debrid/b", undefined, { login: "same", password: "pw" }) + ]); + expect(r1?.directUrl).toBe("https://mega.direct/ok"); + expect(r2?.directUrl).toBe("https://mega.direct/ok"); + // Serialisiert auf demselben Account → der zweite nutzt die gecachte Session, kein zweiter Login. + expect(loginCount).toBe(1); + }); + + it("wandelt auf VERSCHIEDENEN Accounts parallel um (Logins laufen gleichzeitig)", async () => { + let activeLogins = 0; + let maxActiveLogins = 0; + const bothInFlight = new Promise((resolve) => { + const check = setInterval(() => { if (activeLogins >= 2) { clearInterval(check); resolve(); } }, 5); + }); + globalThis.fetch = vi.fn(async (url: string | URL | Request) => { + const u = String(url); + if (u.includes("form=login")) { + activeLogins += 1; + maxActiveLogins = Math.max(maxActiveLogins, activeLogins); + await bothInFlight; + activeLogins -= 1; + const headers = new Headers(); + headers.append("set-cookie", "session=c; path=/"); + return new Response("", { headers, status: 200 }); + } + if (u.includes("page=debrideur")) return new Response('
', { status: 200 }); + if (u.includes("form=debrid")) return new Response(`

Link: https://mega.debrid/l

d
`, { status: 200 }); + if (u.includes("ajax=debrid")) return new Response(JSON.stringify({ link: "https://mega.direct/ok" }), { status: 200 }); + return new Response("Not found", { status: 404 }); + }) as unknown as typeof fetch; + + const fallback = new MegaWebFallback(() => ({ login: "d", password: "p" })); + const [r1, r2] = await Promise.all([ + fallback.unrestrict("https://mega.debrid/a", undefined, { login: "acc1", password: "p" }), + fallback.unrestrict("https://mega.debrid/b", undefined, { login: "acc2", password: "p" }) + ]); + expect(r1?.directUrl).toBe("https://mega.direct/ok"); + expect(r2?.directUrl).toBe("https://mega.direct/ok"); + // Verschiedene Accounts → beide Logins gleichzeitig in-flight (sonst haengt es am bothInFlight-Barrier). + expect(maxActiveLogins).toBe(2); + }, 10000); + + it("aborts pending Mega-Web polling when signal is cancelled", async () => { + globalThis.fetch = vi.fn((url: string | URL | Request, init?: RequestInit): Promise => { + const urlStr = String(url); + + if (urlStr.includes("form=login")) { + const headers = new Headers(); + headers.append("set-cookie", "session=goodcookie; path=/"); + return Promise.resolve(new Response("", { headers, status: 200 })); + } + + if (urlStr.includes("page=debrideur")) { + return Promise.resolve(new Response('
', { status: 200 })); + } + + if (urlStr.includes("form=debrid")) { + return Promise.resolve(new Response(` +
+

Link: https://mega.debrid/link2

+ Download +
+ `, { status: 200 })); + } + + if (urlStr.includes("ajax=debrid")) { + return new Promise((_resolve, reject) => { + const signal = init?.signal; + const onAbort = (): void => reject(new Error("aborted:ajax")); + if (signal?.aborted) { + onAbort(); + return; + } + signal?.addEventListener("abort", onAbort, { once: true }); + }); + } + + return Promise.resolve(new Response("Not found", { status: 404 })); + }) as unknown as typeof fetch; + + const fallback = new MegaWebFallback(() => ({ login: "user", password: "pwd" })); + const controller = new AbortController(); + const timer = setTimeout(() => { + controller.abort("test"); + }, 200); + + try { + await expect(fallback.unrestrict("https://mega.debrid/link2", controller.signal)).rejects.toThrow(/aborted/i); + } finally { + clearTimeout(timer); + } + }); + + it("klassifiziert einen Abbruch WAEHREND in der Queue als Queue-Timeout (nicht harter Abbruch), damit der belegte Account nicht bestraft wird", async () => { + let releaseLogin: () => void = () => {}; + const loginGate = new Promise((resolve) => { releaseLogin = resolve; }); + globalThis.fetch = vi.fn(async (url: string | URL | Request) => { + const u = String(url); + if (u.includes("form=login")) { + await loginGate; + const headers = new Headers(); + headers.append("set-cookie", "session=c; path=/"); + return new Response("", { headers, status: 200 }); + } + if (u.includes("page=debrideur")) return new Response('
', { status: 200 }); + if (u.includes("form=debrid")) return new Response(`

Link: https://mega.debrid/l

d
`, { status: 200 }); + if (u.includes("ajax=debrid")) return new Response(JSON.stringify({ link: "https://mega.direct/ok" }), { status: 200 }); + return new Response("Not found", { status: 404 }); + }) as unknown as typeof fetch; + + const fallback = new MegaWebFallback(() => ({ login: "same", password: "pw" })); + const firstCtrl = new AbortController(); + const first = fallback.unrestrict("https://mega.debrid/a", firstCtrl.signal, { login: "same", password: "pw" }); + + await new Promise((r) => setTimeout(r, 25)); + + const secondCtrl = new AbortController(); + const second = fallback.unrestrict("https://mega.debrid/b", secondCtrl.signal, { login: "same", password: "pw" }); + + await new Promise((r) => setTimeout(r, 25)); + secondCtrl.abort("caller-timeout"); + + await expect(second).rejects.toThrow(/queue.?timeout/i); + + releaseLogin(); + await first.catch(() => null); + await new Promise((r) => setTimeout(r, 20)); + }, 10000); + }); +}); diff --git a/tests/notify-hooks.test.ts b/tests/notify-hooks.test.ts new file mode 100644 index 0000000..582422f --- /dev/null +++ b/tests/notify-hooks.test.ts @@ -0,0 +1,153 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +vi.mock("../src/main/notify", async (importActual) => { + const actual = await importActual(); + return { ...actual, sendNotification: vi.fn().mockResolvedValue(true) }; +}); + +import { DownloadManager } from "../src/main/download-manager"; +import { defaultSettings } from "../src/main/constants"; +import { createStoragePaths, emptySession } from "../src/main/storage"; +import { shutdownItemLogs } from "../src/main/item-log"; +import { shutdownPackageLogs } from "../src/main/package-log"; +import { shutdownRenameLog } from "../src/main/rename-log"; +import { sendNotification } from "../src/main/notify"; + +const mockedSend = sendNotification as unknown as ReturnType; +const tempDirs: string[] = []; + +afterEach(() => { + mockedSend.mockClear(); + shutdownItemLogs(); + shutdownPackageLogs(); + shutdownRenameLog(); + for (const dir of tempDirs.splice(0)) { + try { fs.rmSync(dir, { recursive: true, force: true }); } catch { /* ignore */ } + } +}); + +function setup(): { manager: DownloadManager; session: ReturnType } { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-nh-")); + tempDirs.push(root); + const session = emptySession(); + const manager = new DownloadManager( + { + ...defaultSettings(), + token: "rd-token", + outputDir: path.join(root, "out"), + extractDir: path.join(root, "extract"), + notifyUrl: "https://discord.com/api/webhooks/123/abc", + notifyOnPackageCompleted: true, + notifyOnPackageFailed: true + }, + session, + createStoragePaths(path.join(root, "state")) + ); + return { manager, session }; +} + +function addPackage(session: ReturnType, itemStatuses: string[]): any { + const pkgId = "pkg-1"; + const pkg: any = { + id: pkgId, + name: "Test.Show.S01", + outputDir: "C:/out", + extractDir: "C:/extract", + status: "queued", + itemIds: itemStatuses.map((_s, i) => `it-${i}`), + cancelled: false, + enabled: true, + priority: "normal", + createdAt: 1, + updatedAt: 1 + }; + session.packages[pkgId] = pkg; + session.packageOrder.push(pkgId); + itemStatuses.forEach((status, i) => { + session.items[`it-${i}`] = { + id: `it-${i}`, + packageId: pkgId, + url: `https://dummy/${i}`, + provider: null, + status, + retries: 0, + speedBps: 0, + downloadedBytes: 0, + totalBytes: null, + progressPercent: 0, + fileName: `f${i}.rar`, + targetPath: "", + resumable: true, + attempts: 1, + lastError: "", + fullStatus: "", + createdAt: 1, + updatedAt: 1 + } as any; + }); + return pkg; +} + +describe("refreshPackageStatus failed-transition notify", () => { + it("notifies a MIXED package (some success, last finisher failed) — the lost-webhook case", () => { + const { manager, session } = setup(); + const pkg = addPackage(session, ["completed", "failed"]); + session.running = true; + + (manager as any).refreshPackageStatus(pkg); + + expect(pkg.status).toBe("failed"); + expect(mockedSend).toHaveBeenCalledTimes(1); + expect(mockedSend.mock.calls[0][1].title).toBe("❌ Paket fehlgeschlagen"); + expect(mockedSend.mock.calls[0][1].message).toContain("1 von 2"); + }); + + it("notifies an all-failed package and dedups repeat refreshes", () => { + const { manager, session } = setup(); + const pkg = addPackage(session, ["failed", "failed"]); + session.running = true; + + (manager as any).refreshPackageStatus(pkg); + (manager as any).refreshPackageStatus(pkg); + + expect(pkg.status).toBe("failed"); + expect(mockedSend).toHaveBeenCalledTimes(1); + }); + + it("stays silent outside a run (startup recovery must not spam)", () => { + const { manager, session } = setup(); + const pkg = addPackage(session, ["failed"]); + session.running = false; + + (manager as any).refreshPackageStatus(pkg); + + expect(pkg.status).toBe("failed"); + expect(mockedSend).not.toHaveBeenCalled(); + }); + + it("does not notify while items are still pending", () => { + const { manager, session } = setup(); + const pkg = addPackage(session, ["failed", "queued"]); + session.running = true; + + (manager as any).refreshPackageStatus(pkg); + + expect(pkg.status).toBe("queued"); + expect(mockedSend).not.toHaveBeenCalled(); + }); + + it("releases the dedup marker when the send ultimately fails (retro-notify possible)", async () => { + const { manager, session } = setup(); + const pkg = addPackage(session, ["failed", "failed"]); + session.running = true; + mockedSend.mockResolvedValueOnce(false); + + (manager as any).refreshPackageStatus(pkg); + await new Promise((r) => setTimeout(r, 0)); + + expect((manager as any).notifiedPackages.has(pkg.id)).toBe(false); + }); +}); diff --git a/tests/notify.test.ts b/tests/notify.test.ts new file mode 100644 index 0000000..e398714 --- /dev/null +++ b/tests/notify.test.ts @@ -0,0 +1,127 @@ +import { describe, expect, it, vi } from "vitest"; +import { buildNotifyRequest, isNotifyUrlValid, normalizeDiscordMention, sendNotification, truncateContent } from "../src/main/notify"; + +const noSleep = async (): Promise => {}; +const WEBHOOK = "https://discord.com/api/webhooks/123/abc"; + +describe("normalizeDiscordMention", () => { + it("wraps a bare user ID as a pinging mention", () => { + expect(normalizeDiscordMention("123456789012345678")).toBe("<@123456789012345678>"); + expect(normalizeDiscordMention(" 987654321 ")).toBe("<@987654321>"); + }); + it("passes @everyone/@here and formed mentions through", () => { + expect(normalizeDiscordMention("@everyone")).toBe("@everyone"); + expect(normalizeDiscordMention("@here")).toBe("@here"); + expect(normalizeDiscordMention("<@123456789>")).toBe("<@123456789>"); + expect(normalizeDiscordMention("<@&111222333>")).toBe("<@&111222333>"); + }); + it("returns empty for empty input", () => { + expect(normalizeDiscordMention("")).toBe(""); + expect(normalizeDiscordMention(" ")).toBe(""); + }); +}); + +describe("isNotifyUrlValid", () => { + it("accepts http/https URLs", () => { + expect(isNotifyUrlValid(WEBHOOK)).toBe(true); + expect(isNotifyUrlValid("http://192.168.1.10:8080/hook")).toBe(true); + expect(isNotifyUrlValid(` ${WEBHOOK} `)).toBe(true); + }); + it("rejects empty and non-http values", () => { + expect(isNotifyUrlValid("")).toBe(false); + expect(isNotifyUrlValid("discord.com/api/webhooks/123/abc")).toBe(false); + expect(isNotifyUrlValid("ftp://x")).toBe(false); + expect(isNotifyUrlValid("https:// mit leerzeichen")).toBe(false); + expect(isNotifyUrlValid("***")).toBe(false); + }); +}); + +describe("truncateContent", () => { + it("leaves short content untouched", () => { + expect(truncateContent("hallo")).toBe("hallo"); + }); + it("caps at the limit", () => { + expect(truncateContent("x".repeat(3000)).length).toBe(2000); + }); + it("never splits a surrogate pair at the boundary", () => { + const emoji = "🏁"; + const content = "x".repeat(1999) + emoji; + const cut = truncateContent(content); + expect(cut.length).toBe(1999); + expect(/[\uD800-\uDBFF]$/.test(cut)).toBe(false); + }); +}); + +describe("buildNotifyRequest", () => { + it("builds a Discord-compatible JSON webhook POST (bold title + message as content)", () => { + const req = buildNotifyRequest(` ${WEBHOOK} `, { title: "✅ Paket fertig", message: "Show.S01\n5 Datei(en)" }); + expect(req.url).toBe(WEBHOOK); + expect(req.init.method).toBe("POST"); + expect(req.init.headers).toMatchObject({ "Content-Type": "application/json" }); + const body = JSON.parse(String(req.init.body)); + expect(body.content).toBe("**✅ Paket fertig**\nShow.S01\n5 Datei(en)"); + expect(body.username).toBe("Real-Debrid Downloader"); + }); + it("prepends the mention so Discord pings (bare ID gets wrapped)", () => { + const req = buildNotifyRequest(WEBHOOK, { title: "T", message: "M", mention: "123456789012345678" }); + const body = JSON.parse(String(req.init.body)); + expect(body.content).toBe("<@123456789012345678> **T**\nM"); + }); + it("sends no mention prefix when the field is empty", () => { + const req = buildNotifyRequest(WEBHOOK, { title: "T", message: "M", mention: "" }); + const body = JSON.parse(String(req.init.body)); + expect(body.content).toBe("**T**\nM"); + }); +}); + +describe("sendNotification", () => { + it("returns true on HTTP ok (Discord answers 204 No Content)", async () => { + const fetchFn = vi.fn().mockResolvedValue(new Response(null, { status: 204 })); + await expect(sendNotification(WEBHOOK, { title: "T", message: "M" }, fetchFn, noSleep)).resolves.toBe(true); + expect(fetchFn).toHaveBeenCalledTimes(1); + }); + it("retries a 429 using Discord's retry_after and then succeeds", async () => { + const waits: number[] = []; + const sleepSpy = async (ms: number): Promise => { waits.push(ms); }; + const fetchFn = vi.fn() + .mockResolvedValueOnce(new Response(JSON.stringify({ retry_after: 1.2 }), { status: 429 })) + .mockResolvedValueOnce(new Response(null, { status: 204 })); + await expect(sendNotification(WEBHOOK, { title: "T", message: "M" }, fetchFn, sleepSpy)).resolves.toBe(true); + expect(fetchFn).toHaveBeenCalledTimes(2); + expect(waits).toContain(1200); // seconds -> ms + }); + it("retries transient 5xx and network errors, then gives up", async () => { + const fetchFn = vi.fn().mockResolvedValue(new Response("", { status: 502 })); + await expect(sendNotification(WEBHOOK, { title: "T", message: "M" }, fetchFn, noSleep)).resolves.toBe(false); + expect(fetchFn).toHaveBeenCalledTimes(3); // initial + 2 retries + + const fetchErr = vi.fn().mockRejectedValue(new Error("offline")); + await expect(sendNotification(WEBHOOK, { title: "T", message: "M" }, fetchErr, noSleep)).resolves.toBe(false); + expect(fetchErr).toHaveBeenCalledTimes(3); + }); + it("does not retry a permanent 4xx", async () => { + const fetchFn = vi.fn().mockResolvedValue(new Response("", { status: 404 })); + await expect(sendNotification(WEBHOOK, { title: "T", message: "M" }, fetchFn, noSleep)).resolves.toBe(false); + expect(fetchFn).toHaveBeenCalledTimes(1); + }); + it("serializes concurrent sends in order (burst protection)", async () => { + const order: string[] = []; + const fetchFn = vi.fn().mockImplementation(async (_url: string, init: RequestInit) => { + order.push(JSON.parse(String(init.body)).content); + return new Response(null, { status: 204 }); + }); + const sends = [ + sendNotification(WEBHOOK, { title: "1", message: "" }, fetchFn, noSleep), + sendNotification(WEBHOOK, { title: "2", message: "" }, fetchFn, noSleep), + sendNotification(WEBHOOK, { title: "3", message: "" }, fetchFn, noSleep) + ]; + await expect(Promise.all(sends)).resolves.toEqual([true, true, true]); + expect(order).toEqual(["**1**\n", "**2**\n", "**3**\n"]); + }); + it("does not call fetch for an invalid URL", async () => { + const fetchFn = vi.fn(); + await expect(sendNotification("", { title: "T", message: "M" }, fetchFn, noSleep)).resolves.toBe(false); + await expect(sendNotification("kein-url", { title: "T", message: "M" }, fetchFn, noSleep)).resolves.toBe(false); + expect(fetchFn).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/package-log.test.ts b/tests/package-log.test.ts new file mode 100644 index 0000000..d3440f9 --- /dev/null +++ b/tests/package-log.test.ts @@ -0,0 +1,82 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { ensurePackageLog, getPackageLogPath, initPackageLogs, logPackageEvent, shutdownPackageLogs } from "../src/main/package-log"; + +const tempDirs: string[] = []; + +afterEach(() => { + shutdownPackageLogs(); + for (const dir of tempDirs.splice(0)) { + fs.rmSync(dir, { recursive: true, force: true }); + } +}); + +describe("package-log", () => { + it("creates a persistent package log file", () => { + const baseDir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-plog-")); + tempDirs.push(baseDir); + + initPackageLogs(baseDir); + const logPath = ensurePackageLog({ + packageId: "pkg-1", + name: "Test Paket", + outputDir: "C:\\downloads\\Test Paket", + extractDir: "C:\\extract\\Test Paket" + }); + + expect(logPath).not.toBeNull(); + expect(fs.existsSync(logPath!)).toBe(true); + + const content = fs.readFileSync(logPath!, "utf8"); + expect(content).toContain("Paket-Log Start"); + expect(content).toContain("Test Paket"); + }); + + it("writes detail events into the package log", async () => { + const baseDir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-plog-")); + tempDirs.push(baseDir); + + initPackageLogs(baseDir); + ensurePackageLog({ + packageId: "pkg-2", + name: "Detail Paket", + outputDir: "C:\\downloads\\Detail Paket", + extractDir: "C:\\extract\\Detail Paket" + }); + + logPackageEvent("pkg-2", "INFO", "Passwort-Versuch", { + archive: "episode.part1.rar", + attempt: "1/3", + password: "\"secret\"" + }); + + await new Promise((resolve) => setTimeout(resolve, 350)); + + const logPath = getPackageLogPath("pkg-2"); + expect(logPath).not.toBeNull(); + const content = fs.readFileSync(logPath!, "utf8"); + expect(content).toContain("Passwort-Versuch"); + expect(content).toContain("archive=episode.part1.rar"); + expect(content).toContain("password=\"secret\""); + }); + + it("keeps traversal-like package ids inside the package log directory", () => { + const baseDir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-plog-")); + tempDirs.push(baseDir); + + initPackageLogs(baseDir); + const logPath = ensurePackageLog({ + packageId: "..\\..\\outside", + name: "Traversal Paket", + outputDir: "C:\\downloads\\Traversal Paket", + extractDir: "C:\\extract\\Traversal Paket" + }); + + expect(logPath).not.toBeNull(); + const logsDir = path.resolve(path.join(baseDir, "package-logs")); + const resolvedLogPath = path.resolve(logPath!); + expect(resolvedLogPath === logsDir || resolvedLogPath.startsWith(`${logsDir}${path.sep}`)).toBe(true); + }); +}); diff --git a/tests/package-order.test.ts b/tests/package-order.test.ts new file mode 100644 index 0000000..b9200af --- /dev/null +++ b/tests/package-order.test.ts @@ -0,0 +1,109 @@ +import { describe, expect, it } from "vitest"; +import type { DownloadItem, PackageEntry } from "../src/shared/types"; +import { sortPackagesForDisplay } from "../src/renderer/package-order"; + +function createPackage(id: string, itemIds: string[]): PackageEntry { + const now = Date.now(); + return { + id, + name: id, + outputDir: "", + extractDir: "", + status: "queued", + itemIds, + cancelled: false, + enabled: true, + priority: "normal", + createdAt: now, + updatedAt: now + }; +} + +function createItem(id: string, packageId: string, status: DownloadItem["status"], downloadedBytes: number): DownloadItem { + const now = Date.now(); + return { + id, + packageId, + url: `https://hoster.example/${id}`, + provider: null, + status, + retries: 0, + speedBps: 0, + downloadedBytes, + totalBytes: downloadedBytes, + progressPercent: downloadedBytes > 0 ? 50 : 0, + fileName: `${id}.bin`, + targetPath: "", + resumable: true, + attempts: 0, + lastError: "", + fullStatus: "", + createdAt: now, + updatedAt: now + }; +} + +describe("sortPackagesForDisplay", () => { + it("floats active packages to the top, keeping queue order within each group", () => { + // pkg-a and pkg-b both have an active (downloading) item -> both float up in + // their original queue order; pkg-c (queued only) sinks below. + const packages = [ + createPackage("pkg-a", ["a1", "a2"]), + createPackage("pkg-c", ["c1"]), + createPackage("pkg-b", ["b1", "b2"]) + ]; + const items: Record = { + a1: createItem("a1", "pkg-a", "downloading", 250), + a2: createItem("a2", "pkg-a", "completed", 500), + c1: createItem("c1", "pkg-c", "queued", 0), + b1: createItem("b1", "pkg-b", "downloading", 800), + b2: createItem("b2", "pkg-b", "completed", 900) + }; + + const sorted = sortPackagesForDisplay(packages, items, true, true); + + // active group [pkg-a, pkg-b] in queue order, then rest [pkg-c] + expect(sorted.map((pkg) => pkg.id)).toEqual(["pkg-a", "pkg-b", "pkg-c"]); + }); + + it("does NOT reshuffle active packages when only their progress changes (anti-flicker)", () => { + const packages = [ + createPackage("pkg-a", ["a1"]), + createPackage("pkg-b", ["b1"]) + ]; + // Both active. pkg-b initially has more bytes than pkg-a. + const before: Record = { + a1: createItem("a1", "pkg-a", "downloading", 100), + b1: createItem("b1", "pkg-b", "downloading", 900) + }; + const orderBefore = sortPackagesForDisplay(packages, before, true, true).map((p) => p.id); + + // A progress tick: pkg-a overtakes pkg-b in bytes. Order must NOT change — + // both are still active, so they keep queue order. (Old code swapped them.) + const after: Record = { + a1: createItem("a1", "pkg-a", "downloading", 5000), + b1: createItem("b1", "pkg-b", "downloading", 950) + }; + const orderAfter = sortPackagesForDisplay(packages, after, true, true).map((p) => p.id); + + expect(orderBefore).toEqual(["pkg-a", "pkg-b"]); + expect(orderAfter).toEqual(orderBefore); + }); + + it("keeps package order untouched when auto sort is disabled", () => { + const packages = [ + createPackage("pkg-a", ["a1"]), + createPackage("pkg-b", ["b1"]), + createPackage("pkg-c", ["c1"]) + ]; + const items: Record = { + a1: createItem("a1", "pkg-a", "queued", 0), + b1: createItem("b1", "pkg-b", "downloading", 500), + c1: createItem("c1", "pkg-c", "queued", 0) + }; + + const sorted = sortPackagesForDisplay(packages, items, true, false); + + expect(sorted.map((pkg) => pkg.id)).toEqual(["pkg-a", "pkg-b", "pkg-c"]); + }); +}); diff --git a/tests/public-release-metadata.test.ts b/tests/public-release-metadata.test.ts new file mode 100644 index 0000000..38837b5 --- /dev/null +++ b/tests/public-release-metadata.test.ts @@ -0,0 +1,391 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import crypto from "node:crypto"; +import { spawnSync } from "node:child_process"; +import { afterEach, describe, expect, it } from "vitest"; + +type ReleaseVerification = { + publish: { + provider: string; + owner: string; + repo: string; + }; + latestArtifact: string; + missingArtifacts: string[]; +}; + +type CommandResult = { + status: number | null; + stdout?: string; + stderr?: string; + error?: Error; +}; + +type ArchiveVerification = { + verifiedArchives: string[]; +}; + +const verifierPath = path.resolve("scripts", "verify_public_release.mjs"); +const verifierUrl = "../scripts/verify_public_release.mjs"; +const { verifyPublicRelease, verifyReleaseArchives } = await import(verifierUrl) as { + verifyPublicRelease: (rootDir: string) => ReleaseVerification; + verifyReleaseArchives: ( + rootDir: string, + options: { + sevenZipPath: string; + runCommand: (command: string, args: string[]) => CommandResult; + } + ) => ArchiveVerification; +}; +const fixtureRoots: string[] = []; +const redistributionFiles = [ + "LICENSE", + "resources/extractor-jvm/licenses/LGPL-2.1.txt", + "resources/extractor-jvm/licenses/7-Zip-license.txt", + "resources/extractor-jvm/licenses/Apache-2.0.txt", + "resources/extractor-jvm/THIRD_PARTY_NOTICES.txt" +] as const; + +function writeFile(rootDir: string, relativePath: string, content: string | Buffer): void { + const filePath = path.join(rootDir, ...relativePath.split("/")); + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + fs.writeFileSync(filePath, content); +} + +function writeRedistributionFiles(rootDir: string, packaged = false): void { + for (const relativePath of redistributionFiles) { + const content = fs.readFileSync(path.resolve(...relativePath.split("/"))); + let targetPath: string = relativePath; + if (packaged && relativePath === "LICENSE") { + targetPath = "win-unpacked/resources/LICENSE"; + } else if (packaged) { + targetPath = `win-unpacked/resources/app.asar.unpacked/${relativePath}`; + } + writeFile(rootDir, targetPath, content); + } +} + +function writeArchivePayload(outputDir: string, omittedName = ""): void { + for (const relativePath of redistributionFiles) { + if (path.basename(relativePath) === omittedName) { + continue; + } + const content = fs.readFileSync(path.resolve(...relativePath.split("/"))); + const targetPath = relativePath === "LICENSE" + ? "resources/LICENSE" + : `resources/app.asar.unpacked/${relativePath}`; + writeFile(outputDir, targetPath, content); + } +} + +function createArchiveCommandRunner(omittedName = "") { + return (command: string, args: string[]): CommandResult => { + const archivePath = args[1] || ""; + const outputArg = args.find((arg) => arg.startsWith("-o")); + if (!outputArg) { + return { status: 2, stderr: "missing output directory" }; + } + const outputDir = outputArg.slice(2); + if (archivePath.toLowerCase().endsWith(".exe")) { + writeFile(outputDir, "payload/app-64.7z", "nested archive"); + } else if (archivePath.toLowerCase().endsWith(".7z")) { + writeArchivePayload(outputDir, omittedName); + } + return { status: command ? 0 : 2, stdout: "ok", stderr: "" }; + }; +} + +function createReleaseFixture(): string { + const rootDir = fs.mkdtempSync(path.join(os.tmpdir(), "public-release-metadata-")); + fixtureRoots.push(rootDir); + const setupPayload = Buffer.from("setup"); + const setupSha512 = crypto.createHash("sha512").update(setupPayload).digest("base64"); + + writeFile(rootDir, "package.json", `${JSON.stringify({ + name: "real-debrid-downloader", + version: "1.7.233", + build: { + productName: "Real-Debrid-Downloader", + publish: { + provider: "github", + owner: "Sucukdeluxe", + repo: "multi-debrid-downloader" + }, + files: [ + "build/main/**/*", + "build/renderer/**/*", + "resources/extractor-jvm/**/*", + "LICENSE", + "package.json" + ], + extraResources: [ + { + from: "LICENSE", + to: "LICENSE" + } + ], + nsis: { + artifactName: "${productName}-Setup-${version}.${ext}", + oneClick: false, + perMachine: false, + allowToChangeInstallationDirectory: true, + createDesktopShortcut: true + }, + portable: { + artifactName: "${productName}-${version}-portable.${ext}" + } + } + }, null, 2)}\n`); + writeFile( + rootDir, + "latest.yml", + `version: 1.7.233\nfiles:\n - url: Real-Debrid-Downloader-Setup-1.7.233.exe\n sha512: ${setupSha512}\n size: ${setupPayload.length}\npath: Real-Debrid-Downloader-Setup-1.7.233.exe\nsha512: ${setupSha512}\n` + ); + writeFile( + rootDir, + "win-unpacked/resources/app-update.yml", + "provider: github\nowner: Sucukdeluxe\nrepo: multi-debrid-downloader\n" + ); + writeFile(rootDir, "Real-Debrid-Downloader-Setup-1.7.233.exe", setupPayload); + writeFile(rootDir, "Real-Debrid-Downloader-Setup-1.7.233.exe.blockmap", "blockmap"); + writeFile(rootDir, "Real-Debrid-Downloader-1.7.233-portable.exe", "portable"); + writeRedistributionFiles(rootDir); + writeRedistributionFiles(rootDir, true); + + return rootDir; +} + +afterEach(() => { + for (const rootDir of fixtureRoots.splice(0)) { + fs.rmSync(rootDir, { recursive: true, force: true }); + } +}); + +describe("public release metadata", () => { + it("accepts the canonical GitHub release metadata and artifacts", () => { + const rootDir = createReleaseFixture(); + + const result = verifyPublicRelease(rootDir); + + expect(result.publish).toEqual({ + provider: "github", + owner: "Sucukdeluxe", + repo: "multi-debrid-downloader" + }); + expect(result.latestArtifact).toBe("Real-Debrid-Downloader-Setup-1.7.233.exe"); + expect(result.missingArtifacts).toEqual([]); + }); + + it("rejects a package configured for a different GitHub owner", () => { + const rootDir = createReleaseFixture(); + const packagePath = path.join(rootDir, "package.json"); + const packageJson = JSON.parse(fs.readFileSync(packagePath, "utf8")); + packageJson.build.publish.owner = "DifferentOwner"; + fs.writeFileSync(packagePath, `${JSON.stringify(packageJson, null, 2)}\n`); + + expect(() => verifyPublicRelease(rootDir)).toThrow(/owner/i); + }); + + it("rejects a latest.yml path whose artifact does not exist", () => { + const rootDir = createReleaseFixture(); + fs.rmSync(path.join(rootDir, "Real-Debrid-Downloader-Setup-1.7.233.exe")); + + expect(() => verifyPublicRelease(rootDir)).toThrow(/Real-Debrid-Downloader-Setup-1\.7\.233\.exe/); + }); + + it("rejects syntactically invalid latest.yml", () => { + const rootDir = createReleaseFixture(); + fs.writeFileSync( + path.join(rootDir, "latest.yml"), + "version: 1.7.233\nfiles: [\npath: Real-Debrid-Downloader-Setup-1.7.233.exe\n" + ); + + expect(() => verifyPublicRelease(rootDir)).toThrow(/latest\.yml|yaml/i); + }); + + it("rejects a noncanonical files entry in latest.yml", () => { + const rootDir = createReleaseFixture(); + const latestPath = path.join(rootDir, "latest.yml"); + const latest = fs.readFileSync(latestPath, "utf8").replace( + "url: Real-Debrid-Downloader-Setup-1.7.233.exe", + "url: Different-Setup-1.7.233.exe" + ); + fs.writeFileSync(latestPath, latest); + + expect(() => verifyPublicRelease(rootDir)).toThrow(/files|url|canonical/i); + }); + + it("rejects a latest.yml SHA512 digest that does not match the installer", () => { + const rootDir = createReleaseFixture(); + const latestPath = path.join(rootDir, "latest.yml"); + const latest = fs.readFileSync(latestPath, "utf8"); + const wrongDigest = Buffer.alloc(64, 0x23).toString("base64"); + fs.writeFileSync(latestPath, latest.replace(/sha512: [^\n]+/g, `sha512: ${wrongDigest}`)); + + expect(() => verifyPublicRelease(rootDir)).toThrow(/sha512|digest|integrity/i); + }); + + it("rejects a directory in place of an artifact file", () => { + const rootDir = createReleaseFixture(); + const setupPath = path.join(rootDir, "Real-Debrid-Downloader-Setup-1.7.233.exe"); + fs.rmSync(setupPath); + fs.mkdirSync(setupPath); + + expect(() => verifyPublicRelease(rootDir)).toThrow(/artifact|file/i); + }); + + it("rejects an empty artifact file", () => { + const rootDir = createReleaseFixture(); + fs.writeFileSync(path.join(rootDir, "Real-Debrid-Downloader-1.7.233-portable.exe"), ""); + + expect(() => verifyPublicRelease(rootDir)).toThrow(/artifact|empty|file/i); + }); + + it("rejects a release missing a declared redistribution license", () => { + const rootDir = createReleaseFixture(); + fs.rmSync(path.join(rootDir, "resources", "extractor-jvm", "licenses", "Apache-2.0.txt")); + + expect(() => verifyPublicRelease(rootDir)).toThrow(/Apache-2\.0\.txt/); + }); + + it("rejects a modified official license text", () => { + const rootDir = createReleaseFixture(); + fs.appendFileSync( + path.join(rootDir, "resources", "extractor-jvm", "licenses", "LGPL-2.1.txt"), + "modified" + ); + + expect(() => verifyPublicRelease(rootDir)).toThrow(/LGPL-2\.1\.txt|digest|content/i); + }); + + it("rejects swapped third-party license assignments", () => { + const rootDir = createReleaseFixture(); + const noticePath = path.join(rootDir, "resources", "extractor-jvm", "THIRD_PARTY_NOTICES.txt"); + const notice = fs.readFileSync(noticePath, "utf8") + .replace("GNU Lesser General Public License 2.1 or later", "Apache License 2.0") + .replace("licenses/LGPL-2.1.txt; licenses/7-Zip-license.txt", "licenses/Apache-2.0.txt"); + fs.writeFileSync(noticePath, notice); + + expect(() => verifyPublicRelease(rootDir)).toThrow(/THIRD_PARTY_NOTICES|notice|digest|mapping/i); + }); + + it("rejects a release whose unpacked application omits a license", () => { + const rootDir = createReleaseFixture(); + fs.rmSync(path.join( + rootDir, + "win-unpacked", + "resources", + "app.asar.unpacked", + "resources", + "extractor-jvm", + "licenses", + "Apache-2.0.txt" + )); + + expect(() => verifyPublicRelease(rootDir)).toThrow(/win-unpacked|Apache-2\.0\.txt|packaged/i); + }); + + it("rejects a symlink in place of a release artifact", () => { + const rootDir = createReleaseFixture(); + const setupPath = path.join(rootDir, "Real-Debrid-Downloader-Setup-1.7.233.exe"); + const targetPath = path.join(rootDir, "setup-target.exe"); + fs.renameSync(setupPath, targetPath); + fs.symlinkSync(targetPath, setupPath, "file"); + + expect(() => verifyPublicRelease(rootDir)).toThrow(/symbolic|symlink|regular file/i); + }); + + it("rejects a symlink in place of an official license", () => { + const rootDir = createReleaseFixture(); + const licensePath = path.join(rootDir, "resources", "extractor-jvm", "licenses", "Apache-2.0.txt"); + const targetPath = path.join(rootDir, "Apache-target.txt"); + fs.renameSync(licensePath, targetPath); + fs.symlinkSync(targetPath, licensePath, "file"); + + expect(() => verifyPublicRelease(rootDir)).toThrow(/symbolic|symlink|regular file/i); + }); + + it("rejects build metadata that omits the project license", () => { + const rootDir = createReleaseFixture(); + const packagePath = path.join(rootDir, "package.json"); + const packageJson = JSON.parse(fs.readFileSync(packagePath, "utf8")); + packageJson.build.files = packageJson.build.files.filter((entry: string) => entry !== "LICENSE"); + fs.writeFileSync(packagePath, `${JSON.stringify(packageJson, null, 2)}\n`); + + expect(() => verifyPublicRelease(rootDir)).toThrow(/LICENSE/); + }); + + it("rejects build metadata that does not copy the project license into resources", () => { + const rootDir = createReleaseFixture(); + const packagePath = path.join(rootDir, "package.json"); + const packageJson = JSON.parse(fs.readFileSync(packagePath, "utf8")); + delete packageJson.build.extraResources; + fs.writeFileSync(packagePath, `${JSON.stringify(packageJson, null, 2)}\n`); + + expect(() => verifyPublicRelease(rootDir)).toThrow(/extraResources|LICENSE/); + }); + + it("rejects incomplete third-party redistribution notices", () => { + const rootDir = createReleaseFixture(); + fs.writeFileSync( + path.join(rootDir, "resources", "extractor-jvm", "THIRD_PARTY_NOTICES.txt"), + "net.sf.sevenzipjbinding:sevenzipjbinding:16.02-2.01 LGPL-2.1.txt\n" + ); + + expect(() => verifyPublicRelease(rootDir)).toThrow(/THIRD_PARTY_NOTICES|notice|content/i); + }); + + it("returns a nonzero CLI status for invalid release metadata", () => { + const rootDir = createReleaseFixture(); + const packagePath = path.join(rootDir, "package.json"); + const packageJson = JSON.parse(fs.readFileSync(packagePath, "utf8")); + packageJson.build.publish.owner = "DifferentOwner"; + fs.writeFileSync(packagePath, `${JSON.stringify(packageJson, null, 2)}\n`); + + const result = spawnSync(process.execPath, [verifierPath, rootDir], { + encoding: "utf8" + }); + + expect(result.status).not.toBe(0); + expect(result.stderr).toMatch(/owner/i); + }); + + it("recursively verifies redistribution files inside setup and portable archives", () => { + const rootDir = createReleaseFixture(); + const result = verifyReleaseArchives(rootDir, { + sevenZipPath: "C:\\Tools\\7-Zip\\7z.exe", + runCommand: createArchiveCommandRunner() + }); + + expect(result.verifiedArchives).toEqual([ + "Real-Debrid-Downloader-Setup-1.7.233.exe", + "Real-Debrid-Downloader-1.7.233-portable.exe" + ]); + }); + + it("rejects an archive whose nested application payload omits a license", () => { + const rootDir = createReleaseFixture(); + + expect(() => verifyReleaseArchives(rootDir, { + sevenZipPath: "C:\\Tools\\7-Zip\\7z.exe", + runCommand: createArchiveCommandRunner("Apache-2.0.txt") + })).toThrow(/Apache-2\.0\.txt|missing redistribution file/i); + }); + + it("exposes archive verification as a nonzero CLI gate", () => { + const rootDir = createReleaseFixture(); + const result = spawnSync(process.execPath, [ + verifierPath, + rootDir, + "--verify-archives", + "--seven-zip", + path.join(rootDir, "missing-7z.exe") + ], { + encoding: "utf8" + }); + + expect(result.status).not.toBe(0); + expect(result.stderr).toMatch(/7-Zip|command|spawn/i); + }); +}); diff --git a/tests/realdebrid-web.test.ts b/tests/realdebrid-web.test.ts new file mode 100644 index 0000000..a3632e2 --- /dev/null +++ b/tests/realdebrid-web.test.ts @@ -0,0 +1,140 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const { + mockSessionFetch, + mockClearStorageData, + mockClearCache, + mockFromPartition, + mockBrowserWindow, + mockBrowserWindowCtor, + mockExecuteJavaScript, + mockLoadURL, + mockShow, + mockFocus +} = vi.hoisted(() => { + const sessionFetch = vi.fn(); + const clearStorageData = vi.fn(); + const clearCache = vi.fn(); + const fromPartition = vi.fn(); + const executeJavaScript = vi.fn(); + const loadURL = vi.fn(async () => {}); + const show = vi.fn(); + const focus = vi.fn(); + const webContentsEvents: Record void> = {}; + const windowEvents: Record void> = {}; + let destroyed = false; + + const browserWindow = { + isDestroyed: vi.fn(() => destroyed), + isMinimized: vi.fn(() => false), + restore: vi.fn(), + show, + focus, + close: vi.fn(() => { + destroyed = true; + windowEvents.closed?.(); + }), + setMenuBarVisibility: vi.fn(), + loadURL, + on: vi.fn((event: string, handler: (...args: unknown[]) => void) => { + windowEvents[event] = handler; + return browserWindow; + }), + webContents: { + setUserAgent: vi.fn(), + on: vi.fn((event: string, handler: (...args: unknown[]) => void) => { + webContentsEvents[event] = handler; + }), + executeJavaScript + } + }; + + const BrowserWindowCtor = vi.fn(() => { + destroyed = false; + return browserWindow; + }); + + return { + mockSessionFetch: sessionFetch, + mockClearStorageData: clearStorageData, + mockClearCache: clearCache, + mockFromPartition: fromPartition, + mockBrowserWindow: browserWindow, + mockBrowserWindowCtor: BrowserWindowCtor, + mockExecuteJavaScript: executeJavaScript, + mockLoadURL: loadURL, + mockShow: show, + mockFocus: focus + }; +}); + +vi.mock("electron", () => ({ + session: { + fromPartition: mockFromPartition + }, + BrowserWindow: mockBrowserWindowCtor +})); + +import { RealDebridWebFallback, extractPrivateTokenFromHtml } from "../src/main/realdebrid-web"; + +describe("realdebrid-web", () => { + const mockSession = { + fetch: mockSessionFetch, + clearStorageData: mockClearStorageData, + clearCache: mockClearCache + }; + + beforeEach(() => { + mockFromPartition.mockReturnValue(mockSession); + mockExecuteJavaScript.mockReset(); + mockLoadURL.mockClear(); + mockShow.mockClear(); + mockFocus.mockClear(); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + vi.clearAllMocks(); + mockFromPartition.mockReturnValue(mockSession); + }); + + it("extracts private tokens from current Real-Debrid HTML patterns", () => { + expect(extractPrivateTokenFromHtml("document.querySelectorAll('input[name=private_token]')[0].value = 'abc123';")) + .toBe("abc123"); + expect(extractPrivateTokenFromHtml("")) + .toBe("def456"); + expect(extractPrivateTokenFromHtml("")) + .toBe("ghi789"); + }); + + it("uses the already logged-in browser window to warm the token cache before unrestricting", async () => { + const apiFetch = vi.fn().mockResolvedValue(new Response(JSON.stringify({ + download: "https://cdn.real-debrid.example/file.bin", + filename: "file.bin", + filesize: 12345 + }), { status: 200 })); + vi.stubGlobal("fetch", apiFetch); + + mockExecuteJavaScript.mockResolvedValue("token-from-window"); + + const fallback = new RealDebridWebFallback(() => true); + await fallback.openLoginWindow(); + + const result = await fallback.unrestrict("https://rapidgator.net/file/abc"); + + expect(result).toEqual({ + directUrl: "https://cdn.real-debrid.example/file.bin", + fileName: "file.bin", + fileSize: 12345, + retriesUsed: 0 + }); + expect(mockBrowserWindowCtor).toHaveBeenCalledTimes(1); + expect(mockLoadURL).toHaveBeenCalledWith("https://real-debrid.com"); + expect(mockShow).toHaveBeenCalled(); + expect(mockFocus).toHaveBeenCalled(); + expect(mockSessionFetch).not.toHaveBeenCalled(); + expect(apiFetch).toHaveBeenCalledTimes(1); + expect(apiFetch.mock.calls[0]?.[0]).toBe("https://api.real-debrid.com/rest/1.0/unrestrict/link"); + expect(mockBrowserWindow.webContents.executeJavaScript).toHaveBeenCalled(); + }); +}); diff --git a/tests/realdebrid.test.ts b/tests/realdebrid.test.ts new file mode 100644 index 0000000..cc37e9b --- /dev/null +++ b/tests/realdebrid.test.ts @@ -0,0 +1,42 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { RealDebridClient } from "../src/main/realdebrid"; + +const originalFetch = globalThis.fetch; + +afterEach(() => { + globalThis.fetch = originalFetch; +}); + +describe("realdebrid client", () => { + it("returns a clear error when HTML is returned instead of JSON", async () => { + globalThis.fetch = (async (): Promise => { + return new Response("Cloudflare", { + status: 200, + headers: { "Content-Type": "text/html" } + }); + }) as typeof fetch; + + const client = new RealDebridClient("rd-token"); + await expect(client.unrestrictLink("https://hoster.example/file/html")).rejects.toThrow(/html/i); + }); + + it("does not leak raw response body on JSON parse errors", async () => { + globalThis.fetch = (async (): Promise => { + return new Response("token=secret-should-not-leak", { + status: 200, + headers: { "Content-Type": "application/json" } + }); + }) as typeof fetch; + + const client = new RealDebridClient("rd-token"); + try { + await client.unrestrictLink("https://hoster.example/file/invalid-json"); + throw new Error("expected unrestrict to fail"); + } catch (error) { + const text = String(error || ""); + expect(text.toLowerCase()).toContain("json"); + expect(text.toLowerCase()).not.toContain("secret-should-not-leak"); + expect(text.toLowerCase()).not.toContain(""); + } + }); +}); diff --git a/tests/rename-log.test.ts b/tests/rename-log.test.ts new file mode 100644 index 0000000..2c40efc --- /dev/null +++ b/tests/rename-log.test.ts @@ -0,0 +1,52 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { getRenameLogPath, initRenameLog, logRenameEvent, shutdownRenameLog } from "../src/main/rename-log"; + +const tempDirs: string[] = []; + +afterEach(() => { + shutdownRenameLog(); + for (const dir of tempDirs.splice(0)) { + fs.rmSync(dir, { recursive: true, force: true }); + } +}); + +describe("rename-log", () => { + it("writes rename events to the rename log", () => { + const baseDir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-rlog-")); + tempDirs.push(baseDir); + + initRenameLog(baseDir); + logRenameEvent("INFO", "Auto-Rename durchgeführt", { + packageName: "Test Paket", + sourcePath: "C:\\extract\\old.mkv", + targetPath: "C:\\extract\\new.mkv" + }); + + const logPath = getRenameLogPath(); + expect(logPath).not.toBeNull(); + expect(fs.existsSync(logPath!)).toBe(true); + const content = fs.readFileSync(logPath!, "utf8"); + expect(content).toContain("Rename-Log Start"); + expect(content).toContain("Auto-Rename durchgeführt"); + expect(content).toContain("sourcePath=C:\\extract\\old.mkv"); + }); + + it("rotates oversized rename logs on startup", () => { + const baseDir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-rlog-rotate-")); + tempDirs.push(baseDir); + + const oversizedPath = path.join(baseDir, "rename.log"); + fs.mkdirSync(baseDir, { recursive: true }); + fs.writeFileSync(oversizedPath, "x".repeat(10 * 1024 * 1024 + 256), "utf8"); + + initRenameLog(baseDir); + + expect(fs.existsSync(oversizedPath)).toBe(true); + expect(fs.existsSync(`${oversizedPath}.old`)).toBe(true); + const content = fs.readFileSync(oversizedPath, "utf8"); + expect(content).toContain("Rename-Log Start"); + }); +}); diff --git a/tests/resolve-archive-items.test.ts b/tests/resolve-archive-items.test.ts new file mode 100644 index 0000000..487f62c --- /dev/null +++ b/tests/resolve-archive-items.test.ts @@ -0,0 +1,157 @@ +import { describe, expect, it } from "vitest"; +import { resolveArchiveItemsFromList } from "../src/main/download-manager"; + +type MinimalItem = { + targetPath?: string; + fileName?: string; + [key: string]: unknown; +}; + +function makeItems(names: string[]): MinimalItem[] { + return names.map((name) => ({ + targetPath: `C:\\Downloads\\Package\\${name}`, + fileName: name, + id: name, + status: "completed", + })); +} + +describe("resolveArchiveItemsFromList", () => { + + it("matches multipart .part1.rar archives", () => { + const items = makeItems([ + "Movie.part1.rar", + "Movie.part2.rar", + "Movie.part3.rar", + "Other.rar", + ]); + const result = resolveArchiveItemsFromList("Movie.part1.rar", items as any); + expect(result).toHaveLength(3); + expect(result.map((i: any) => i.fileName)).toEqual([ + "Movie.part1.rar", + "Movie.part2.rar", + "Movie.part3.rar", + ]); + }); + + it("matches multipart .part01.rar archives (zero-padded)", () => { + const items = makeItems([ + "Film.part01.rar", + "Film.part02.rar", + "Film.part10.rar", + "Unrelated.zip", + ]); + const result = resolveArchiveItemsFromList("Film.part01.rar", items as any); + expect(result).toHaveLength(3); + }); + + it("matches old-style .rar + .rNN volumes", () => { + const items = makeItems([ + "Archive.rar", + "Archive.r00", + "Archive.r01", + "Archive.r02", + "Other.zip", + ]); + const result = resolveArchiveItemsFromList("Archive.rar", items as any); + expect(result).toHaveLength(4); + }); + + it("matches a single .rar file", () => { + const items = makeItems(["SingleFile.rar", "Other.mkv"]); + const result = resolveArchiveItemsFromList("SingleFile.rar", items as any); + expect(result).toHaveLength(1); + expect((result[0] as any).fileName).toBe("SingleFile.rar"); + }); + + it("matches split .zip.NNN files", () => { + const items = makeItems([ + "Data.zip", + "Data.zip.001", + "Data.zip.002", + "Data.zip.003", + ]); + const result = resolveArchiveItemsFromList("Data.zip.001", items as any); + expect(result).toHaveLength(4); + }); + + it("matches split .7z.NNN files", () => { + const items = makeItems([ + "Backup.7z.001", + "Backup.7z.002", + ]); + const result = resolveArchiveItemsFromList("Backup.7z.001", items as any); + expect(result).toHaveLength(2); + }); + + it("matches generic .NNN split files", () => { + const items = makeItems([ + "video.001", + "video.002", + "video.003", + ]); + const result = resolveArchiveItemsFromList("video.001", items as any); + expect(result).toHaveLength(3); + }); + + it("matches a single .zip by exact name", () => { + const items = makeItems(["myarchive.zip", "other.rar"]); + const result = resolveArchiveItemsFromList("myarchive.zip", items as any); + expect(result).toHaveLength(1); + expect((result[0] as any).fileName).toBe("myarchive.zip"); + }); + + it("matches case-insensitively", () => { + const items = makeItems([ + "MOVIE.PART1.RAR", + "MOVIE.PART2.RAR", + ]); + const result = resolveArchiveItemsFromList("movie.part1.rar", items as any); + expect(result).toHaveLength(2); + }); + + it("uses stem-based fallback when exact patterns fail", () => { + const items = makeItems([ + "Movie.rar", + ]); + const result = resolveArchiveItemsFromList("Movie.part1.rar", items as any); + expect(result).toHaveLength(1); + }); + + it("returns single archive item when no pattern matches", () => { + const items = makeItems(["totally-different-name.rar"]); + const result = resolveArchiveItemsFromList("Original.rar", items as any); + expect(result).toHaveLength(1); + }); + + it("returns empty when items have no archive extensions", () => { + const items = makeItems(["video.mkv", "subtitle.srt"]); + const result = resolveArchiveItemsFromList("Archive.rar", items as any); + expect(result).toHaveLength(0); + }); + + it("falls back to fileName when targetPath is missing", () => { + const items = [ + { fileName: "Movie.part1.rar", id: "1", status: "completed" }, + { fileName: "Movie.part2.rar", id: "2", status: "completed" }, + ]; + const result = resolveArchiveItemsFromList("Movie.part1.rar", items as any); + expect(result).toHaveLength(2); + }); + + it("does not cross-match different archive groups", () => { + const items = makeItems([ + "Episode.S01E01.part1.rar", + "Episode.S01E01.part2.rar", + "Episode.S01E02.part1.rar", + "Episode.S01E02.part2.rar", + ]); + const result1 = resolveArchiveItemsFromList("Episode.S01E01.part1.rar", items as any); + expect(result1).toHaveLength(2); + expect(result1.every((i: any) => i.fileName.includes("S01E01"))).toBe(true); + + const result2 = resolveArchiveItemsFromList("Episode.S01E02.part1.rar", items as any); + expect(result2).toHaveLength(2); + expect(result2.every((i: any) => i.fileName.includes("S01E02"))).toBe(true); + }); +}); diff --git a/tests/selection.test.ts b/tests/selection.test.ts new file mode 100644 index 0000000..806a230 --- /dev/null +++ b/tests/selection.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, it } from "vitest"; +import { pruneSelection } from "../src/renderer/selection"; +import type { SessionState } from "../src/shared/types"; + +function session(packageIds: string[], itemIds: string[]): Pick { + const packages: Record = {}; + const items: Record = {}; + for (const id of packageIds) packages[id] = {} as never; + for (const id of itemIds) items[id] = {} as never; + return { packages, items }; +} + +describe("pruneSelection", () => { + it("drops ids whose package/item no longer exists", () => { + const sel = new Set(["p1", "i1", "ghost-p", "ghost-i"]); + const next = pruneSelection(sel, session(["p1"], ["i1"])); + expect([...next].sort()).toEqual(["i1", "p1"]); + }); + + it("returns the SAME set instance when nothing changed (no needless re-render)", () => { + const sel = new Set(["p1", "i1"]); + const next = pruneSelection(sel, session(["p1"], ["i1"])); + expect(next).toBe(sel); + }); + + it("returns the same instance for an empty selection", () => { + const sel = new Set(); + expect(pruneSelection(sel, session(["p1"], ["i1"]))).toBe(sel); + }); + + it("prunes everything when the whole session was swapped out", () => { + const sel = new Set(["p1", "i1"]); + const next = pruneSelection(sel, session([], [])); + expect(next.size).toBe(0); + expect(next).not.toBe(sel); + }); + + it("keeps a mixed package+item selection when both survive", () => { + const sel = new Set(["p1", "p2", "i1"]); + const next = pruneSelection(sel, session(["p1", "p2"], ["i1", "i2"])); + expect([...next].sort()).toEqual(["i1", "p1", "p2"]); + expect(next).toBe(sel); // unchanged → same instance + }); +}); diff --git a/tests/self-check.ts b/tests/self-check.ts new file mode 100644 index 0000000..28a46ea --- /dev/null +++ b/tests/self-check.ts @@ -0,0 +1,208 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import http from "node:http"; +import { once } from "node:events"; +import { DownloadManager } from "../src/main/download-manager"; +import { defaultSettings } from "../src/main/constants"; +import { createStoragePaths, emptySession } from "../src/main/storage"; + +function assert(condition: unknown, message: string): void { + if (!condition) { + throw new Error(`Self-check fehlgeschlagen: ${message}`); + } +} + +async function waitFor(predicate: () => boolean, timeoutMs = 20000): Promise { + const start = Date.now(); + while (!predicate()) { + if (Date.now() - start > timeoutMs) { + throw new Error("Timeout während Self-check"); + } + await new Promise((resolve) => setTimeout(resolve, 100)); + } +} + +async function runDownloadCase(baseDir: string, baseUrl: string, url: string, options?: Partial>): Promise { + const settings = { + ...defaultSettings(), + token: "demo-token", + outputDir: path.join(baseDir, "downloads"), + extractDir: path.join(baseDir, "extract"), + autoExtract: false, + autoReconnect: true, + reconnectWaitSeconds: 1, + ...options + }; + + const manager = new DownloadManager(settings, emptySession(), createStoragePaths(path.join(baseDir, "state"))); + manager.addPackages([ + { + name: "test-package", + links: [url] + } + ]); + manager.start(); + await waitFor(() => !manager.getSnapshot().session.running, 30000); + return manager; +} + +async function main(): Promise { + const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "rd-node-self-")); + const binary = Buffer.alloc(512 * 1024, 7); + let flakyFailures = 1; + + const server = http.createServer((req, res) => { + const url = req.url || "/"; + if (url.startsWith("/file.bin") || url.startsWith("/slow.bin") || url.startsWith("/rarcancel.bin") || url.startsWith("/flaky.bin")) { + if (url.startsWith("/flaky.bin") && flakyFailures > 0) { + flakyFailures -= 1; + res.statusCode = 503; + res.end("retry"); + return; + } + + const range = req.headers.range; + let start = 0; + if (range) { + const match = String(range).match(/bytes=(\d+)-/i); + if (match) { + start = Number(match[1]); + } + } + const chunk = binary.subarray(start); + if (start > 0) { + res.statusCode = 206; + res.setHeader("Content-Range", `bytes ${start}-${binary.length - 1}/${binary.length}`); + } + res.setHeader("Accept-Ranges", "bytes"); + res.setHeader("Content-Length", chunk.length); + res.statusCode = res.statusCode || 200; + + if (url.startsWith("/slow.bin") || url.startsWith("/rarcancel.bin")) { + const mid = Math.floor(chunk.length / 2); + res.write(chunk.subarray(0, mid)); + setTimeout(() => { + res.end(chunk.subarray(mid)); + }, 400); + return; + } + res.end(chunk); + return; + } + + res.statusCode = 404; + res.end("not-found"); + }); + + server.listen(0, "127.0.0.1"); + await once(server, "listening"); + const address = server.address(); + if (!address || typeof address === "string") { + throw new Error("Server konnte nicht gestartet werden"); + } + const baseUrl = `http://127.0.0.1:${address.port}`; + + const originalFetch = globalThis.fetch; + globalThis.fetch = async (input: RequestInfo | URL, init?: RequestInit): Promise => { + const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url; + if (url.includes("/unrestrict/link")) { + const body = init?.body; + const params = body instanceof URLSearchParams ? body : new URLSearchParams(String(body || "")); + const link = params.get("link") || ""; + const filename = link.includes("rarcancel") ? "release.part1.rar" : "file.bin"; + const direct = link.includes("slow") + ? `${baseUrl}/slow.bin` + : link.includes("rarcancel") + ? `${baseUrl}/rarcancel.bin` + : link.includes("flaky") + ? `${baseUrl}/flaky.bin` + : `${baseUrl}/file.bin`; + return new Response( + JSON.stringify({ + download: direct, + filename, + filesize: binary.length + }), + { + status: 200, + headers: { "Content-Type": "application/json" } + } + ); + } + return originalFetch(input, init); + }; + + try { + const manager1 = await runDownloadCase(tempRoot, baseUrl, "https://dummy/file"); + const snapshot1 = manager1.getSnapshot(); + const item1 = Object.values(snapshot1.session.items)[0]; + assert(item1?.status === "completed", "normaler Download wurde nicht abgeschlossen"); + assert(fs.existsSync(item1.targetPath), "Datei fehlt nach Download"); + + const manager2 = new DownloadManager( + { + ...defaultSettings(), + token: "demo-token", + outputDir: path.join(tempRoot, "downloads-pause"), + extractDir: path.join(tempRoot, "extract-pause"), + autoExtract: false, + autoReconnect: false + }, + emptySession(), + createStoragePaths(path.join(tempRoot, "state-pause")) + ); + manager2.addPackages([{ name: "pause", links: ["https://dummy/slow"] }]); + await manager2.start(); + await new Promise((resolve) => setTimeout(resolve, 120)); + const paused = manager2.togglePause(); + assert(paused, "Pause konnte nicht aktiviert werden"); + await new Promise((resolve) => setTimeout(resolve, 150)); + manager2.togglePause(); + await waitFor(() => !manager2.getSnapshot().session.running, 30000); + const item2 = Object.values(manager2.getSnapshot().session.items)[0]; + assert(item2?.status === "completed", "Pause/Resume Download nicht abgeschlossen"); + + const manager3 = await runDownloadCase(tempRoot, baseUrl, "https://dummy/flaky", { autoReconnect: true, reconnectWaitSeconds: 1 }); + const item3 = Object.values(manager3.getSnapshot().session.items)[0]; + assert(item3?.status === "completed", "Reconnect-Fall nicht abgeschlossen"); + + const manager4 = new DownloadManager( + { + ...defaultSettings(), + token: "demo-token", + outputDir: path.join(tempRoot, "downloads-cancel"), + extractDir: path.join(tempRoot, "extract-cancel"), + autoExtract: false + }, + emptySession(), + createStoragePaths(path.join(tempRoot, "state-cancel")) + ); + manager4.addPackages([{ name: "cancel", links: ["https://dummy/rarcancel"] }]); + manager4.start(); + await new Promise((resolve) => setTimeout(resolve, 150)); + const pkgId = manager4.getSnapshot().session.packageOrder[0]; + manager4.cancelPackage(pkgId); + await waitFor(() => !manager4.getSnapshot().session.running || Object.values(manager4.getSnapshot().session.items).every((item) => item.status !== "downloading"), 15000); + const cancelSnapshot = manager4.getSnapshot(); + const remainingItems = Object.values(cancelSnapshot.session.items); + if (remainingItems.length === 0) { + assert(cancelSnapshot.session.packageOrder.length === 0, "Abgebrochenes Paket wurde nicht entfernt"); + } else { + const cancelItem = remainingItems[0]; + assert(cancelItem?.status === "cancelled" || cancelItem?.status === "queued", "Paketabbruch nicht wirksam"); + } + const packageDir = path.join(path.join(tempRoot, "downloads-cancel"), "cancel"); + const cancelArtifact = path.join(packageDir, "release.part1.rar"); + await waitFor(() => !fs.existsSync(cancelArtifact), 10000); + assert(!fs.existsSync(cancelArtifact), "RAR-Artefakt wurde nicht gelöscht"); + + console.log("Node self-check erfolgreich"); + } finally { + globalThis.fetch = originalFetch; + server.close(); + fs.rmSync(tempRoot, { recursive: true, force: true }); + } +} + +void main(); diff --git a/tests/session-log.test.ts b/tests/session-log.test.ts new file mode 100644 index 0000000..38a90e2 --- /dev/null +++ b/tests/session-log.test.ts @@ -0,0 +1,151 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { initSessionLog, getSessionLogPath, shutdownSessionLog } from "../src/main/session-log"; +import { setLogListener } from "../src/main/logger"; + +const tempDirs: string[] = []; + +afterEach(() => { + shutdownSessionLog(); + setLogListener(null); + for (const dir of tempDirs.splice(0)) { + fs.rmSync(dir, { recursive: true, force: true }); + } +}); + +describe("session-log", () => { + it("initSessionLog creates directory and file", () => { + const baseDir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-slog-")); + tempDirs.push(baseDir); + + initSessionLog(baseDir); + const logPath = getSessionLogPath(); + expect(logPath).not.toBeNull(); + expect(fs.existsSync(logPath!)).toBe(true); + expect(fs.existsSync(path.join(baseDir, "session-logs"))).toBe(true); + expect(path.basename(logPath!)).toMatch(/^session_\d{4}-\d{2}-\d{2}_\d{2}-\d{2}-\d{2}\.txt$/); + + const content = fs.readFileSync(logPath!, "utf8"); + expect(content).toContain("=== Session gestartet:"); + + shutdownSessionLog(); + }); + + it("logger listener writes to session log", async () => { + const baseDir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-slog-")); + tempDirs.push(baseDir); + + initSessionLog(baseDir); + const logPath = getSessionLogPath()!; + + const { logger } = await import("../src/main/logger"); + logger.info("Test-Nachricht für Session-Log"); + + await new Promise((resolve) => setTimeout(resolve, 500)); + + const content = fs.readFileSync(logPath, "utf8"); + expect(content).toContain("Test-Nachricht für Session-Log"); + + shutdownSessionLog(); + }); + + it("shutdownSessionLog writes closing line", () => { + const baseDir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-slog-")); + tempDirs.push(baseDir); + + initSessionLog(baseDir); + const logPath = getSessionLogPath()!; + + shutdownSessionLog(); + + const content = fs.readFileSync(logPath, "utf8"); + expect(content).toContain("=== Session beendet:"); + }); + + it("shutdownSessionLog removes listener", async () => { + const baseDir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-slog-")); + tempDirs.push(baseDir); + + initSessionLog(baseDir); + const logPath = getSessionLogPath()!; + + shutdownSessionLog(); + + const { logger } = await import("../src/main/logger"); + logger.info("Nach-Shutdown-Nachricht"); + + await new Promise((resolve) => setTimeout(resolve, 500)); + + const content = fs.readFileSync(logPath, "utf8"); + expect(content).not.toContain("Nach-Shutdown-Nachricht"); + }); + + it("cleanupOldSessionLogs deletes old files", async () => { + const baseDir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-slog-")); + tempDirs.push(baseDir); + + const logsDir = path.join(baseDir, "session-logs"); + fs.mkdirSync(logsDir, { recursive: true }); + + const oldFile = path.join(logsDir, "session_2020-01-01_00-00-00.txt"); + fs.writeFileSync(oldFile, "old session"); + const oldTime = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000); + fs.utimesSync(oldFile, oldTime, oldTime); + + const newFile = path.join(logsDir, "session_2099-01-01_00-00-00.txt"); + fs.writeFileSync(newFile, "new session"); + + initSessionLog(baseDir); + + await new Promise((resolve) => setTimeout(resolve, 300)); + + expect(fs.existsSync(oldFile)).toBe(false); + expect(fs.existsSync(newFile)).toBe(true); + + shutdownSessionLog(); + }); + + it("cleanupOldSessionLogs keeps recent files", async () => { + const baseDir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-slog-")); + tempDirs.push(baseDir); + + const logsDir = path.join(baseDir, "session-logs"); + fs.mkdirSync(logsDir, { recursive: true }); + + const recentFile = path.join(logsDir, "session_2025-12-01_00-00-00.txt"); + fs.writeFileSync(recentFile, "recent session"); + const recentTime = new Date(Date.now() - 2 * 24 * 60 * 60 * 1000); + fs.utimesSync(recentFile, recentTime, recentTime); + + initSessionLog(baseDir); + + await new Promise((resolve) => setTimeout(resolve, 300)); + + expect(fs.existsSync(recentFile)).toBe(true); + + shutdownSessionLog(); + }); + + it("multiple sessions create different files", async () => { + const baseDir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-slog-")); + tempDirs.push(baseDir); + + initSessionLog(baseDir); + const path1 = getSessionLogPath(); + shutdownSessionLog(); + + await new Promise((resolve) => setTimeout(resolve, 1100)); + + initSessionLog(baseDir); + const path2 = getSessionLogPath(); + shutdownSessionLog(); + + expect(path1).not.toBeNull(); + expect(path2).not.toBeNull(); + expect(path1).not.toBe(path2); + expect(fs.existsSync(path1!)).toBe(true); + expect(fs.existsSync(path2!)).toBe(true); + }); +}); diff --git a/tests/session-restart-loss.test.ts b/tests/session-restart-loss.test.ts new file mode 100644 index 0000000..39bb814 Binary files /dev/null and b/tests/session-restart-loss.test.ts differ diff --git a/tests/startup-health-check.test.ts b/tests/startup-health-check.test.ts new file mode 100644 index 0000000..7cb553d --- /dev/null +++ b/tests/startup-health-check.test.ts @@ -0,0 +1,134 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { defaultSettings } from "../src/main/constants"; +import { createStoragePaths } from "../src/main/storage"; +import { runStartupHealthCheck } from "../src/main/startup-health-check"; + +const tempDirs: string[] = []; + +afterEach(() => { + for (const dir of tempDirs.splice(0)) { + try { + fs.rmSync(dir, { recursive: true, force: true }); + } catch { + } + } +}); + +function makeTempBase(): { baseDir: string; outputDir: string; paths: ReturnType } { + const baseDir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-health-")); + tempDirs.push(baseDir); + const outputDir = path.join(baseDir, "downloads"); + fs.mkdirSync(outputDir, { recursive: true }); + return { + baseDir: path.join(baseDir, "runtime"), + outputDir, + paths: createStoragePaths(path.join(baseDir, "runtime")) + }; +} + +describe("runStartupHealthCheck", () => { + it("flags missing download directory", () => { + const { outputDir, paths } = makeTempBase(); + fs.mkdirSync(paths.baseDir, { recursive: true }); + + const settings = { + ...defaultSettings(), + token: "rd-token", + outputDir: path.join(outputDir, "does-not-exist-subdir") + }; + const report = runStartupHealthCheck(settings, paths); + const codes = report.findings.map((f) => f.code); + expect(codes).toContain("outputDir_not_found"); + }); + + it("flags no-provider-configured when all credentials are empty", () => { + const { outputDir, paths } = makeTempBase(); + fs.mkdirSync(paths.baseDir, { recursive: true }); + + const settings = { + ...defaultSettings(), + token: "", + megaLogin: "", + megaPassword: "", + megaCredentials: "", + allDebridToken: "", + bestToken: "", + oneFichierApiKey: "", + debridLinkApiKeys: "", + outputDir + }; + const report = runStartupHealthCheck(settings, paths); + const codes = report.findings.map((f) => f.code); + expect(codes).toContain("no_provider_configured"); + expect(report.warnCount).toBeGreaterThanOrEqual(1); + }); + + it("reports configured providers when at least one credential is set", () => { + const { outputDir, paths } = makeTempBase(); + fs.mkdirSync(paths.baseDir, { recursive: true }); + + const settings = { + ...defaultSettings(), + token: "rd-token-here", + debridLinkApiKeys: "dl-key-a\ndl-key-b", + outputDir + }; + const report = runStartupHealthCheck(settings, paths); + const providersFinding = report.findings.find((f) => f.code === "providers_configured"); + expect(providersFinding).toBeDefined(); + expect(providersFinding?.message).toContain("Real-Debrid"); + expect(providersFinding?.message).toContain("Debrid-Link"); + expect(providersFinding?.message).toContain("2 Keys"); + }); + + it("flags large state files", () => { + const { outputDir, paths } = makeTempBase(); + fs.mkdirSync(paths.baseDir, { recursive: true }); + fs.writeFileSync(paths.sessionFile, Buffer.alloc(60 * 1024 * 1024, 0)); + + const settings = { + ...defaultSettings(), + token: "rd-token", + outputDir + }; + const report = runStartupHealthCheck(settings, paths); + const codes = report.findings.map((f) => f.code); + expect(codes).toContain("large_state_file"); + }); + + it("flags missing base dir as ERROR", () => { + const { outputDir, paths } = makeTempBase(); + + const settings = { + ...defaultSettings(), + token: "rd-token", + outputDir + }; + const report = runStartupHealthCheck(settings, paths); + const codes = report.findings.map((f) => f.code); + expect(codes).toContain("baseDir_missing"); + expect(report.errorCount).toBeGreaterThanOrEqual(1); + }); + + it("passes cleanly when everything is healthy", () => { + const { outputDir, paths } = makeTempBase(); + fs.mkdirSync(paths.baseDir, { recursive: true }); + + const settings = { + ...defaultSettings(), + token: "rd-token-here", + outputDir + }; + const report = runStartupHealthCheck(settings, paths); + expect(report.errorCount).toBe(0); + const codes = report.findings.map((f) => f.code); + expect(codes).not.toContain("outputDir_not_found"); + expect(codes).not.toContain("outputDir_not_writable"); + expect(codes).not.toContain("no_provider_configured"); + expect(codes).not.toContain("baseDir_missing"); + expect(codes).not.toContain("baseDir_not_writable"); + }); +}); diff --git a/tests/storage.test.ts b/tests/storage.test.ts new file mode 100644 index 0000000..a4ba8ff --- /dev/null +++ b/tests/storage.test.ts @@ -0,0 +1,867 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { parseDebridLinkApiKeys } from "../src/shared/debrid-link-keys"; +import { getProviderUsageDayKey } from "../src/shared/provider-daily-limits"; +import { AppSettings } from "../src/shared/types"; +import { defaultSettings } from "../src/main/constants"; +import { addHistoryEntryForRetention, createStoragePaths, emptySession, loadHistory, loadHistoryForRetention, loadSession, loadSettings, normalizeSettings, resetHistoryForRetention, saveHistory, saveSession, saveSessionAsync, saveSettings } from "../src/main/storage"; + +const tempDirs: string[] = []; + +afterEach(() => { + for (const dir of tempDirs.splice(0)) { + fs.rmSync(dir, { recursive: true, force: true }); + } +}); + +describe("settings storage", () => { + it("does not persist provider credentials when rememberToken is disabled", () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-store-")); + tempDirs.push(dir); + const paths = createStoragePaths(dir); + + saveSettings(paths, { + ...defaultSettings(), + rememberToken: false, + token: "rd-token", + megaLogin: "mega-user", + megaPassword: "mega-pass", + bestToken: "best-token", + allDebridToken: "all-token" + }); + + const raw = JSON.parse(fs.readFileSync(paths.configFile, "utf8")) as Record; + expect(raw.token).toBe(""); + expect(raw.megaLogin).toBe(""); + expect(raw.megaPassword).toBe(""); + expect(raw.bestToken).toBe(""); + expect(raw.allDebridToken).toBe(""); + + const loaded = loadSettings(paths); + expect(loaded.rememberToken).toBe(false); + expect(loaded.token).toBe(""); + expect(loaded.megaLogin).toBe(""); + expect(loaded.megaPassword).toBe(""); + expect(loaded.bestToken).toBe(""); + expect(loaded.allDebridToken).toBe(""); + }); + + it("persists provider credentials when rememberToken is enabled", () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-store-")); + tempDirs.push(dir); + const paths = createStoragePaths(dir); + + saveSettings(paths, { + ...defaultSettings(), + rememberToken: true, + token: "rd-token", + megaLogin: "mega-user", + megaPassword: "mega-pass", + bestToken: "best-token", + allDebridToken: "all-token" + }); + + const loaded = loadSettings(paths); + expect(loaded.token).toBe("rd-token"); + expect(loaded.megaLogin).toBe("mega-user"); + expect(loaded.megaPassword).toBe("mega-pass"); + expect(loaded.bestToken).toBe("best-token"); + expect(loaded.allDebridToken).toBe("all-token"); + }); + + it("normalizes invalid enum and numeric values", () => { + const normalized = normalizeSettings({ + ...defaultSettings(), + providerPrimary: "invalid-provider" as unknown as AppSettings["providerPrimary"], + providerSecondary: "invalid-provider" as unknown as AppSettings["providerSecondary"], + providerTertiary: "invalid-provider" as unknown as AppSettings["providerTertiary"], + cleanupMode: "broken" as unknown as AppSettings["cleanupMode"], + extractConflictMode: "broken" as unknown as AppSettings["extractConflictMode"], + completedCleanupPolicy: "broken" as unknown as AppSettings["completedCleanupPolicy"], + speedLimitMode: "broken" as unknown as AppSettings["speedLimitMode"], + maxParallel: 0, + retryLimit: 999, + reconnectWaitSeconds: 9999, + speedLimitKbps: -1, + outputDir: " ", + extractDir: " ", + mkvLibraryDir: " ", + updateRepo: " " + }); + + expect(normalized.providerPrimary).toBe("realdebrid"); + expect(normalized.providerSecondary).toBe("none"); + expect(normalized.providerTertiary).toBe("none"); + expect(normalized.cleanupMode).toBe("none"); + expect(normalized.extractConflictMode).toBe("overwrite"); + expect(normalized.completedCleanupPolicy).toBe("never"); + expect(normalized.speedLimitMode).toBe("global"); + expect(normalized.maxParallel).toBe(1); + expect(normalized.retryLimit).toBe(99); + expect(normalized.reconnectWaitSeconds).toBe(600); + expect(normalized.speedLimitKbps).toBe(0); + expect(normalized.outputDir).toBe(defaultSettings().outputDir); + expect(normalized.extractDir).toBe(defaultSettings().extractDir); + expect(normalized.mkvLibraryDir).toBe(defaultSettings().mkvLibraryDir); + expect(normalized.updateRepo).toBe(defaultSettings().updateRepo); + }); + + it("normalizes malformed persisted config on load", () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-store-")); + tempDirs.push(dir); + const paths = createStoragePaths(dir); + + fs.writeFileSync( + paths.configFile, + JSON.stringify({ + providerPrimary: "not-valid", + completedCleanupPolicy: "not-valid", + maxParallel: "999", + retryLimit: "-3", + reconnectWaitSeconds: "1", + speedLimitMode: "not-valid", + updateRepo: "", + autoSortPackagesByProgress: false + }), + "utf8" + ); + + const loaded = loadSettings(paths); + expect(loaded.providerPrimary).toBe("realdebrid"); + expect(loaded.completedCleanupPolicy).toBe("never"); + expect(loaded.maxParallel).toBe(50); + expect(loaded.retryLimit).toBe(0); + expect(loaded.reconnectWaitSeconds).toBe(10); + expect(loaded.speedLimitMode).toBe("global"); + expect(loaded.updateRepo).toBe(defaultSettings().updateRepo); + expect(loaded.autoSortPackagesByProgress).toBe(false); + }); + + it("keeps explicit none as fallback provider choice", () => { + const normalized = normalizeSettings({ + ...defaultSettings(), + providerSecondary: "none", + providerTertiary: "none" + }); + + expect(normalized.providerSecondary).toBe("none"); + expect(normalized.providerTertiary).toBe("none"); + }); + + it("migrates legacy MegaDebrid provider selections to explicit API/Web providers", () => { + const apiNormalized = normalizeSettings({ + ...defaultSettings(), + megaLogin: "mega-user", + megaPassword: "mega-pass", + megaDebridPreferApi: true, + providerPrimary: "megadebrid" as unknown as AppSettings["providerPrimary"], + providerSecondary: "megadebrid" as unknown as AppSettings["providerSecondary"], + disabledProviders: ["megadebrid" as unknown as AppSettings["providerPrimary"]] + }); + + expect(apiNormalized.providerPrimary).toBe("megadebrid-api"); + expect(apiNormalized.providerSecondary).toBe("none"); + expect(apiNormalized.disabledProviders).toEqual(["megadebrid-api", "megadebrid-web"]); + + const webNormalized = normalizeSettings({ + ...defaultSettings(), + megaLogin: "mega-user", + megaPassword: "mega-pass", + megaDebridPreferApi: false, + megaDebridApiEnabled: false, + megaDebridWebEnabled: true, + providerPrimary: "megadebrid" as unknown as AppSettings["providerPrimary"], + hosterRouting: { rapidgator: "megadebrid" as unknown as AppSettings["providerPrimary"] } + }); + + expect(webNormalized.providerPrimary).toBe("megadebrid-web"); + expect(webNormalized.hosterRouting.rapidgator).toBe("megadebrid-web"); + }); + + it("migriert eine pre-v1.6.90-Config (Mega-Creds, beide Enable-Flags fehlen) zu aktiviertem Mega-Debrid statt es still auf false zu setzen", () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-store-")); + tempDirs.push(dir); + const paths = createStoragePaths(dir); + const legacyApi = { + megaLogin: "mega-user", + megaPassword: "mega-pass", + megaDebridPreferApi: true, + providerPrimary: "realdebrid", + providerSecondary: "megadebrid" + }; + fs.writeFileSync(paths.configFile, JSON.stringify(legacyApi), "utf8"); + const loadedApi = loadSettings(paths); + expect(loadedApi.megaDebridApiEnabled).toBe(true); + expect(loadedApi.megaDebridWebEnabled).toBe(false); + + const dir2 = fs.mkdtempSync(path.join(os.tmpdir(), "rd-store-")); + tempDirs.push(dir2); + const paths2 = createStoragePaths(dir2); + const legacyWeb = { + megaLogin: "mega-user", + megaPassword: "mega-pass", + megaDebridPreferApi: false, + providerPrimary: "realdebrid", + providerSecondary: "megadebrid" + }; + fs.writeFileSync(paths2.configFile, JSON.stringify(legacyWeb), "utf8"); + const loadedWeb = loadSettings(paths2); + expect(loadedWeb.megaDebridApiEnabled).toBe(false); + expect(loadedWeb.megaDebridWebEnabled).toBe(true); + }); + + it("re-aktiviert KEINE bewusst deaktivierten Mega-Flags und migriert nicht ohne Mega-Creds", () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-store-")); + tempDirs.push(dir); + const paths = createStoragePaths(dir); + const deliberatelyDisabled = { + megaLogin: "mega-user", + megaPassword: "mega-pass", + megaDebridPreferApi: true, + megaDebridApiEnabled: false, + megaDebridWebEnabled: false + }; + fs.writeFileSync(paths.configFile, JSON.stringify(deliberatelyDisabled), "utf8"); + const loaded = loadSettings(paths); + expect(loaded.megaDebridApiEnabled).toBe(false); + expect(loaded.megaDebridWebEnabled).toBe(false); + + const dir2 = fs.mkdtempSync(path.join(os.tmpdir(), "rd-store-")); + tempDirs.push(dir2); + const paths2 = createStoragePaths(dir2); + const noCreds = { + megaDebridPreferApi: true, + providerPrimary: "realdebrid" + }; + fs.writeFileSync(paths2.configFile, JSON.stringify(noCreds), "utf8"); + const loadedNoCreds = loadSettings(paths2); + expect(loadedNoCreds.megaDebridApiEnabled).toBe(false); + expect(loadedNoCreds.megaDebridWebEnabled).toBe(false); + }); + + it("normalizes provider daily limits and resets stale daily usage", () => { + const [debridLinkKey] = parseDebridLinkApiKeys("dl-key-one"); + const normalized = normalizeSettings({ + ...defaultSettings(), + megaLogin: "mega-user", + megaPassword: "mega-pass", + megaDebridApiEnabled: true, + debridLinkApiKeys: "dl-key-one", + providerDailyLimitBytes: { + realdebrid: 1024, + megadebrid: 2048 + } as AppSettings["providerDailyLimitBytes"], + providerTotalUsageBytes: { + realdebrid: 16384, + megadebrid: 32768 + } as AppSettings["providerTotalUsageBytes"], + debridLinkApiKeyDailyLimitBytes: { + [debridLinkKey.id]: 3072, + stale: 1234 + }, + providerDailyUsageDay: "2001-01-01", + providerDailyUsageBytes: { + realdebrid: 4096, + megadebrid: 8192 + } as AppSettings["providerDailyUsageBytes"], + debridLinkApiKeyDailyUsageBytes: { + [debridLinkKey.id]: 8192, + stale: 9999 + }, + debridLinkApiKeyTotalUsageBytes: { + [debridLinkKey.id]: 12288, + stale: 9999 + } + }); + + expect(normalized.providerDailyLimitBytes.realdebrid).toBe(1024); + expect(normalized.providerDailyLimitBytes["megadebrid-api"]).toBe(2048); + expect(normalized.debridLinkApiKeyDailyLimitBytes).toEqual({ + [debridLinkKey.id]: 3072 + }); + expect(normalized.providerTotalUsageBytes).toEqual({ + realdebrid: 16384, + "megadebrid-api": 32768 + }); + expect(normalized.providerDailyUsageDay).toBe(getProviderUsageDayKey()); + expect(normalized.providerDailyUsageBytes).toEqual({}); + expect(normalized.debridLinkApiKeyDailyUsageBytes).toEqual({}); + expect(normalized.debridLinkApiKeyTotalUsageBytes).toEqual({ + [debridLinkKey.id]: 12288 + }); + }); + + it("normalizes archive password list line endings", () => { + const normalized = normalizeSettings({ + ...defaultSettings(), + archivePasswordList: "one\r\ntwo\r\nthree" + }); + + expect(normalized.archivePasswordList).toBe("one\ntwo\nthree"); + }); + + it("defaults Real-Debrid web login to disabled and normalizes the flag", () => { + expect(defaultSettings().realDebridUseWebLogin).toBe(false); + + const normalizedEnabled = normalizeSettings({ + ...defaultSettings(), + realDebridUseWebLogin: 1 as unknown as boolean + }); + expect(normalizedEnabled.realDebridUseWebLogin).toBe(true); + + const normalizedDisabled = normalizeSettings({ + ...defaultSettings(), + realDebridUseWebLogin: 0 as unknown as boolean + }); + expect(normalizedDisabled.realDebridUseWebLogin).toBe(false); + }); + + it("defaults AllDebrid web login to disabled and normalizes the flag", () => { + expect(defaultSettings().allDebridUseWebLogin).toBe(false); + + const normalizedEnabled = normalizeSettings({ + ...defaultSettings(), + allDebridUseWebLogin: 1 as unknown as boolean + }); + expect(normalizedEnabled.allDebridUseWebLogin).toBe(true); + + const normalizedDisabled = normalizeSettings({ + ...defaultSettings(), + allDebridUseWebLogin: 0 as unknown as boolean + }); + expect(normalizedDisabled.allDebridUseWebLogin).toBe(false); + }); + + it("defaults history retention to permanent and normalizes invalid values", () => { + expect(defaultSettings().historyRetentionMode).toBe("permanent"); + + const normalized = normalizeSettings({ + ...defaultSettings(), + historyRetentionMode: "broken" as unknown as AppSettings["historyRetentionMode"] + }); + + expect(normalized.historyRetentionMode).toBe("permanent"); + }); + + it("skips adding persisted history entries when history retention is never", () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-store-")); + tempDirs.push(dir); + const paths = createStoragePaths(dir); + + const result = addHistoryEntryForRetention(paths, "never", { + id: "hist-1", + name: "ignored", + totalBytes: 1024, + downloadedBytes: 1024, + fileCount: 1, + provider: "realdebrid", + completedAt: Date.now(), + durationSeconds: 12, + status: "completed", + outputDir: path.join(dir, "out"), + urls: ["https://example.com/file.rar"] + }); + + expect(result).toEqual([]); + expect(loadHistory(paths)).toEqual([]); + expect(loadHistoryForRetention(paths, "never")).toEqual([]); + }); + + it("clears persisted history for session retention mode", () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-store-")); + tempDirs.push(dir); + const paths = createStoragePaths(dir); + + saveHistory(paths, [{ + id: "hist-2", + name: "kept", + totalBytes: 2048, + downloadedBytes: 2048, + fileCount: 1, + provider: "realdebrid", + completedAt: Date.now(), + durationSeconds: 20, + status: "completed", + outputDir: path.join(dir, "out"), + urls: ["https://example.com/file2.rar"] + }]); + + resetHistoryForRetention(paths, "session"); + + expect(loadHistory(paths)).toEqual([]); + }); + + it("caps persisted history to the configured maxEntries", () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-store-")); + tempDirs.push(dir); + const paths = createStoragePaths(dir); + const now = Date.now(); + const entries = Array.from({ length: 10 }, (_unused, i) => ({ + id: `h-${i}`, + name: `e${i}`, + totalBytes: 1, + downloadedBytes: 1, + fileCount: 1, + provider: "realdebrid" as const, + completedAt: now - i * 1000, + durationSeconds: 1, + status: "completed" as const, + outputDir: path.join(dir, "out"), + urls: [] + })); + + saveHistory(paths, entries, { maxEntries: 3, maxAgeDays: 0 }); + + const loaded = loadHistory(paths, { maxEntries: 3, maxAgeDays: 0 }); + expect(loaded).toHaveLength(3); + expect(loaded.map((e) => e.id)).toEqual(["h-0", "h-1", "h-2"]); + }); + + it("drops history entries older than maxAgeDays", () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-store-")); + tempDirs.push(dir); + const paths = createStoragePaths(dir); + const now = Date.now(); + const day = 24 * 60 * 60 * 1000; + const fresh = { + id: "fresh", + name: "fresh", + totalBytes: 1, + downloadedBytes: 1, + fileCount: 1, + provider: "realdebrid" as const, + completedAt: now - 2 * day, + durationSeconds: 1, + status: "completed" as const, + outputDir: path.join(dir, "out"), + urls: [] + }; + const old = { ...fresh, id: "old", name: "old", completedAt: now - 40 * day }; + + saveHistory(paths, [fresh, old], { maxEntries: 500, maxAgeDays: 30 }); + + const loaded = loadHistory(paths, { maxEntries: 500, maxAgeDays: 30 }); + expect(loaded.map((e) => e.id)).toEqual(["fresh"]); + }); + + it("assigns and preserves bandwidth schedule ids", () => { + const normalized = normalizeSettings({ + ...defaultSettings(), + bandwidthSchedules: [{ id: "", startHour: 1, endHour: 6, speedLimitKbps: 1024, enabled: true }] + }); + + const generatedId = normalized.bandwidthSchedules[0]?.id; + expect(typeof generatedId).toBe("string"); + expect(generatedId?.length).toBeGreaterThan(0); + + const normalizedAgain = normalizeSettings({ + ...defaultSettings(), + bandwidthSchedules: normalized.bandwidthSchedules + }); + expect(normalizedAgain.bandwidthSchedules[0]?.id).toBe(generatedId); + }); + + it("resets stale active statuses to queued on session load", () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-store-")); + tempDirs.push(dir); + const paths = createStoragePaths(dir); + + const session = emptySession(); + session.packages["pkg1"] = { + id: "pkg1", + name: "Test Package", + outputDir: "/tmp/out", + extractDir: "/tmp/extract", + status: "downloading", + itemIds: ["item1", "item2", "item3", "item4"], + cancelled: false, + enabled: true, + downloadStartedAt: 0, + downloadCompletedAt: 0, + createdAt: Date.now(), + updatedAt: Date.now() + }; + session.items["item1"] = { + id: "item1", + packageId: "pkg1", + url: "https://example.com/file1.rar", + provider: null, + status: "downloading", + retries: 0, + speedBps: 1024, + downloadedBytes: 5000, + totalBytes: 10000, + progressPercent: 50, + fileName: "file1.rar", + targetPath: "/tmp/out/file1.rar", + resumable: true, + attempts: 1, + lastError: "some error", + fullStatus: "", + createdAt: Date.now(), + updatedAt: Date.now() + }; + session.items["item2"] = { + id: "item2", + packageId: "pkg1", + url: "https://example.com/file2.rar", + provider: null, + status: "paused", + retries: 0, + speedBps: 0, + downloadedBytes: 0, + totalBytes: null, + progressPercent: 0, + fileName: "file2.rar", + targetPath: "/tmp/out/file2.rar", + resumable: false, + attempts: 0, + lastError: "", + fullStatus: "", + createdAt: Date.now(), + updatedAt: Date.now() + }; + session.items["item3"] = { + id: "item3", + packageId: "pkg1", + url: "https://example.com/file3.rar", + provider: null, + status: "completed", + retries: 0, + speedBps: 0, + downloadedBytes: 10000, + totalBytes: 10000, + progressPercent: 100, + fileName: "file3.rar", + targetPath: "/tmp/out/file3.rar", + resumable: false, + attempts: 1, + lastError: "", + fullStatus: "", + createdAt: Date.now(), + updatedAt: Date.now() + }; + session.items["item4"] = { + id: "item4", + packageId: "pkg1", + url: "https://example.com/file4.rar", + provider: null, + status: "queued", + retries: 0, + speedBps: 0, + downloadedBytes: 0, + totalBytes: null, + progressPercent: 0, + fileName: "file4.rar", + targetPath: "/tmp/out/file4.rar", + resumable: false, + attempts: 0, + lastError: "", + fullStatus: "", + createdAt: Date.now(), + updatedAt: Date.now() + }; + + saveSession(paths, session); + const loaded = loadSession(paths); + + expect(loaded.items["item1"].status).toBe("queued"); + expect(loaded.items["item2"].status).toBe("queued"); + expect(loaded.items["item1"].speedBps).toBe(0); + expect(loaded.items["item1"].lastError).toBe(""); + expect(loaded.items["item3"].status).toBe("completed"); + expect(loaded.items["item4"].status).toBe("queued"); + expect(loaded.items["item1"].downloadedBytes).toBe(5000); + expect(loaded.packages["pkg1"].name).toBe("Test Package"); + }); + + it("returns empty session when session file contains invalid JSON", () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-store-")); + tempDirs.push(dir); + const paths = createStoragePaths(dir); + + fs.writeFileSync(paths.sessionFile, "{{{corrupted json!!!", "utf8"); + + const loaded = loadSession(paths); + const empty = emptySession(); + expect(loaded.packages).toEqual(empty.packages); + expect(loaded.items).toEqual(empty.items); + expect(loaded.packageOrder).toEqual(empty.packageOrder); + }); + + it("loads backup session when primary session is corrupted", () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-store-")); + tempDirs.push(dir); + const paths = createStoragePaths(dir); + + const backupSession = emptySession(); + backupSession.packageOrder = ["pkg-backup"]; + backupSession.packages["pkg-backup"] = { + id: "pkg-backup", + name: "Backup Package", + outputDir: path.join(dir, "out"), + extractDir: path.join(dir, "extract"), + status: "queued", + itemIds: ["item-backup"], + cancelled: false, + enabled: true, + downloadStartedAt: 0, + downloadCompletedAt: 0, + createdAt: Date.now(), + updatedAt: Date.now() + }; + backupSession.items["item-backup"] = { + id: "item-backup", + packageId: "pkg-backup", + url: "https://example.com/backup-file", + provider: null, + status: "queued", + retries: 0, + speedBps: 0, + downloadedBytes: 0, + totalBytes: null, + progressPercent: 0, + fileName: "backup-file.rar", + targetPath: path.join(dir, "out", "backup-file.rar"), + resumable: true, + attempts: 0, + lastError: "", + fullStatus: "Wartet", + createdAt: Date.now(), + updatedAt: Date.now() + }; + + fs.writeFileSync(`${paths.sessionFile}.bak`, JSON.stringify(backupSession), "utf8"); + fs.writeFileSync(paths.sessionFile, "{broken-session-json", "utf8"); + + const loaded = loadSession(paths); + expect(loaded.packageOrder).toEqual(["pkg-backup"]); + expect(loaded.packages["pkg-backup"]?.name).toBe("Backup Package"); + expect(loaded.items["item-backup"]?.fileName).toBe("backup-file.rar"); + + const restoredPrimary = JSON.parse(fs.readFileSync(paths.sessionFile, "utf8")) as { packages?: Record }; + expect(restoredPrimary.packages && "pkg-backup" in restoredPrimary.packages).toBe(true); + }); + + it("returns defaults when config file contains invalid JSON", () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-store-")); + tempDirs.push(dir); + const paths = createStoragePaths(dir); + + fs.writeFileSync(paths.configFile, "{{{{not valid json!!!}", "utf8"); + + const loaded = loadSettings(paths); + const defaults = defaultSettings(); + expect(loaded.providerPrimary).toBe(defaults.providerPrimary); + expect(loaded.maxParallel).toBe(defaults.maxParallel); + expect(loaded.retryLimit).toBe(defaults.retryLimit); + expect(loaded.outputDir).toBe(defaults.outputDir); + expect(loaded.cleanupMode).toBe(defaults.cleanupMode); + }); + + it("loads backup config when primary config is corrupted", () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-store-")); + tempDirs.push(dir); + const paths = createStoragePaths(dir); + + const backupSettings = { + ...defaultSettings(), + outputDir: path.join(dir, "backup-output"), + packageName: "from-backup" + }; + fs.writeFileSync(`${paths.configFile}.bak`, JSON.stringify(backupSettings, null, 2), "utf8"); + fs.writeFileSync(paths.configFile, "{broken-json", "utf8"); + + const loaded = loadSettings(paths); + expect(loaded.outputDir).toBe(backupSettings.outputDir); + expect(loaded.packageName).toBe("from-backup"); + }); + + it("sanitizes malformed persisted session structures", () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-store-")); + tempDirs.push(dir); + const paths = createStoragePaths(dir); + + fs.writeFileSync(paths.sessionFile, JSON.stringify({ + version: "invalid", + packageOrder: [123, "pkg-valid"], + packages: { + "1": "bad-entry", + "pkg-valid": { + id: "pkg-valid", + name: "Valid Package", + outputDir: "C:/tmp/out", + extractDir: "C:/tmp/extract", + status: "downloading", + itemIds: ["item-valid", 123], + cancelled: false, + enabled: true + } + }, + items: { + "item-valid": { + id: "item-valid", + packageId: "pkg-valid", + url: "https://example.com/file", + status: "queued", + fileName: "file.bin", + targetPath: "C:/tmp/out/file.bin" + }, + "item-bad": "broken" + } + }), "utf8"); + + const loaded = loadSession(paths); + expect(Object.keys(loaded.packages)).toEqual(["pkg-valid"]); + expect(Object.keys(loaded.items)).toEqual(["item-valid"]); + expect(loaded.packageOrder).toEqual(["pkg-valid"]); + }); + + it("drops unsafe session ids and target paths outside the package output directory", () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-store-")); + tempDirs.push(dir); + const paths = createStoragePaths(dir); + const outputDir = path.join(dir, "downloads", "safe"); + const safeTargetPath = path.join(outputDir, "safe.bin"); + const outsideTargetPath = path.join(dir, "outside.bin"); + + fs.writeFileSync(paths.sessionFile, JSON.stringify({ + version: 2, + packageOrder: ["pkg-safe", "../pkg-evil"], + packages: { + "pkg-safe": { + id: "pkg-safe", + name: "Safe Package", + outputDir, + extractDir: path.join(dir, "extract", "safe"), + status: "queued", + itemIds: ["item-safe", "item-outside", "../item-evil"], + cancelled: false, + enabled: true + }, + "../pkg-evil": { + id: "../pkg-evil", + name: "Unsafe Package", + outputDir, + extractDir: path.join(dir, "extract", "unsafe"), + status: "queued", + itemIds: ["item-evil"], + cancelled: false, + enabled: true + } + }, + items: { + "item-safe": { + id: "item-safe", + packageId: "pkg-safe", + url: "https://example.com/safe", + status: "queued", + fileName: "safe.bin", + targetPath: safeTargetPath + }, + "item-outside": { + id: "item-outside", + packageId: "pkg-safe", + url: "https://example.com/outside", + status: "queued", + fileName: "outside.bin", + targetPath: outsideTargetPath + }, + "../item-evil": { + id: "../item-evil", + packageId: "pkg-safe", + url: "https://example.com/evil", + status: "queued", + fileName: "evil.bin", + targetPath: safeTargetPath + } + } + }), "utf8"); + + const loaded = loadSession(paths); + expect(Object.keys(loaded.packages)).toEqual(["pkg-safe"]); + expect(Object.keys(loaded.items).sort()).toEqual(["item-outside", "item-safe"]); + expect(loaded.packageOrder).toEqual(["pkg-safe"]); + expect(path.resolve(loaded.items["item-safe"]?.targetPath || "")).toBe(path.resolve(safeTargetPath)); + expect(loaded.items["item-outside"]?.targetPath).toBe(""); + }); + + it("captures async session save payload before later mutations", async () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-store-")); + tempDirs.push(dir); + const paths = createStoragePaths(dir); + + const session = emptySession(); + session.summaryText = "before-mutation"; + + const pending = saveSessionAsync(paths, session); + session.summaryText = "after-mutation"; + await pending; + + const persisted = JSON.parse(fs.readFileSync(paths.sessionFile, "utf8")) as { summaryText: string }; + expect(persisted.summaryText).toBe("before-mutation"); + }); + + it("creates session backup before sync and async session overwrites", async () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-store-")); + tempDirs.push(dir); + const paths = createStoragePaths(dir); + + const first = emptySession(); + first.summaryText = "first"; + saveSession(paths, first); + + const second = emptySession(); + second.summaryText = "second"; + saveSession(paths, second); + + const backupAfterSync = JSON.parse(fs.readFileSync(`${paths.sessionFile}.bak`, "utf8")) as { summaryText?: string }; + expect(backupAfterSync.summaryText).toBe("first"); + + const third = emptySession(); + third.summaryText = "third"; + await saveSessionAsync(paths, third); + + const backupAfterAsync = JSON.parse(fs.readFileSync(`${paths.sessionFile}.bak`, "utf8")) as { summaryText?: string }; + const primaryAfterAsync = JSON.parse(fs.readFileSync(paths.sessionFile, "utf8")) as { summaryText?: string }; + expect(backupAfterAsync.summaryText).toBe("second"); + expect(primaryAfterAsync.summaryText).toBe("third"); + }); + + it("applies defaults for missing fields when loading old config", () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-store-")); + tempDirs.push(dir); + const paths = createStoragePaths(dir); + + fs.writeFileSync( + paths.configFile, + JSON.stringify({ + token: "my-token", + rememberToken: true, + outputDir: "/custom/output" + }), + "utf8" + ); + + const loaded = loadSettings(paths); + const defaults = defaultSettings(); + + expect(loaded.token).toBe("my-token"); + expect(loaded.outputDir).toBe(path.resolve("/custom/output")); + + expect(loaded.autoProviderFallback).toBe(defaults.autoProviderFallback); + expect(loaded.hybridExtract).toBe(defaults.hybridExtract); + expect(loaded.completedCleanupPolicy).toBe(defaults.completedCleanupPolicy); + expect(loaded.speedLimitMode).toBe(defaults.speedLimitMode); + expect(loaded.clipboardWatch).toBe(defaults.clipboardWatch); + expect(loaded.minimizeToTray).toBe(defaults.minimizeToTray); + expect(loaded.retryLimit).toBe(defaults.retryLimit); + expect(loaded.collectMkvToLibrary).toBe(defaults.collectMkvToLibrary); + expect(loaded.mkvLibraryDir).toBe(defaults.mkvLibraryDir); + expect(loaded.theme).toBe(defaults.theme); + expect(loaded.bandwidthSchedules).toEqual(defaults.bandwidthSchedules); + expect(loaded.updateRepo).toBe(defaults.updateRepo); + }); +}); diff --git a/tests/support-bundle.test.ts b/tests/support-bundle.test.ts new file mode 100644 index 0000000..bc3cf31 --- /dev/null +++ b/tests/support-bundle.test.ts @@ -0,0 +1,74 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import AdmZip from "adm-zip"; +import { afterEach, describe, expect, it } from "vitest"; +import { buildSupportBundle } from "../src/main/support-bundle"; +import type { DownloadManager } from "../src/main/download-manager"; + +const tempDirs: string[] = []; +const legacyManifestFile = ["debug_", "a", "i", "_manifest.json"].join(""); + +afterEach(() => { + for (const dir of tempDirs.splice(0)) { + try { fs.rmSync(dir, { recursive: true, force: true }); } catch { } + } +}); + +function fakeManager(): DownloadManager { + const snapshot = { + stats: {}, + session: { packages: {}, items: {}, packageOrder: [] }, + speedText: "", + etaText: "", + canStart: false, + canStop: false, + canPause: false + }; + return { + getSnapshot: () => snapshot, + getPackageLogPath: () => null, + getItemLogPath: () => null + } as unknown as DownloadManager; +} + +describe("buildSupportBundle (async, non-blocking)", () => { + it("returns a Promise and produces a valid zip with overview + a real on-disk file", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-bundle-")); + tempDirs.push(root); + fs.writeFileSync(path.join(root, "debug_host.txt"), "host-info-test", "utf8"); + fs.writeFileSync(path.join(root, "debug_support_manifest.json"), JSON.stringify({ purpose: "support" }), "utf8"); + fs.writeFileSync(path.join(root, legacyManifestFile), JSON.stringify({ purpose: "legacy" }), "utf8"); + + const promise = buildSupportBundle(fakeManager(), root, { hostDiagnosticsMode: "none" }); + expect(promise).toBeInstanceOf(Promise); + + const buffer = await promise; + expect(Buffer.isBuffer(buffer)).toBe(true); + expect(buffer.length).toBeGreaterThan(0); + + const entries = new AdmZip(buffer).getEntries().map((e) => e.entryName); + expect(entries).toContain("overview/meta.json"); + expect(entries).toContain("overview/settings.json"); + expect(entries).toContain("runtime/debug_host.txt"); + expect(entries).toContain("runtime/debug_support_manifest.json"); + expect(entries).toContain("overview/support-manifest.json"); + expect(entries).not.toContain(`runtime/${legacyManifestFile}`); + expect(entries).not.toContain(["overview/", "a", "i-manifest.json"].join("")); + + const hostEntry = new AdmZip(buffer).getEntry("runtime/debug_host.txt"); + expect(hostEntry?.getData().toString("utf8")).toBe("host-info-test"); + }); + + it("does not block the event loop while building (a concurrent timer still fires)", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-bundle-")); + tempDirs.push(root); + + let timerFired = false; + const timer = setTimeout(() => { timerFired = true; }, 0); + await buildSupportBundle(fakeManager(), root, { hostDiagnosticsMode: "none" }); + clearTimeout(timer); + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(timerFired).toBe(true); + }); +}); diff --git a/tests/trace-log.test.ts b/tests/trace-log.test.ts new file mode 100644 index 0000000..1f89bbe --- /dev/null +++ b/tests/trace-log.test.ts @@ -0,0 +1,90 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { configureLogger, logger } from "../src/main/logger"; +import { getSessionLogPath, initSessionLog, shutdownSessionLog } from "../src/main/session-log"; +import { getTraceConfig, getTraceConfigPath, getTraceLogPath, initTraceLog, logTraceEvent, setTraceEnabled, shutdownTraceLog } from "../src/main/trace-log"; + +const tempDirs: string[] = []; + +afterEach(() => { + shutdownSessionLog(); + shutdownTraceLog(); + for (const dir of tempDirs.splice(0)) { + fs.rmSync(dir, { recursive: true, force: true }); + } +}); + +describe("trace-log", () => { + it("captures main log lines and explicit trace events when enabled", async () => { + const baseDir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-tlog-")); + tempDirs.push(baseDir); + + configureLogger(baseDir); + initTraceLog(baseDir); + initSessionLog(baseDir); + setTraceEnabled(true, "test"); + + logger.info("TRACE-MAIN-CAPTURE"); + logTraceEvent("INFO", "audit", "TRACE-AUDIT-CAPTURE", { source: "test" }); + + await new Promise((resolve) => setTimeout(resolve, 350)); + + const traceLogPath = getTraceLogPath(); + const sessionLogPath = getSessionLogPath(); + const traceConfigPath = getTraceConfigPath(); + expect(traceLogPath).not.toBeNull(); + expect(sessionLogPath).not.toBeNull(); + expect(traceConfigPath).not.toBeNull(); + + const traceContent = fs.readFileSync(traceLogPath!, "utf8"); + expect(traceContent).toContain("Trace-Log Start"); + expect(traceContent).toContain("TRACE-MAIN-CAPTURE"); + expect(traceContent).toContain("TRACE-AUDIT-CAPTURE"); + + const sessionContent = fs.readFileSync(sessionLogPath!, "utf8"); + expect(sessionContent).toContain("TRACE-MAIN-CAPTURE"); + + const traceConfig = getTraceConfig(); + expect(traceConfig.enabled).toBe(true); + expect(traceConfig.autoDisableAt).toBeTruthy(); + expect(JSON.parse(fs.readFileSync(traceConfigPath!, "utf8")).enabled).toBe(true); + }); + + it("auto-disables support trace after the requested duration", async () => { + const baseDir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-tlog-expire-")); + tempDirs.push(baseDir); + + configureLogger(baseDir); + initTraceLog(baseDir); + setTraceEnabled(true, "expire-test", 50); + + await new Promise((resolve) => setTimeout(resolve, 350)); + + const traceConfig = getTraceConfig(); + expect(traceConfig.enabled).toBe(false); + expect(traceConfig.autoDisableAt).toBeNull(); + + const traceLogPath = getTraceLogPath(); + expect(traceLogPath).not.toBeNull(); + const traceContent = fs.readFileSync(traceLogPath!, "utf8"); + expect(traceContent).toContain("Support-Trace automatisch deaktiviert"); + }); + + it("rotates oversized trace logs on startup", () => { + const baseDir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-tlog-rotate-")); + tempDirs.push(baseDir); + + const oversizedPath = path.join(baseDir, "trace.log"); + fs.mkdirSync(baseDir, { recursive: true }); + fs.writeFileSync(oversizedPath, "x".repeat(10 * 1024 * 1024 + 256), "utf8"); + + initTraceLog(baseDir); + + expect(fs.existsSync(oversizedPath)).toBe(true); + expect(fs.existsSync(`${oversizedPath}.old`)).toBe(true); + const currentContent = fs.readFileSync(oversizedPath, "utf8"); + expect(currentContent).toContain("Trace-Log Start"); + }); +}); diff --git a/tests/unrestrict-retry.test.ts b/tests/unrestrict-retry.test.ts new file mode 100644 index 0000000..7a7753c --- /dev/null +++ b/tests/unrestrict-retry.test.ts @@ -0,0 +1,114 @@ +import { describe, expect, it } from "vitest"; +import { transientResolveRetryDelayMs, parseMegaDebridCooldownRetry, parseMegaDebridResetPark, parseMegaDebridSlowLinkRetry } from "../src/main/download-manager"; + +describe("transientResolveRetryDelayMs (fast, bounded retry for transient resolve failures)", () => { + it("starts fast (<= 3s) instead of the 5s..120s exponential", () => { + expect(transientResolveRetryDelayMs(1)).toBeLessThanOrEqual(3000); + }); + + it("ramps gently and caps at 10s", () => { + expect(transientResolveRetryDelayMs(2)).toBeLessThanOrEqual(7000); + expect(transientResolveRetryDelayMs(3)).toBeLessThanOrEqual(10000); + expect(transientResolveRetryDelayMs(10)).toBe(10000); + expect(transientResolveRetryDelayMs(100)).toBe(10000); + }); + + it("never schedules anywhere near the 5s..120s exponential cap", () => { + for (let n = 1; n <= 50; n += 1) { + expect(transientResolveRetryDelayMs(n)).toBeLessThanOrEqual(10000); + expect(transientResolveRetryDelayMs(n)).toBeGreaterThanOrEqual(1000); + } + }); + + it("is monotonic non-decreasing", () => { + let prev = 0; + for (let n = 1; n <= 12; n += 1) { + const d = transientResolveRetryDelayMs(n); + expect(d).toBeGreaterThanOrEqual(prev); + prev = d; + } + }); +}); + +describe("parseMegaDebridCooldownRetry (honor the encoded account-cooldown delay)", () => { + it("parses the encoded delay from a bare mega_debrid_cooldown error", () => { + const r = parseMegaDebridCooldownRetry("mega_debrid_cooldown:20330:Mega-Debrid (Account 2/2, Da******el): Token error"); + expect(r).not.toBeNull(); + expect(r!.delayMs).toBe(20330); + expect(r!.detail).toContain("Mega-Debrid"); + }); + + it("parses it when embedded in the aggregated provider-chain error", () => { + const aggregated = "Unrestrict fehlgeschlagen: Mega-Debrid API: mega_debrid_cooldown:20330:Mega-Debrid (Account 2/2): Token error"; + expect(parseMegaDebridCooldownRetry(aggregated)!.delayMs).toBe(20330); + }); + + it("takes the SOONEST (min) cooldown when several accounts are cooled", () => { + const both = "Mega-Debrid API: mega_debrid_cooldown:116285:web | Mega-Debrid API: mega_debrid_cooldown:20330:api"; + expect(parseMegaDebridCooldownRetry(both)!.delayMs).toBe(20330); + }); + + it("clamps to [1s, 15min]", () => { + expect(parseMegaDebridCooldownRetry("mega_debrid_cooldown:1:x")!.delayMs).toBe(1000); + expect(parseMegaDebridCooldownRetry("mega_debrid_cooldown:99999999:x")!.delayMs).toBe(15 * 60 * 1000); + }); + + it("returns null when there is no mega cooldown marker", () => { + expect(parseMegaDebridCooldownRetry("Datei beim Hoster gerade nicht abrufbar")).toBeNull(); + expect(parseMegaDebridCooldownRetry("debrid_link_cooldown:5000:x")).toBeNull(); + }); + + it("does NOT swallow the until-Tagesreset park token", () => { + expect(parseMegaDebridCooldownRetry("mega_debrid_reset_park:43200000:Alle Accounts bis zum Tagesreset gesperrt")).toBeNull(); + }); +}); + +describe("parseMegaDebridSlowLinkRetry (park only the slow link, never the account)", () => { + it("parses the encoded delay from a slow-link error", () => { + const r = parseMegaDebridSlowLinkRetry("mega_debrid_slow_link:120000:Mega-Debrid (Account 1/1, Su******e3): aborted"); + expect(r).not.toBeNull(); + expect(r!.delayMs).toBe(120000); + expect(r!.detail).toContain("Mega-Debrid"); + }); + + it("parses it when embedded in the aggregated provider-chain error", () => { + const aggregated = "Provider-Kette: Mega-Debrid Web fehlgeschlagen (Error: mega_debrid_slow_link:90000:Mega-Debrid (Account 1/1): aborted)"; + expect(parseMegaDebridSlowLinkRetry(aggregated)!.delayMs).toBe(90000); + }); + + it("clamps to [1s, 15min]", () => { + expect(parseMegaDebridSlowLinkRetry("mega_debrid_slow_link:1:x")!.delayMs).toBe(1000); + expect(parseMegaDebridSlowLinkRetry("mega_debrid_slow_link:99999999:x")!.delayMs).toBe(15 * 60 * 1000); + }); + + it("does not collide with the account-cooldown or reset-park tokens", () => { + expect(parseMegaDebridSlowLinkRetry("mega_debrid_cooldown:20330:x")).toBeNull(); + expect(parseMegaDebridSlowLinkRetry("mega_debrid_reset_park:43200000:x")).toBeNull(); + expect(parseMegaDebridCooldownRetry("mega_debrid_slow_link:120000:x")).toBeNull(); + }); +}); + +describe("parseMegaDebridResetPark (park the item until the Tagesreset, not a ~2min generic retry)", () => { + it("parses the encoded until-reset delay from the park token", () => { + const r = parseMegaDebridResetPark("mega_debrid_reset_park:43200000:Mega-Debrid: Alle Accounts am Tageslimit (bis zum Tagesreset gesperrt)"); + expect(r).not.toBeNull(); + expect(r!.delayMs).toBe(43200000); + expect(r!.detail).toContain("bis zum Tagesreset gesperrt"); + }); + + it("parses it when embedded in the aggregated provider-chain error", () => { + const aggregated = "Unrestrict fehlgeschlagen: Mega-Debrid API: mega_debrid_reset_park:7200000:Alle Accounts bis zum Tagesreset gesperrt"; + expect(parseMegaDebridResetPark(aggregated)!.delayMs).toBe(7200000); + }); + + it("is NOT clamped to the 15min cooldown ceiling (can park multiple hours)", () => { + expect(parseMegaDebridResetPark("mega_debrid_reset_park:21600000:x")!.delayMs).toBe(21600000); + }); + + it("clamps to a sane [1s, 26h] window and rejects junk", () => { + expect(parseMegaDebridResetPark("mega_debrid_reset_park:1:x")!.delayMs).toBe(1000); + expect(parseMegaDebridResetPark("mega_debrid_reset_park:999999999999:x")!.delayMs).toBe(26 * 60 * 60 * 1000); + expect(parseMegaDebridResetPark("mega_debrid_cooldown:20330:x")).toBeNull(); + expect(parseMegaDebridResetPark("kein Token hier")).toBeNull(); + }); +}); diff --git a/tests/update-install-flow.test.ts b/tests/update-install-flow.test.ts new file mode 100644 index 0000000..72bd833 --- /dev/null +++ b/tests/update-install-flow.test.ts @@ -0,0 +1,63 @@ +import { describe, expect, it } from "vitest"; +import { runInstallWithResume, InstallResumeManager } from "../src/main/update-install-flow"; + +function makeManager(running: boolean): InstallResumeManager & { startCalls: number; stopCalls: number; persistCalls: number; sessionRunning: boolean } { + return { + sessionRunning: running, + startCalls: 0, + stopCalls: 0, + persistCalls: 0, + isSessionRunning() { + return this.sessionRunning; + }, + stop() { + this.stopCalls += 1; + this.sessionRunning = false; + }, + persistNowSync() { + this.persistCalls += 1; + }, + async start() { + this.startCalls += 1; + this.sessionRunning = true; + } + }; +} + +describe("runInstallWithResume", () => { + it("resumes a running session when the install returns started:false", async () => { + const m = makeManager(true); + const result = await runInstallWithResume(m, async () => ({ started: false })); + expect(result.started).toBe(false); + expect(m.stopCalls).toBe(1); + expect(m.startCalls).toBe(1); + expect(m.isSessionRunning()).toBe(true); + }); + + it("resumes a running session when the install THROWS, then rethrows", async () => { + const m = makeManager(true); + await expect( + runInstallWithResume(m, async () => { + throw new Error("network down"); + }) + ).rejects.toThrow("network down"); + expect(m.stopCalls).toBe(1); + expect(m.startCalls).toBe(1); + expect(m.isSessionRunning()).toBe(true); + }); + + it("does NOT resume when the install succeeds (started:true) — the app is about to quit", async () => { + const m = makeManager(true); + const result = await runInstallWithResume(m, async () => ({ started: true })); + expect(result.started).toBe(true); + expect(m.startCalls).toBe(0); + expect(m.isSessionRunning()).toBe(false); + }); + + it("does NOT resume when no session was running before the install", async () => { + const m = makeManager(false); + await runInstallWithResume(m, async () => ({ started: false })); + expect(m.stopCalls).toBe(0); + expect(m.startCalls).toBe(0); + }); +}); diff --git a/tests/update-restart-resume.test.ts b/tests/update-restart-resume.test.ts new file mode 100644 index 0000000..161cb99 --- /dev/null +++ b/tests/update-restart-resume.test.ts @@ -0,0 +1,172 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import http from "node:http"; +import { once } from "node:events"; +import { afterEach, describe, expect, it } from "vitest"; +import { DownloadManager } from "../src/main/download-manager"; +import { defaultSettings } from "../src/main/constants"; +import { createStoragePaths, emptySession, loadSession } from "../src/main/storage"; +import { shutdownItemLogs } from "../src/main/item-log"; +import { shutdownPackageLogs } from "../src/main/package-log"; + +const tempDirs: string[] = []; +const originalFetch = globalThis.fetch; + +afterEach(async () => { + globalThis.fetch = originalFetch; + shutdownItemLogs(); + shutdownPackageLogs(); + for (const dir of tempDirs.splice(0)) { + for (let attempt = 0; attempt < 5; attempt += 1) { + try { + fs.rmSync(dir, { recursive: true, force: true }); + break; + } catch { + await new Promise((resolve) => setTimeout(resolve, 80)); + } + } + } +}); + +async function waitFor(predicate: () => boolean, timeoutMs = 20000): Promise { + const started = Date.now(); + while (!predicate()) { + if (Date.now() - started > timeoutMs) { + throw new Error("waitFor timeout"); + } + await new Promise((resolve) => setTimeout(resolve, 50)); + } +} + +async function startTricklingServer(): Promise<{ directUrl: string; stop: () => Promise }> { + const openTimers = new Set(); + const openResponses = new Set(); + const server = http.createServer((req, res) => { + if ((req.url || "") !== "/direct") { + res.statusCode = 404; + res.end("not-found"); + return; + } + res.statusCode = 200; + res.setHeader("Accept-Ranges", "bytes"); + res.setHeader("Content-Length", String(64 * 1024 * 1024)); + openResponses.add(res); + res.write(Buffer.alloc(64 * 1024, 7)); + const timer = setInterval(() => { + try { + res.write(Buffer.alloc(16 * 1024, 9)); + } catch { + } + }, 100); + openTimers.add(timer); + res.on("close", () => { + clearInterval(timer); + openTimers.delete(timer); + openResponses.delete(res); + }); + }); + server.listen(0, "127.0.0.1"); + await once(server, "listening"); + const address = server.address(); + if (!address || typeof address === "string") { + throw new Error("server address unavailable"); + } + const directUrl = `http://127.0.0.1:${address.port}/direct`; + const stop = async (): Promise => { + for (const timer of openTimers) { + clearInterval(timer); + } + openTimers.clear(); + for (const res of openResponses) { + try { + res.destroy(); + } catch { + } + } + openResponses.clear(); + server.close(); + await once(server, "close"); + }; + return { directUrl, stop }; +} + +function mockUnrestrict(directUrl: string): void { + globalThis.fetch = async (input: RequestInfo | URL, init?: RequestInit): Promise => { + const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url; + if (url.includes("/unrestrict/link")) { + return new Response( + JSON.stringify({ download: directUrl, filename: "episode.mkv", filesize: 64 * 1024 * 1024 }), + { status: 200, headers: { "Content-Type": "application/json" } } + ); + } + return originalFetch(input, init); + }; +} + +async function driveActiveDownload(root: string): Promise<{ manager: DownloadManager; paths: ReturnType; serverStop: () => Promise }> { + const { directUrl, stop: serverStop } = await startTricklingServer(); + mockUnrestrict(directUrl); + const paths = createStoragePaths(path.join(root, "state")); + const manager = new DownloadManager( + { + ...defaultSettings(), + token: "rd-token", + outputDir: path.join(root, "downloads"), + extractDir: path.join(root, "extract"), + autoExtract: false, + autoReconnect: false, + retryLimit: 0 + }, + emptySession(), + paths + ); + manager.addPackages([{ name: "park", links: ["https://dummy/park"] }]); + await manager.start(); + await waitFor(() => { + const item = Object.values(manager.getSnapshot().session.items)[0]; + return item?.status === "downloading" && (manager as unknown as { activeTasks: Map }).activeTasks.size > 0; + }); + return { manager, paths, serverStop }; +} + +describe("update restart resume", () => { + it("characterization: a plain stop() leaves an in-flight item cancelled across a restart", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-update-resume-")); + tempDirs.push(root); + const { manager, paths, serverStop } = await driveActiveDownload(root); + try { + manager.stop(); + manager.persistNowSync(); + await waitFor(() => (manager as unknown as { activeTasks: Map }).activeTasks.size === 0); + manager.prepareForShutdown(); + + const reloaded = loadSession(paths); + const item = Object.values(reloaded.items)[0]; + expect(item).toBeTruthy(); + expect(item.status).toBe("cancelled"); + } finally { + await serverStop(); + } + }); + + it("parks an in-flight item as queued for an update restart so it auto-resumes", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-update-resume-")); + tempDirs.push(root); + const { manager, paths, serverStop } = await driveActiveDownload(root); + try { + manager.stop({ parkForRestart: true }); + manager.persistNowSync(); + await waitFor(() => (manager as unknown as { activeTasks: Map }).activeTasks.size === 0); + manager.prepareForShutdown(); + + const reloaded = loadSession(paths); + const item = Object.values(reloaded.items)[0]; + expect(item).toBeTruthy(); + expect(Object.keys(reloaded.packages).length).toBe(1); + expect(item.status).toBe("queued"); + } finally { + await serverStop(); + } + }); +}); diff --git a/tests/update.test.ts b/tests/update.test.ts new file mode 100644 index 0000000..455fdb7 --- /dev/null +++ b/tests/update.test.ts @@ -0,0 +1,629 @@ +import fs from "node:fs"; +import crypto from "node:crypto"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +const { spawnMock, unrefMock, onceMock } = vi.hoisted(() => { + const unref = vi.fn(); + const once = vi.fn((_event: string, _handler: (...args: unknown[]) => void) => ({ + unref + })); + const spawn = vi.fn(() => ({ + once, + unref + })); + return { + spawnMock: spawn, + unrefMock: unref, + onceMock: once + }; +}); + +vi.mock("node:child_process", () => ({ + spawn: spawnMock +})); + +import { buildInstallerLaunchArgs, checkGitHubUpdate, installLatestUpdate, isRemoteNewer, normalizeUpdateRepo, parseVersionParts } from "../src/main/update"; +import { APP_VERSION } from "../src/main/constants"; +import { UpdateCheckResult, UpdateInstallProgress } from "../src/shared/types"; + +const originalFetch = globalThis.fetch; + +function sha256Hex(buffer: Buffer): string { + return crypto.createHash("sha256").update(buffer).digest("hex"); +} + +function sha512Hex(buffer: Buffer): string { + return crypto.createHash("sha512").update(buffer).digest("hex"); +} + +afterEach(() => { + globalThis.fetch = originalFetch; + spawnMock.mockClear(); + unrefMock.mockClear(); + onceMock.mockClear(); + vi.restoreAllMocks(); +}); + +describe("update", () => { + it("normalizes update repo input", () => { + expect(normalizeUpdateRepo("")).toBe("Sucukdeluxe/multi-debrid-downloader"); + expect(normalizeUpdateRepo("owner/repo")).toBe("owner/repo"); + expect(normalizeUpdateRepo("https://github.com/owner/repo")).toBe("owner/repo"); + expect(normalizeUpdateRepo("https://www.github.com/owner/repo")).toBe("owner/repo"); + expect(normalizeUpdateRepo("https://github.com/owner/repo/releases/tag/v1.2.3")).toBe("owner/repo"); + expect(normalizeUpdateRepo("github.com/owner/repo.git")).toBe("owner/repo"); + expect(normalizeUpdateRepo("git@github.com:owner/repo.git")).toBe("owner/repo"); + }); + + it("uses normalized repo slug for API requests", async () => { + let requestedUrl = ""; + globalThis.fetch = (async (input: RequestInfo | URL): Promise => { + requestedUrl = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url; + return new Response( + JSON.stringify({ + tag_name: `v${APP_VERSION}`, + html_url: "https://github.com/owner/repo/releases/tag/v1.0.0", + assets: [] + }), + { + status: 200, + headers: { "Content-Type": "application/json" } + } + ); + }) as typeof fetch; + + const result = await checkGitHubUpdate("https://github.com/owner/repo/releases"); + expect(requestedUrl).toBe("https://api.github.com/repos/owner/repo/releases/latest"); + expect(result.currentVersion).toBe(APP_VERSION); + expect(result.latestVersion).toBe(APP_VERSION); + expect(result.updateAvailable).toBe(false); + }); + + it("picks setup executable asset from release list", async () => { + globalThis.fetch = (async (): Promise => new Response( + JSON.stringify({ + tag_name: "v9.9.9", + html_url: "https://github.com/owner/repo/releases/tag/v9.9.9", + assets: [ + { + name: "Real-Debrid-Downloader-9.9.9-portable.exe", + browser_download_url: "https://example.invalid/portable.exe" + }, + { + name: "Real-Debrid-Downloader-Setup-9.9.9.exe", + browser_download_url: "https://example.invalid/setup.exe", + digest: "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + } + ] + }), + { + status: 200, + headers: { "Content-Type": "application/json" } + } + )) as typeof fetch; + + const result = await checkGitHubUpdate("owner/repo"); + expect(result.updateAvailable).toBe(true); + expect(result.setupAssetUrl).toBe("https://example.invalid/setup.exe"); + expect(result.setupAssetName).toBe("Real-Debrid-Downloader-Setup-9.9.9.exe"); + }); + + it("uses silent NSIS install flags with auto-run after update", () => { + expect(buildInstallerLaunchArgs()).toEqual(["/S", "--updated", "--force-run"]); + }); + + it("falls back to alternate download URL when setup asset URL returns 404", async () => { + const executablePayload = fs.readFileSync(process.execPath); + const executableDigest = sha256Hex(executablePayload); + const requestedUrls: string[] = []; + globalThis.fetch = (async (input: RequestInfo | URL): Promise => { + const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url; + requestedUrls.push(url); + + if (url.includes("stale-setup.exe")) { + return new Response("missing", { status: 404 }); + } + if (url.includes("/releases/download/v9.9.9/")) { + return new Response(executablePayload, { + status: 200, + headers: { "Content-Type": "application/octet-stream" } + }); + } + return new Response("missing", { status: 404 }); + }) as typeof fetch; + + const prechecked: UpdateCheckResult = { + updateAvailable: true, + currentVersion: APP_VERSION, + latestVersion: "9.9.9", + latestTag: "v9.9.9", + releaseUrl: "https://github.com/owner/repo/releases/tag/v9.9.9", + setupAssetUrl: "https://example.invalid/stale-setup.exe", + setupAssetName: "Real-Debrid-Downloader-Setup-9.9.9.exe", + setupAssetDigest: `sha256:${executableDigest}` + }; + + const result = await installLatestUpdate("owner/repo", prechecked); + expect(result.started).toBe(true); + expect(requestedUrls.some((url) => url.includes("/releases/download/v9.9.9/"))).toBe(true); + expect(requestedUrls.filter((url) => url.includes("stale-setup.exe"))).toHaveLength(1); + }); + + it("skips draft tag payload and resolves setup asset from stable latest release", async () => { + const executablePayload = fs.readFileSync(process.execPath); + const requestedUrls: string[] = []; + + globalThis.fetch = (async (input: RequestInfo | URL): Promise => { + const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url; + requestedUrls.push(url); + + if (url.endsWith("/releases/tags/v9.9.9")) { + return new Response(JSON.stringify({ + tag_name: "v9.9.9", + draft: true, + prerelease: false, + assets: [ + { + name: "Draft Setup 9.9.9.exe", + browser_download_url: "https://example.invalid/draft-setup.exe" + } + ] + }), { + status: 200, + headers: { "Content-Type": "application/json" } + }); + } + + if (url.endsWith("/releases/latest")) { + const stableDigest = sha256Hex(executablePayload); + return new Response(JSON.stringify({ + tag_name: "v9.9.9", + draft: false, + prerelease: false, + assets: [ + { + name: "Stable Setup 9.9.9.exe", + browser_download_url: "https://example.invalid/stable-setup.exe", + digest: `sha256:${stableDigest}` + } + ] + }), { + status: 200, + headers: { "Content-Type": "application/json" } + }); + } + + if (url.includes("stable-setup.exe")) { + return new Response(executablePayload, { + status: 200, + headers: { "Content-Type": "application/octet-stream" } + }); + } + + return new Response("missing", { status: 404 }); + }) as typeof fetch; + + const prechecked: UpdateCheckResult = { + updateAvailable: true, + currentVersion: APP_VERSION, + latestVersion: "9.9.9", + latestTag: "v9.9.9", + releaseUrl: "https://github.com/owner/repo/releases/tag/v9.9.9", + setupAssetUrl: "", + setupAssetName: "" + }; + + const result = await installLatestUpdate("owner/repo", prechecked); + expect(result.started).toBe(true); + expect(requestedUrls.some((url) => url.endsWith("/releases/tags/v9.9.9"))).toBe(true); + expect(requestedUrls.some((url) => url.endsWith("/releases/latest"))).toBe(true); + expect(requestedUrls.some((url) => url.includes("stable-setup.exe"))).toBe(true); + expect(requestedUrls.some((url) => url.includes("draft-setup.exe"))).toBe(false); + }); + + it("times out hanging release JSON body reads", async () => { + vi.useFakeTimers(); + try { + const cancelSpy = vi.fn(async () => undefined); + globalThis.fetch = (async (): Promise => ({ + ok: true, + status: 200, + headers: new Headers({ "Content-Type": "application/json" }), + json: () => new Promise(() => undefined), + body: { + cancel: cancelSpy + } + } as unknown as Response)) as typeof fetch; + + const pending = checkGitHubUpdate("owner/repo"); + await vi.advanceTimersByTimeAsync(13000); + const result = await pending; + expect(result.updateAvailable).toBe(false); + expect(String(result.error || "")).toMatch(/timeout/i); + expect(cancelSpy).toHaveBeenCalledTimes(1); + } finally { + vi.useRealTimers(); + } + }); + + it("aborts hanging update body downloads on idle timeout", async () => { + const previousTimeout = process.env.RD_UPDATE_BODY_IDLE_TIMEOUT_MS; + process.env.RD_UPDATE_BODY_IDLE_TIMEOUT_MS = "1000"; + + try { + globalThis.fetch = (async (input: RequestInfo | URL): Promise => { + const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url; + if (url.includes("hang-setup.exe")) { + const body = new ReadableStream({ + start(controller) { + controller.enqueue(new Uint8Array([1, 2, 3])); + } + }); + return new Response(body, { + status: 200, + headers: { "Content-Type": "application/octet-stream" } + }); + } + return new Response("missing", { status: 404 }); + }) as typeof fetch; + + const prechecked: UpdateCheckResult = { + updateAvailable: true, + currentVersion: APP_VERSION, + latestVersion: "9.9.9", + latestTag: "v9.9.9", + releaseUrl: "https://github.com/owner/repo/releases/tag/v9.9.9", + setupAssetUrl: "https://example.invalid/hang-setup.exe", + setupAssetName: "", + setupAssetDigest: "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + }; + + const result = await installLatestUpdate("owner/repo", prechecked); + expect(result.started).toBe(false); + expect(result.message).toMatch(/timeout/i); + } finally { + if (previousTimeout === undefined) { + delete process.env.RD_UPDATE_BODY_IDLE_TIMEOUT_MS; + } else { + process.env.RD_UPDATE_BODY_IDLE_TIMEOUT_MS = previousTimeout; + } + } + }, 20000); + + it("blocks installer start when SHA256 digest mismatches", async () => { + const executablePayload = fs.readFileSync(process.execPath); + globalThis.fetch = (async (input: RequestInfo | URL): Promise => { + const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url; + if (url.includes("mismatch-setup.exe")) { + return new Response(executablePayload, { + status: 200, + headers: { "Content-Type": "application/octet-stream" } + }); + } + return new Response("missing", { status: 404 }); + }) as typeof fetch; + + const prechecked: UpdateCheckResult = { + updateAvailable: true, + currentVersion: APP_VERSION, + latestVersion: "9.9.9", + latestTag: "v9.9.9", + releaseUrl: "https://github.com/owner/repo/releases/tag/v9.9.9", + setupAssetUrl: "https://example.invalid/mismatch-setup.exe", + setupAssetName: "setup.exe", + setupAssetDigest: "sha256:1111111111111111111111111111111111111111111111111111111111111111" + }; + + const result = await installLatestUpdate("owner/repo", prechecked); + expect(result.started).toBe(false); + expect(result.message).toMatch(/integrit|sha256|mismatch/i); + }); + + it("blocks installer start when no digest can be resolved", async () => { + const executablePayload = fs.readFileSync(process.execPath); + globalThis.fetch = (async (input: RequestInfo | URL): Promise => { + const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url; + if (url.includes("unsigned-setup.exe")) { + return new Response(executablePayload, { + status: 200, + headers: { "Content-Type": "application/octet-stream" } + }); + } + return new Response("missing", { status: 404 }); + }) as typeof fetch; + + const prechecked: UpdateCheckResult = { + updateAvailable: true, + currentVersion: APP_VERSION, + latestVersion: "9.9.9", + latestTag: "", + releaseUrl: "https://github.com/owner/repo/releases/tag/v9.9.9", + setupAssetUrl: "https://example.invalid/unsigned-setup.exe", + setupAssetName: "setup.exe", + setupAssetDigest: "" + }; + + const result = await installLatestUpdate("owner/repo", prechecked); + expect(result.started).toBe(false); + expect(result.message).toMatch(/digest|integrit|sha/i); + }); + + it("uses latest.yml SHA512 digest when API asset digest is missing", async () => { + const executablePayload = fs.readFileSync(process.execPath); + const digestSha512Hex = sha512Hex(executablePayload); + const digestSha512Base64 = Buffer.from(digestSha512Hex, "hex").toString("base64"); + const requestedUrls: string[] = []; + + globalThis.fetch = (async (input: RequestInfo | URL): Promise => { + const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url; + requestedUrls.push(url); + + if (url.endsWith("/releases/tags/v9.9.9")) { + return new Response(JSON.stringify({ + tag_name: "v9.9.9", + draft: false, + prerelease: false, + assets: [ + { + name: "Real-Debrid-Downloader-Setup-9.9.9.exe", + browser_download_url: "https://example.invalid/setup-no-digest.exe" + }, + { + name: "latest.yml", + browser_download_url: "https://example.invalid/latest.yml" + } + ] + }), { + status: 200, + headers: { "Content-Type": "application/json" } + }); + } + + if (url.includes("latest.yml")) { + return new Response( + `version: 9.9.9\npath: Real-Debrid-Downloader-Setup-9.9.9.exe\nsha512: ${digestSha512Base64}\n`, + { + status: 200, + headers: { "Content-Type": "text/yaml" } + } + ); + } + + if (url.includes("setup-no-digest.exe")) { + return new Response(executablePayload, { + status: 200, + headers: { + "Content-Type": "application/octet-stream", + "Content-Length": String(executablePayload.length) + } + }); + } + + return new Response("missing", { status: 404 }); + }) as typeof fetch; + + const prechecked: UpdateCheckResult = { + updateAvailable: true, + currentVersion: APP_VERSION, + latestVersion: "9.9.9", + latestTag: "v9.9.9", + releaseUrl: "https://github.com/owner/repo/releases/tag/v9.9.9", + setupAssetUrl: "https://example.invalid/setup-no-digest.exe", + setupAssetName: "Real-Debrid-Downloader-Setup-9.9.9.exe", + setupAssetDigest: "" + }; + + const result = await installLatestUpdate("owner/repo", prechecked); + expect(result.started).toBe(true); + expect(requestedUrls.some((url) => url.endsWith("/releases/tags/v9.9.9"))).toBe(true); + expect(requestedUrls.some((url) => url.includes("latest.yml"))).toBe(true); + }); + + it("rejects installer when latest.yml SHA512 digest does not match", async () => { + const executablePayload = fs.readFileSync(process.execPath); + const wrongDigestBase64 = Buffer.alloc(64, 0x13).toString("base64"); + + globalThis.fetch = (async (input: RequestInfo | URL): Promise => { + const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url; + + if (url.endsWith("/releases/tags/v9.9.9")) { + return new Response(JSON.stringify({ + tag_name: "v9.9.9", + draft: false, + prerelease: false, + assets: [ + { + name: "Real-Debrid-Downloader-Setup-9.9.9.exe", + browser_download_url: "https://example.invalid/setup-no-digest.exe" + }, + { + name: "latest.yml", + browser_download_url: "https://example.invalid/latest.yml" + } + ] + }), { + status: 200, + headers: { "Content-Type": "application/json" } + }); + } + + if (url.includes("latest.yml")) { + return new Response( + `version: 9.9.9\npath: Real-Debrid-Downloader-Setup-9.9.9.exe\nsha512: ${wrongDigestBase64}\n`, + { + status: 200, + headers: { "Content-Type": "text/yaml" } + } + ); + } + + if (url.includes("setup-no-digest.exe")) { + return new Response(executablePayload, { + status: 200, + headers: { + "Content-Type": "application/octet-stream", + "Content-Length": String(executablePayload.length) + } + }); + } + + return new Response("missing", { status: 404 }); + }) as typeof fetch; + + const prechecked: UpdateCheckResult = { + updateAvailable: true, + currentVersion: APP_VERSION, + latestVersion: "9.9.9", + latestTag: "v9.9.9", + releaseUrl: "https://github.com/owner/repo/releases/tag/v9.9.9", + setupAssetUrl: "https://example.invalid/setup-no-digest.exe", + setupAssetName: "Real-Debrid-Downloader-Setup-9.9.9.exe", + setupAssetDigest: "" + }; + + const result = await installLatestUpdate("owner/repo", prechecked); + expect(result.started).toBe(false); + expect(result.message).toMatch(/sha512|integrit|mismatch/i); + }); + + it("emits install progress events while downloading and launching update", async () => { + const executablePayload = fs.readFileSync(process.execPath); + const digest = sha256Hex(executablePayload); + + globalThis.fetch = (async (input: RequestInfo | URL): Promise => { + const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url; + if (url.includes("progress-setup.exe")) { + return new Response(executablePayload, { + status: 200, + headers: { + "Content-Type": "application/octet-stream", + "Content-Length": String(executablePayload.length) + } + }); + } + return new Response("missing", { status: 404 }); + }) as typeof fetch; + + const prechecked: UpdateCheckResult = { + updateAvailable: true, + currentVersion: APP_VERSION, + latestVersion: "9.9.9", + latestTag: "v9.9.9", + releaseUrl: "https://github.com/owner/repo/releases/tag/v9.9.9", + setupAssetUrl: "https://example.invalid/progress-setup.exe", + setupAssetName: "setup.exe", + setupAssetDigest: `sha256:${digest}` + }; + + const progressEvents: UpdateInstallProgress[] = []; + const result = await installLatestUpdate("owner/repo", prechecked, (progress) => { + progressEvents.push(progress); + }); + + expect(result.started).toBe(true); + expect(spawnMock).toHaveBeenCalledWith(expect.any(String), ["/S", "--updated", "--force-run"], expect.objectContaining({ + detached: true, + stdio: "ignore", + windowsHide: true + })); + expect(unrefMock).toHaveBeenCalledTimes(1); + expect(progressEvents.some((entry) => entry.stage === "starting")).toBe(true); + expect(progressEvents.some((entry) => entry.stage === "downloading")).toBe(true); + expect(progressEvents.some((entry) => entry.stage === "verifying")).toBe(true); + expect(progressEvents.some((entry) => entry.stage === "launching")).toBe(true); + expect(progressEvents.some((entry) => entry.stage === "done")).toBe(true); + }); +}); + +describe("normalizeUpdateRepo extended", () => { + it("handles trailing slashes and extra path segments", () => { + expect(normalizeUpdateRepo("owner/repo/")).toBe("owner/repo"); + expect(normalizeUpdateRepo("/owner/repo/")).toBe("owner/repo"); + expect(normalizeUpdateRepo("https://github.com/owner/repo/tree/main/src")).toBe("owner/repo"); + }); + + it("handles ssh-style git URLs", () => { + expect(normalizeUpdateRepo("git@github.com:user/project.git")).toBe("user/project"); + }); + + it("returns default for malformed inputs", () => { + expect(normalizeUpdateRepo("just-one-part")).toBe("Sucukdeluxe/multi-debrid-downloader"); + expect(normalizeUpdateRepo(" ")).toBe("Sucukdeluxe/multi-debrid-downloader"); + }); + + it("rejects traversal-like owner or repo segments", () => { + expect(normalizeUpdateRepo("../owner/repo")).toBe("Sucukdeluxe/multi-debrid-downloader"); + expect(normalizeUpdateRepo("owner/../repo")).toBe("Sucukdeluxe/multi-debrid-downloader"); + expect(normalizeUpdateRepo("https://github.com/owner/../../repo")).toBe("Sucukdeluxe/multi-debrid-downloader"); + }); + + it("handles www prefix", () => { + expect(normalizeUpdateRepo("https://www.github.com/owner/repo")).toBe("owner/repo"); + expect(normalizeUpdateRepo("www.github.com/owner/repo")).toBe("owner/repo"); + }); +}); + +describe("isRemoteNewer", () => { + it("detects newer major version", () => { + expect(isRemoteNewer("1.0.0", "2.0.0")).toBe(true); + }); + + it("detects newer minor version", () => { + expect(isRemoteNewer("1.2.0", "1.3.0")).toBe(true); + }); + + it("detects newer patch version", () => { + expect(isRemoteNewer("1.2.3", "1.2.4")).toBe(true); + }); + + it("returns false for same version", () => { + expect(isRemoteNewer("1.2.3", "1.2.3")).toBe(false); + }); + + it("returns false for older version", () => { + expect(isRemoteNewer("2.0.0", "1.0.0")).toBe(false); + expect(isRemoteNewer("1.3.0", "1.2.0")).toBe(false); + expect(isRemoteNewer("1.2.4", "1.2.3")).toBe(false); + }); + + it("handles versions with different segment counts", () => { + expect(isRemoteNewer("1.2", "1.2.1")).toBe(true); + expect(isRemoteNewer("1.2.1", "1.2")).toBe(false); + expect(isRemoteNewer("1", "1.0.1")).toBe(true); + }); + + it("handles v-prefix in version strings", () => { + expect(isRemoteNewer("v1.0.0", "v2.0.0")).toBe(true); + expect(isRemoteNewer("v1.0.0", "v1.0.0")).toBe(false); + }); +}); + +describe("parseVersionParts", () => { + it("parses standard version strings", () => { + expect(parseVersionParts("1.2.3")).toEqual([1, 2, 3]); + expect(parseVersionParts("10.20.30")).toEqual([10, 20, 30]); + }); + + it("strips v prefix", () => { + expect(parseVersionParts("v1.2.3")).toEqual([1, 2, 3]); + expect(parseVersionParts("V1.2.3")).toEqual([1, 2, 3]); + }); + + it("handles single segment", () => { + expect(parseVersionParts("5")).toEqual([5]); + }); + + it("handles version with pre-release suffix", () => { + expect(parseVersionParts("1.2.3-beta")).toEqual([1, 2, 3]); + expect(parseVersionParts("1.2.3rc1")).toEqual([1, 2, 3]); + }); + + it("handles empty and whitespace", () => { + expect(parseVersionParts("")).toEqual([0]); + expect(parseVersionParts(" ")).toEqual([0]); + }); + + it("handles versions with extra dots", () => { + expect(parseVersionParts("1.2.3.4")).toEqual([1, 2, 3, 4]); + }); +}); diff --git a/tests/utils.test.ts b/tests/utils.test.ts new file mode 100644 index 0000000..86f2759 --- /dev/null +++ b/tests/utils.test.ts @@ -0,0 +1,132 @@ +import { describe, expect, it } from "vitest"; +import { extractHttpLinksFromText, parsePackagesFromLinksText, isHttpLink, sanitizeFilename, formatEta, filenameFromUrl, looksLikeOpaqueFilename } from "../src/main/utils"; + +describe("utils", () => { + it("validates http links", () => { + expect(isHttpLink("https://example.com/file")).toBe(true); + expect(isHttpLink("http://example.com/file")).toBe(true); + expect(isHttpLink("ftp://example.com")).toBe(false); + expect(isHttpLink("foo bar")).toBe(false); + }); + + it("extracts links from text and trims trailing punctuation", () => { + const links = extractHttpLinksFromText("See (https://example.com/test) and https://rapidgator.net/file/abc123, plus https://example.com/a.b."); + expect(links).toEqual([ + "https://example.com/test", + "https://rapidgator.net/file/abc123", + "https://example.com/a.b" + ]); + }); + + it("sanitizes filenames", () => { + expect(sanitizeFilename("foo/bar:baz*")).toBe("foo bar baz"); + expect(sanitizeFilename(" ")).toBe("Paket"); + expect(sanitizeFilename("test\0file.txt")).toBe("testfile.txt"); + expect(sanitizeFilename("\0\0\0")).toBe("Paket"); + expect(sanitizeFilename("..")).toBe("Paket"); + expect(sanitizeFilename(".")).toBe("Paket"); + expect(sanitizeFilename("release... ")).toBe("release"); + expect(sanitizeFilename(" con ")).toBe("con_"); + }); + + it("parses package markers", () => { + const parsed = parsePackagesFromLinksText( + "# package: A\nhttps://a.com/1\nhttps://a.com/2\n# package: B\nhttps://b.com/1\n", + "Default" + ); + expect(parsed).toHaveLength(2); + expect(parsed[0].name).toBe("A"); + expect(parsed[0].links).toHaveLength(2); + expect(parsed[1].name).toBe("B"); + }); + + it("parses optional file markers for roundtrip imports", () => { + const parsed = parsePackagesFromLinksText( + "# rd-link-export: 1\n# package: Dave Staffel 1\n# file: Folge 001.rar\nhttps://a.com/1\n# file: Folge 002.rar\nhttps://a.com/2\n", + "Default" + ); + expect(parsed).toHaveLength(1); + expect(parsed[0].name).toBe("Dave Staffel 1"); + expect(parsed[0].links).toEqual(["https://a.com/1", "https://a.com/2"]); + expect(parsed[0].fileNames).toEqual(["Folge 001.rar", "Folge 002.rar"]); + }); + + it("does not carry a file marker across package boundaries", () => { + const parsed = parsePackagesFromLinksText( + "# package: Dave Staffel 1\n# file: Folge 001.rar\n# package: Dave Staffel 2\nhttps://a.com/2\n", + "Default" + ); + expect(parsed).toHaveLength(1); + expect(parsed[0].name).toBe("Dave Staffel 2"); + expect(parsed[0].links).toEqual(["https://a.com/2"]); + expect(parsed[0].fileNames).toBeUndefined(); + }); + + it("formats eta", () => { + expect(formatEta(-1)).toBe("--"); + expect(formatEta(65)).toBe("01:05"); + expect(formatEta(3661)).toBe("01:01:01"); + }); + + it("normalizes filenames from links", () => { + expect(filenameFromUrl("https://rapidgator.net/file/id/show.part1.rar.html")).toBe("show.part1.rar"); + expect(filenameFromUrl("https://debrid.example/dl/abc?filename=Movie.S01E01.mkv")).toBe("Movie.S01E01.mkv"); + expect(filenameFromUrl("https://debrid.example/dl/%E0%A4%A")).toBe("%E0%A4%A"); + expect(filenameFromUrl("https://debrid.example/dl/e51f6809bb6ca615601f5ac5db433737")).toBe("e51f6809bb6ca615601f5ac5db433737"); + expect(filenameFromUrl("data:text/plain;base64,SGVsbG8=")).toBe("download.bin"); + expect(filenameFromUrl("blob:https://example.com/12345678-1234-1234-1234-1234567890ab")).toBe("download.bin"); + expect(looksLikeOpaqueFilename("download.bin")).toBe(true); + expect(looksLikeOpaqueFilename("e51f6809bb6ca615601f5ac5db433737")).toBe(true); + expect(looksLikeOpaqueFilename("movie.part1.rar")).toBe(false); + }); + + it("preserves unicode filenames", () => { + expect(sanitizeFilename("日本語ファイル.txt")).toBe("日本語ファイル.txt"); + expect(sanitizeFilename("Ünïcödé Tëst.mkv")).toBe("Ünïcödé Tëst.mkv"); + expect(sanitizeFilename("파일이름.rar")).toBe("파일이름.rar"); + expect(sanitizeFilename("файл.zip")).toBe("файл.zip"); + }); + + it("handles very long filenames", () => { + const longName = "a".repeat(300); + const result = sanitizeFilename(longName); + expect(typeof result).toBe("string"); + expect(result.length).toBeGreaterThan(0); + expect(result).toBe(longName); + }); + + it("formats eta with very large values without crashing", () => { + const result = formatEta(999999); + expect(typeof result).toBe("string"); + expect(result.length).toBeGreaterThan(0); + expect(result).toBe("277:46:39"); + }); + + it("formats eta with edge cases", () => { + expect(formatEta(0)).toBe("00:00"); + expect(formatEta(NaN)).toBe("--"); + expect(formatEta(Infinity)).toBe("--"); + expect(formatEta(Number.MAX_SAFE_INTEGER)).toMatch(/^\d+:\d{2}:\d{2}$/); + }); + + it("extracts filenames from URLs with encoded characters", () => { + expect(filenameFromUrl("https://example.com/file%20with%20spaces.rar")).toBe("file with spaces.rar"); + expect(filenameFromUrl("https://example.com/t%C3%A9st%20file.zip")).toBe("t\u00e9st file.zip"); + expect(filenameFromUrl("https://example.com/dl?filename=Movie%20Name%20S01E01.mkv")).toBe("Movie Name S01E01.mkv"); + const result = filenameFromUrl("https://example.com/%ZZ%invalid"); + expect(typeof result).toBe("string"); + expect(result.length).toBeGreaterThan(0); + }); + + it("handles looksLikeOpaqueFilename edge cases", () => { + expect(looksLikeOpaqueFilename("")).toBe(false); + expect(looksLikeOpaqueFilename("a")).toBe(false); + expect(looksLikeOpaqueFilename("ab")).toBe(false); + expect(looksLikeOpaqueFilename("abc")).toBe(false); + expect(looksLikeOpaqueFilename("download.bin")).toBe(true); + expect(looksLikeOpaqueFilename("abcdef123456789012345678")).toBe(true); + expect(looksLikeOpaqueFilename("abcdef1234567890abcdef12")).toBe(true); + expect(looksLikeOpaqueFilename("abcdef12345")).toBe(false); + expect(looksLikeOpaqueFilename("Show.S01E01.720p.mkv")).toBe(false); + }); +}); diff --git a/tests/video-processor.test.ts b/tests/video-processor.test.ts new file mode 100644 index 0000000..0f92054 --- /dev/null +++ b/tests/video-processor.test.ts @@ -0,0 +1,359 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + stripDualLangMarker, + hasDualLangMarker, + isRemuxableVideoFile, + looksLikeGermanRelease, + pickAudioTrack, + parseFfprobeAudioStreams, + buildFfprobeArgs, + buildFfmpegRemuxArgs, + computeRemuxTimeoutMs, + processVideoFile, + renameWithRetry, + type VideoSpawnResult +} from "../src/main/video-processor"; + +describe("stripDualLangMarker", () => { + it("strips a mid-name .DL. token", () => { + expect(stripDualLangMarker("Show.S01E01.German.DL.720p.WEB.x264.mkv")).toBe("Show.S01E01.German.720p.WEB.x264.mkv"); + }); + it("strips a .DL. directly before the extension", () => { + expect(stripDualLangMarker("Movie.DL.mkv")).toBe("Movie.mkv"); + }); + it("strips a trailing .DL token before extension", () => { + expect(stripDualLangMarker("Movie.German.DL.mp4")).toBe("Movie.German.mp4"); + }); + it("is case-insensitive", () => { + expect(stripDualLangMarker("Show.dl.1080p.mkv")).toBe("Show.1080p.mkv"); + }); + it("leaves files without the marker unchanged", () => { + expect(stripDualLangMarker("Show.S01E01.German.1080p.mkv")).toBe("Show.S01E01.German.1080p.mkv"); + }); + it("does not strip unrelated tokens containing DL", () => { + expect(stripDualLangMarker("Show.HANDLES.1080p.mkv")).toBe("Show.HANDLES.1080p.mkv"); + }); +}); + +describe("hasDualLangMarker", () => { + it("detects the marker", () => { + expect(hasDualLangMarker("X.German.DL.720p.mkv")).toBe(true); + expect(hasDualLangMarker("X.DL.mkv")).toBe(true); + }); + it("returns false without the marker", () => { + expect(hasDualLangMarker("X.German.720p.mkv")).toBe(false); + }); +}); + +describe("isRemuxableVideoFile", () => { + it("accepts mkv/mp4 only", () => { + expect(isRemuxableVideoFile("a.mkv")).toBe(true); + expect(isRemuxableVideoFile("a.MP4")).toBe(true); + expect(isRemuxableVideoFile("a.avi")).toBe(false); + expect(isRemuxableVideoFile("a.srt")).toBe(false); + }); +}); + +describe("pickAudioTrack", () => { + const ger = { language: "ger", title: "" }; + const eng = { language: "eng", title: "" }; + const untagged = { language: "", title: "" }; + + it("no audio -> skip", () => { + expect(pickAudioTrack([], "tag").action).toBe("skip"); + }); + + it("first mode keeps first of many", () => { + const d = pickAudioTrack([eng, ger], "first"); + expect(d).toMatchObject({ action: "remux", audioRelIndex: 0 }); + }); + + it("first mode with single audio -> single (no remux)", () => { + expect(pickAudioTrack([eng], "first")).toMatchObject({ action: "single" }); + }); + + it("tag mode picks the German track even if not first", () => { + const d = pickAudioTrack([eng, ger], "tag"); + expect(d).toMatchObject({ action: "remux", audioRelIndex: 1, reason: "german-tag" }); + }); + + it("tag mode picks German via title when language untagged", () => { + const d = pickAudioTrack([{ language: "", title: "Englisch" }, { language: "", title: "Deutsch" }], "tag"); + expect(d).toMatchObject({ action: "remux", audioRelIndex: 1 }); + }); + + it("tag mode does NOT treat an ambiguous 3-letter title code as German (no false-positive pick)", () => { + // Two untagged tracks whose titles are only "Ger"/"Deu" must not be mistaken + // for a German track; with no real German signal this falls back to first. + const d = pickAudioTrack([{ language: "", title: "Ger" }, { language: "", title: "Deu" }], "tag"); + expect(d).toMatchObject({ action: "remux", audioRelIndex: 0, reason: "fallback-first-untagged" }); + }); + + it("tag mode does NOT let an eng track titled 'German' beat a correctly-tagged German track", () => { + const engTitledGerman = { language: "eng", title: "German Commentary" }; + const d = pickAudioTrack([engTitledGerman, ger], "tag"); + expect(d).toMatchObject({ action: "remux", audioRelIndex: 1, reason: "german-tag" }); + }); + + it("tag mode does NOT pick a non-German-tagged track just because its title contains 'Deutsch'", () => { + const d = pickAudioTrack([{ language: "eng", title: "Deutsch entfernt" }, { language: "fre", title: "" }], "tag"); + expect(d).toMatchObject({ action: "skip", reason: "no-german-track" }); + }); + + it("tag mode with single German -> single (no remux)", () => { + expect(pickAudioTrack([ger], "tag")).toMatchObject({ action: "single" }); + }); + + it("tag mode, fully untagged multi -> fallback to first", () => { + const d = pickAudioTrack([untagged, untagged], "tag"); + expect(d).toMatchObject({ action: "remux", audioRelIndex: 0, reason: "fallback-first-untagged" }); + }); + + it("tag mode, tagged but no German -> SKIP (never delete the only usable audio)", () => { + expect(pickAudioTrack([eng, { language: "fre", title: "" }], "tag")).toMatchObject({ action: "skip", reason: "no-german-track" }); + }); + + it("tag mode, no German tag but GERMAN release -> fall back to first track (mislabeled dub)", () => { + expect(pickAudioTrack([eng, eng], "tag", true)).toMatchObject({ action: "remux", audioRelIndex: 0, reason: "fallback-first-german-release" }); + }); + + it("tag mode, single mislabeled track on a German release -> keep it (no remux)", () => { + expect(pickAudioTrack([eng], "tag", true)).toMatchObject({ action: "single", reason: "single-german-mislabeled" }); + }); + + it("tag mode, no German tag and NOT flagged German -> still SKIP (safety preserved)", () => { + expect(pickAudioTrack([eng, eng], "tag", false)).toMatchObject({ action: "skip", reason: "no-german-track" }); + }); + + it("correctly tagged German still wins even on a German release (fallback not needed)", () => { + expect(pickAudioTrack([eng, ger], "tag", true)).toMatchObject({ action: "remux", audioRelIndex: 1, reason: "german-tag" }); + }); +}); + +describe("looksLikeGermanRelease", () => { + it("detects German/Dubbed release names", () => { + expect(looksLikeGermanRelease("Desperate.Housewives.S02E01.German.DD51.Dubbed.DL.720p.WEB-DL.x264.mkv")).toBe(true); + expect(looksLikeGermanRelease("1899.S01E01.German.DL.720p.WEB-x264-WvF.mkv")).toBe(true); + expect(looksLikeGermanRelease("Show.S01E01.Deutsch.1080p.mkv")).toBe(true); + }); + it("does not flag a bare .DL. name without an explicit German token", () => { + expect(looksLikeGermanRelease("Show.S01E01.DL.720p.x264.mkv")).toBe(false); + expect(looksLikeGermanRelease("Show.S01E01.MULTi.1080p.mkv")).toBe(false); + }); + it("does not flag a non-German dub as a German release (bare 'Dubbed' is ambiguous)", () => { + expect(looksLikeGermanRelease("Movie.2020.ITALIAN.Dubbed.DL.1080p.mkv")).toBe(false); + expect(looksLikeGermanRelease("Movie.2020.FRENCH.DUBBED.DL.720p.mkv")).toBe(false); + }); +}); + +describe("parseFfprobeAudioStreams", () => { + it("parses language/title tags", () => { + const json = JSON.stringify({ streams: [{ index: 1, tags: { language: "ger", title: "Deutsch" } }, { index: 2, tags: { language: "eng" } }] }); + expect(parseFfprobeAudioStreams(json)).toEqual([{ language: "ger", title: "Deutsch" }, { language: "eng", title: "" }]); + }); + it("returns [] on invalid json", () => { + expect(parseFfprobeAudioStreams("not json")).toEqual([]); + }); + it("returns [] when streams missing", () => { + expect(parseFfprobeAudioStreams("{}")).toEqual([]); + }); +}); + +describe("buildFfprobeArgs", () => { + it("requests audio streams as json", () => { + const args = buildFfprobeArgs("in.mkv"); + expect(args).toContain("-select_streams"); + expect(args).toContain("a"); + expect(args[args.length - 1]).toBe("in.mkv"); + expect(args).toContain("json"); + }); +}); + +describe("buildFfmpegRemuxArgs", () => { + it("maps video + chosen audio, stream-copy, keeps metadata (language tag), no subs by default", () => { + const args = buildFfmpegRemuxArgs({ input: "in.mkv", output: "out.mkv", audioRelIndex: 1 }); + expect(args).toEqual([ + "-i", "in.mkv", "-map", "0:v:0", "-map", "0:a:1", + "-c", "copy", "-disposition:a:0", "default", "-y", "out.mkv" + ]); + expect(args).not.toContain("-map_metadata"); // language tag of kept track must survive + }); + it("adds optional German subtitle maps when keepSubs", () => { + const args = buildFfmpegRemuxArgs({ input: "in.mkv", output: "out.mkv", audioRelIndex: 0, keepSubs: true }); + expect(args.join(" ")).toContain("0:s:m:language:ger?"); + }); +}); + +describe("computeRemuxTimeoutMs", () => { + it("has a floor", () => { + expect(computeRemuxTimeoutMs(0)).toBe(120_000); + }); + it("scales with size and caps at 60 min", () => { + expect(computeRemuxTimeoutMs(50 * 1024 * 1024 * 1024)).toBe(60 * 60 * 1000); + }); +}); + +// Exercises the REAL file-mutating body (temp -> replace -> utimes -> rm) with a +// fake ffmpeg/ffprobe runner. This is the irreversible-overwrite path that the +// download-manager integration test (which mocks processVideoFile wholesale) +// cannot cover. +describe("processVideoFile (real fs body, fake runner)", () => { + const tempDirs: string[] = []; + afterEach(() => { + for (const d of tempDirs.splice(0)) { + try { fs.rmSync(d, { recursive: true, force: true }); } catch { /* ignore */ } + } + }); + + function makeFile(content: string, name = "Show.S01E01.German.DL.720p.mkv"): string { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-vp-")); + tempDirs.push(dir); + const file = path.join(dir, name); + fs.writeFileSync(file, content); + return file; + } + + function fakeRunner(opts: { probeJson: string; ffmpegOk?: boolean }): typeof import("../src/main/video-processor").runVideoProcess { + return async (_command: string, args: string[]): Promise => { + const base = { aborted: false, timedOut: false, missing: false } as const; + if (args.includes("-show_entries")) { + return { ...base, ok: true, exitCode: 0, stdout: opts.probeJson, stderr: "" }; + } + const output = args[args.length - 1]; + if (opts.ffmpegOk !== false) { + fs.writeFileSync(output, "REMUXED-GERMAN-ONLY"); + return { ...base, ok: true, exitCode: 0, stdout: "", stderr: "" }; + } + return { ...base, ok: false, exitCode: 1, stdout: "", stderr: "ffmpeg boom" }; + }; + } + + // Any sidecar the replace machinery may leave behind (unique "~rd…" temp names). + function leftoverTemps(file: string): string[] { + return fs.readdirSync(path.dirname(file)).filter((n) => n.startsWith("~rd")); + } + + const tooling = async (): Promise<{ ffmpeg: string; ffprobe: string }> => ({ ffmpeg: "ffmpeg", ffprobe: "ffprobe" }); + const twoTracksGerSecond = JSON.stringify({ streams: [{ tags: { language: "eng" } }, { tags: { language: "ger" } }] }); + + it("replaces the original in place and preserves mtime on success", async () => { + const file = makeFile("ORIGINAL"); + const oldTime = new Date(Date.now() - 5 * 60 * 1000); + fs.utimesSync(file, oldTime, oldTime); + const beforeMtime = fs.statSync(file).mtimeMs; + + const result = await processVideoFile(file, { mode: "tag" }, { + resolveTooling: tooling, + runProcess: fakeRunner({ probeJson: twoTracksGerSecond }) + }); + + expect(result.action).toBe("remuxed"); + expect(result.keptTrackIndex).toBe(1); // German was second + expect(fs.readFileSync(file, "utf8")).toBe("REMUXED-GERMAN-ONLY"); // original overwritten + expect(Math.abs(fs.statSync(file).mtimeMs - beforeMtime)).toBeLessThan(1500); // mtime preserved + expect(leftoverTemps(file)).toEqual([]); // unique temp cleaned up + }); + + it("leaves the original intact and removes temp when ffmpeg fails", async () => { + const file = makeFile("ORIGINAL"); + const result = await processVideoFile(file, { mode: "tag" }, { + resolveTooling: tooling, + runProcess: fakeRunner({ probeJson: twoTracksGerSecond, ffmpegOk: false }) + }); + + expect(result.action).toBe("error"); + expect(fs.readFileSync(file, "utf8")).toBe("ORIGINAL"); // never lost + expect(leftoverTemps(file)).toEqual([]); + }); + + it("keeps the original intact and cleans the temp when the atomic replace rename fails (no zero-copy window)", async () => { + // Simulate a Windows file lock that defeats the replace even after retries. + // The original must survive: the old rm-then-rename fallback could leave the + // file with NEITHER the original nor the remux on disk. + const file = makeFile("ORIGINAL"); + const result = await processVideoFile(file, { mode: "tag" }, { + resolveTooling: tooling, + runProcess: fakeRunner({ probeJson: twoTracksGerSecond }), + rename: async () => { throw Object.assign(new Error("locked"), { code: "EBUSY" }); } + }); + + expect(result.action).toBe("error"); + expect(fs.readFileSync(file, "utf8")).toBe("ORIGINAL"); // original never destroyed + expect(leftoverTemps(file)).toEqual([]); // remux temp removed + }); + + it("does not touch a single-audio file (no remux)", async () => { + const file = makeFile("ORIGINAL"); + const result = await processVideoFile(file, { mode: "tag" }, { + resolveTooling: tooling, + runProcess: fakeRunner({ probeJson: JSON.stringify({ streams: [{ tags: { language: "ger" } }] }) }) + }); + expect(result.action).toBe("kept-single"); + expect(fs.readFileSync(file, "utf8")).toBe("ORIGINAL"); + }); + + it("remuxes a German-named release with MISLABELED audio tags (fallback to first track)", async () => { + // Name says German, but both audio tracks are tagged eng/fre (the dub is + // mislabeled). The fallback keeps the first track instead of skipping. + const file = makeFile("ORIGINAL"); // name contains "German" + const result = await processVideoFile(file, { mode: "tag" }, { + resolveTooling: tooling, + runProcess: fakeRunner({ probeJson: JSON.stringify({ streams: [{ tags: { language: "eng" } }, { tags: { language: "fre" } }] }) }) + }); + expect(result.action).toBe("remuxed"); + expect(result.keptTrackIndex).toBe(0); + expect(fs.readFileSync(file, "utf8")).toBe("REMUXED-GERMAN-ONLY"); + }); + + it("leaves a NON-German-named file untouched when tagged but no German track (safety preserved)", async () => { + const file = makeFile("ORIGINAL", "Show.S01E01.MULTi.DL.720p.mkv"); + const result = await processVideoFile(file, { mode: "tag" }, { + resolveTooling: tooling, + runProcess: fakeRunner({ probeJson: JSON.stringify({ streams: [{ tags: { language: "eng" } }, { tags: { language: "fre" } }] }) }) + }); + expect(result.action).toBe("skipped-no-german"); + expect(fs.readFileSync(file, "utf8")).toBe("ORIGINAL"); + }); + + it("returns skipped-no-tool when ffmpeg/ffprobe are absent", async () => { + const file = makeFile("ORIGINAL"); + const result = await processVideoFile(file, { mode: "tag" }, { resolveTooling: async () => null }); + expect(result.action).toBe("skipped-no-tool"); + expect(fs.readFileSync(file, "utf8")).toBe("ORIGINAL"); + }); +}); + +describe("renameWithRetry", () => { + afterEach(() => { vi.restoreAllMocks(); }); + const busy = (): NodeJS.ErrnoException => Object.assign(new Error("locked"), { code: "EBUSY" }); + + it("retries a transient EBUSY and then succeeds", async () => { + let calls = 0; + vi.spyOn(fs.promises, "rename").mockImplementation(async () => { + calls += 1; + if (calls <= 2) { throw busy(); } + }); + await expect(renameWithRetry("a", "b")).resolves.toBeUndefined(); + expect(calls).toBe(3); // failed twice, succeeded on the third attempt + }); + + it("gives up after exhausting retries on a persistent lock", async () => { + let calls = 0; + vi.spyOn(fs.promises, "rename").mockImplementation(async () => { calls += 1; throw busy(); }); + await expect(renameWithRetry("a", "b")).rejects.toThrow("locked"); + expect(calls).toBe(4); // initial attempt + 3 backoff retries + }); + + it("does not retry a non-retryable error (e.g. EXDEV) — fails fast", async () => { + let calls = 0; + vi.spyOn(fs.promises, "rename").mockImplementation(async () => { + calls += 1; + throw Object.assign(new Error("cross-device"), { code: "EXDEV" }); + }); + await expect(renameWithRetry("a", "b")).rejects.toThrow("cross-device"); + expect(calls).toBe(1); + }); +}); diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..3d2f168 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "Bundler", + "jsx": "react-jsx", + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "strict": true, + "skipLibCheck": true, + "esModuleInterop": true, + "resolveJsonModule": true, + "isolatedModules": true, + "types": ["node", "vite/client"] + }, + "include": ["src", "tests", "vite.config.mts"] +} diff --git a/vite.config.mts b/vite.config.mts new file mode 100644 index 0000000..8ef9437 --- /dev/null +++ b/vite.config.mts @@ -0,0 +1,14 @@ +import { defineConfig } from "vite"; +import react from "@vitejs/plugin-react"; +import path from "node:path"; + +export default defineConfig({ + plugins: [react()], + base: "./", + root: path.resolve(__dirname, "src/renderer"), + publicDir: path.resolve(__dirname, "assets"), + build: { + outDir: path.resolve(__dirname, "build/renderer"), + emptyOutDir: true + } +}); diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 0000000..ad88e35 --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,9 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + environment: "node", + include: ["tests/**/*.test.ts", "scripts/tests/**/*.test.ts"], + globals: true + } +});