Initial public release v1.7.233

This commit is contained in:
Sucukdeluxe 2026-08-01 22:28:08 +02:00
commit dc367633f5
152 changed files with 85159 additions and 0 deletions

18
.gitignore vendored Normal file
View File

@ -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

21
LICENSE Normal file
View File

@ -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.

308
README.md Normal file
View File

@ -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 <path-to-7z.exe>` | 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&note=support&durationMinutes=120`
- `GET /support/bundle`
- `GET /diagnostics?package=Release&lines=150`
Authentication works with either:
- header: `Authorization: Bearer <token>`
- query param: `?token=<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&note=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`.

BIN
assets/app_icon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 279 KiB

BIN
assets/app_icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 292 KiB

9782
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

96
package.json Normal file
View File

@ -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"
}
}

View File

@ -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.

View File

@ -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 |

Binary file not shown.

Binary file not shown.

View File

@ -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.
--

View File

@ -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.

View File

@ -0,0 +1,501 @@
GNU LESSER GENERAL PUBLIC LICENSE
Version 2.1, February 1999
Copyright (C) 1991, 1999 Free Software Foundation, Inc.
<https://fsf.org/>
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.
<one line to give the library's name and a brief idea of what it does.>
Copyright (C) <year> <name of author>
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 <https://www.gnu.org/licenses/>.
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.
<signature of Moe Ghoul>, 1 April 1990
Moe Ghoul, President of Vice
That's all there is to it!

File diff suppressed because it is too large Load Diff

18
scripts/afterPack.cjs Normal file
View File

@ -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)}`);
}
};

View File

@ -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 || "<empty>"}`);
}
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;
}
}

197
src/main/account-check.ts Normal file
View File

@ -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<string, unknown> | null {
try {
const parsed = JSON.parse(text) as unknown;
return parsed && typeof parsed === "object" ? (parsed as Record<string, unknown>) : 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<DebridAccountStatus> {
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<DebridAccountStatus> {
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<string, unknown>;
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<DebridAccountStatus[]> {
const now = Date.now();
const megaAccounts = parseMegaDebridAccounts(settings.megaCredentials || "", settings.megaPassword || "");
const debridLinkKeys = parseDebridLinkApiKeys(settings.debridLinkApiKeys || "");
const taskFns: Array<() => Promise<DebridAccountStatus>> = [
...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<T>(taskFns: Array<() => Promise<T>>, limit: number): Promise<T[]> {
const results: T[] = new Array(taskFns.length);
let nextIndex = 0;
const worker = async (): Promise<void> => {
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;
}

View File

@ -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<RotationItemSink>();
export function runWithRotationItemSink<T>(sink: RotationItemSink, fn: () => Promise<T>): Promise<T> {
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<string, unknown>,
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, 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 < 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<string, unknown>
): 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;
}

482
src/main/all-debrid-web.ts Normal file
View File

@ -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<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 asRecord(value: unknown): Record<string, unknown> | null {
if (!value || typeof value !== "object" || Array.isArray(value)) {
return null;
}
return value as Record<string, unknown>;
}
function pickString(payload: Record<string, unknown> | 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<string, unknown> | 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<string, unknown> | 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 = /<tr class=['"]g1['"]>\s*<td[^>]*>[\s\S]*?<i[^>]*alt=['"]([^'"]+)['"][^>]*>[\s\S]*?<\/td>\s*<td[^>]*class=['"]comparatif_content['"][^>]*>[\s\S]*?<img[^>]*src=['"]([^'"]+)['"][^>]*>[\s\S]*?\((?:<span[^>]*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<unknown> = 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<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();
}
public async getHostInfo(host: string): Promise<AllDebridHostInfo> {
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<void> {
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<T>(job: () => Promise<T>, signal?: AbortSignal): Promise<T> {
const queuedAt = Date.now();
const queueWaitTimeoutMs = 90_000;
const guardedJob = async (): Promise<T> => {
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<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: "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<GenerateOutcome> {
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<DelayedStatusPayload> {
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<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("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");
}
}

973
src/main/app-controller.ts Normal file
View File

@ -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<AppSettings>): Partial<AppSettings> {
const entries = Object.entries(partial || {}).filter(([, value]) => value !== undefined);
return Object.fromEntries(entries) as Partial<AppSettings>;
}
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<RemoteDiagnosticsInfo> {
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<RemoteDiagnosticsInfo> {
clearDebugToken();
await restartDebugServer();
this.audit("WARN", "Ferndiagnose deaktiviert (Token entfernt)");
return this.getRemoteDiagnostics();
}
public async rotateRemoteDiagnosticsToken(): Promise<RemoteDiagnosticsInfo> {
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<string, unknown>): 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>): 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<void> {
this.audit("INFO", "Real-Debrid Login-Fenster geöffnet");
await this.realDebridWebFallback.openLoginWindow();
}
public async openAllDebridLoginWindow(): Promise<void> {
this.audit("INFO", "AllDebrid Login-Fenster geöffnet");
await this.allDebridWebFallback.openLoginWindow();
}
public async importBestDebridCookies(filePath: string): Promise<number> {
const imported = await this.bestDebridWebFallback.importCookiesFromFile(filePath);
this.audit("INFO", "BestDebrid Cookies importiert", {
filePath,
imported
});
return imported;
}
public async getAllDebridHostInfo(host = "rapidgator"): Promise<AllDebridHostInfo> {
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<DebridAccountStatus[]> {
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<DebridAccountStatus | null> {
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<UpdateCheckResult> {
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<UpdateInstallResult> {
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<StartConflictEntry[]> {
return this.manager.getStartConflicts();
}
public async resolveStartConflict(packageId: string, policy: DuplicatePolicy): Promise<StartConflictResolutionResult> {
return this.manager.resolveStartConflict(packageId, policy);
}
public clearAll(): void {
this.audit("WARN", "Queue komplett geleert");
this.manager.clearAll();
}
public async start(): Promise<void> {
this.audit("INFO", "Session-Start ausgelöst");
await this.manager.start();
}
public async startPackages(packageIds: string[]): Promise<void> {
this.audit("INFO", "Paket-Start ausgelöst", { packageIds });
await this.manager.startPackages(packageIds);
}
public async startItems(itemIds: string[]): Promise<void> {
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<string, unknown>;
try {
const json = decryptBackup(data);
parsed = JSON.parse(json) as Record<string, unknown>;
} catch {
try {
const json = data.toString("utf8");
parsed = JSON.parse(json) as Record<string, unknown>;
} 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<string, unknown>;
const currentSettingsRecord = this.settings as unknown as Record<string, unknown>;
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);
}
}

119
src/main/audit-log.ts Normal file
View File

@ -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, 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 < 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<string, unknown>): 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;
}

39
src/main/backup-crypto.ts Normal file
View File

@ -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");
}

115
src/main/backup-payload.ts Normal file
View File

@ -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<string, unknown>;
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"
};
}

346
src/main/bestdebrid-web.ts Normal file
View File

@ -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<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;
}
}
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<string, NetscapeCookie>();
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*</i.test(normalized));
}
function looksLikeGuestAccessMessage(message: string): boolean {
return /free users are not allowed|purchase a premium plan|premium required/i.test(String(message || ""));
}
export class BestDebridWebFallback {
private queue: Promise<unknown> = Promise.resolve();
private cookiesImported = false;
private getRememberSession: () => boolean;
public constructor(getRememberSession: () => boolean) {
this.getRememberSession = getRememberSession;
}
public async unrestrict(link: string, signal?: AbortSignal): Promise<UnrestrictedLink | null> {
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<number> {
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<typeof currentSession.cookies.set>[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<void> {
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<T>(job: () => Promise<T>, signal?: AbortSignal): Promise<T> {
const queuedAt = Date.now();
const queueWaitTimeoutMs = 90_000;
const guardedJob = async (): Promise<T> => {
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("<!") || text.trim().startsWith("<html")) {
return { kind: "login_required" };
}
const payload = parseJson(text.trim());
if (!payload) {
return { kind: "login_required" };
}
const error = Number(payload.error ?? -1);
const message = String(payload.message || "").trim();
if (error !== 0) {
if (/login|log in|sign in|not logged|session|auth/i.test(message)) {
return { kind: "login_required" };
}
if (looksLikeGuestAccessMessage(message)) {
const authenticated = await this.isAuthenticated(currentSession, signal).catch(() => 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<string, number> = { 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<boolean> {
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<void> {
await currentSession.clearStorageData({
storages: ["cookies", "indexdb", "localstorage", "serviceworkers", "cachestorage"]
});
try {
await currentSession.clearCache();
} catch {
}
}
}

266
src/main/cleanup.ts Normal file
View File

@ -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<void> {
await new Promise<void>((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<number> {
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<number> {
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<number> => {
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 };
}

View File

@ -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<string, unknown> = { 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");
}

141
src/main/constants.ts Normal file
View File

@ -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
};
}

316
src/main/container.ts Normal file
View File

@ -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(/<textarea[^>]*>([\s\S]*?)<\/textarea>/i);
if (m) {
text = m[1].replace(/&quot;/g, '"').replace(/&amp;/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<string, unknown>).flatMap((value) => extractUrlsRecursive(value)));
}
return [];
}
function groupLinksByName(links: string[]): ParsedPackageInput[] {
const unique = uniquePreserveOrder(links.filter((link) => isHttpLink(link)));
const grouped = new Map<string, string[]>();
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 = /<package\s+[^>]*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 = /<file>([\s\S]*?)<\/file>/gi;
for (let fm = fileRegex.exec(packageBody); fm; fm = fileRegex.exec(packageBody)) {
const fileBody = fm[1] || "";
const urlMatch = fileBody.match(/<url>(.*?)<\/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>(.*?)<\/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>(.*?)<\/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<ParsedPackageInput[]> {
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>(.*?)<\/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<string[] | null> {
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<string[] | null> {
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<ParsedPackageInput[]> {
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<ParsedPackageInput[]> {
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;
}

View File

@ -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<string, string | number>;
phases: ConversionPhase[];
}
const conversionContext = new AsyncLocalStorage<ConversionTrace>();
function shortLink(link: string): string {
const raw = String(link || "").trim();
return raw.length > 90 ? `${raw.slice(0, 90)}` : raw;
}
export function traceConversionPhase(phase: Omit<ConversionPhase, "atMs">): 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<T>(
meta: { itemId: string; itemName: string; link: string; providerOrder: string },
fn: () => Promise<T>
): Promise<T> {
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));
}
}

4156
src/main/debrid.ts Normal file

File diff suppressed because it is too large Load Diff

1181
src/main/debug-server.ts Normal file

File diff suppressed because it is too large Load Diff

435
src/main/debug-setup.ts Normal file
View File

@ -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>"}`
}
};
}

View File

@ -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 });
}

View File

@ -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
};
}

12696
src/main/download-manager.ts Normal file

File diff suppressed because it is too large Load Diff

45
src/main/error-ring.ts Normal file
View File

@ -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();
}

3451
src/main/extractor.ts Normal file

File diff suppressed because it is too large Load Diff

56
src/main/fs-error.ts Normal file
View File

@ -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 ?? "");
}

159
src/main/integrity.ts Normal file
View File

@ -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` };
}

232
src/main/item-log.ts Normal file
View File

@ -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;
}

116
src/main/link-export.ts Normal file
View File

@ -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`;
}

42
src/main/link-parser.ts Normal file
View File

@ -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);
}

11
src/main/log-timestamp.ts Normal file
View File

@ -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}`
);
}

284
src/main/logger.ts Normal file
View File

@ -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;
}

881
src/main/main.ts Normal file
View File

@ -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)}`);
}
});

129
src/main/mega-public-api.ts Normal file
View File

@ -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 };
}

View File

@ -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);
}

152
src/main/notify.ts Normal file
View File

@ -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;
}

230
src/main/package-log.ts Normal file
View File

@ -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;
}

477
src/main/realdebrid-web.ts Normal file
View File

@ -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");
}
}

204
src/main/realdebrid.ts Normal file
View File

@ -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, ""));
}
}

119
src/main/rename-log.ts Normal file
View File

@ -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;
}

123
src/main/session-log.ts Normal file
View File

@ -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;
}

View File

@ -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 };
}

1340
src/main/storage.ts Normal file

File diff suppressed because it is too large Load Diff

224
src/main/support-bundle.ts Normal file
View File

@ -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();
}

188
src/main/support-data.ts Normal file
View File

@ -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
};
}

312
src/main/trace-log.ts Normal file
View File

@ -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 };
}

View File

@ -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;
}
}

1041
src/main/update.ts Normal file

File diff suppressed because it is too large Load Diff

309
src/main/utils.ts Normal file
View File

@ -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")}`;
}

513
src/main/video-processor.ts Normal file
View File

@ -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 };
}

View File

@ -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;
}

124
src/preload/preload.ts Normal file
View File

@ -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);

7325
src/renderer/App.tsx Normal file

File diff suppressed because it is too large Load Diff

View File

@ -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>
);
}
}

12
src/renderer/index.html Normal file
View File

@ -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</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="./main.tsx"></script>
</body>
</html>

48
src/renderer/main.tsx Normal file
View File

@ -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<typeof window.rd.reportRendererError>[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(
<React.StrictMode>
<ErrorBoundary>
<App />
</ErrorBoundary>
</React.StrictMode>
);

View File

@ -0,0 +1,61 @@
import type { DownloadItem, DownloadStatus, PackageEntry } from "../shared/types";
const ACTIVE_PACKAGE_STATUSES = new Set<DownloadStatus>(["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<string, PackageEntry>, 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<string, DownloadItem>,
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];
}

27
src/renderer/selection.ts Normal file
View File

@ -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<string>,
session: Pick<SessionState, "packages" | "items">
): Set<string> {
if (selected.size === 0) {
return selected as Set<string>;
}
const next = new Set<string>();
for (const id of selected) {
if (session.packages[id] || session.items[id]) {
next.add(id);
}
}
return next.size === selected.size ? (selected as Set<string>) : next;
}

3469
src/renderer/styles.css Normal file

File diff suppressed because it is too large Load Diff

9
src/renderer/vite-env.d.ts vendored Normal file
View File

@ -0,0 +1,9 @@
import type { ElectronApi } from "../shared/preload-api";
declare global {
interface Window {
rd: ElectronApi;
}
}
export {};

View File

@ -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<string>();
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);
}

77
src/shared/ipc.ts Normal file
View File

@ -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;

View File

@ -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<string>();
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);
}

View File

@ -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";
}

101
src/shared/preload-api.ts Normal file
View File

@ -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<UiSnapshot>;
getVersion: () => Promise<string>;
checkUpdates: () => Promise<UpdateCheckResult>;
installUpdate: () => Promise<UpdateInstallResult>;
openExternal: (url: string) => Promise<boolean>;
updateSettings: (settings: Partial<AppSettings>) => Promise<AppSettings>;
resetProviderDailyUsage: (provider: DebridProvider) => Promise<AppSettings>;
resetDebridLinkApiKeyDailyUsage: (keyId: string) => Promise<AppSettings>;
addLinks: (payload: AddLinksPayload) => Promise<{ addedPackages: number; addedLinks: number; invalidCount: number }>;
addContainers: (filePaths: string[]) => Promise<{ addedPackages: number; addedLinks: number }>;
getStartConflicts: () => Promise<StartConflictEntry[]>;
resolveStartConflict: (packageId: string, policy: DuplicatePolicy) => Promise<StartConflictResolutionResult>;
clearAll: () => Promise<void>;
start: () => Promise<void>;
startPackages: (packageIds: string[]) => Promise<void>;
stop: () => Promise<void>;
togglePause: () => Promise<boolean>;
cancelPackage: (packageId: string) => Promise<void>;
renamePackage: (packageId: string, newName: string) => Promise<void>;
reorderPackages: (packageIds: string[]) => Promise<void>;
removeItem: (itemId: string) => Promise<void>;
togglePackage: (packageId: string) => Promise<void>;
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<boolean>;
pickFolder: () => Promise<string | null>;
pickContainers: () => Promise<string[]>;
getSessionStats: () => Promise<SessionStats>;
resetSessionStats: () => Promise<void>;
resetDownloadStats: () => Promise<void>;
restart: () => Promise<void>;
quit: () => Promise<void>;
exportBackup: () => Promise<{ saved: boolean }>;
importBackup: () => Promise<{ restored: boolean; relaunch: boolean; message: string }>;
exportSupportBundle: () => Promise<{ saved: boolean; filePath?: string }>;
openLog: () => Promise<void>;
openAuditLog: () => Promise<void>;
openRenameLog: () => Promise<void>;
openSessionLog: () => Promise<void>;
openTraceLog: () => Promise<void>;
openPackageLog: (packageId: string) => Promise<void>;
openItemLog: (itemId: string) => Promise<void>;
getDebugSetupCheck: () => Promise<DebugSetupCheckResult>;
getRecentErrors: () => Promise<Array<{ ts: string; level: string; message: string }>>;
testNotification: (url: string, mention: string) => Promise<boolean>;
getTraceConfig: () => Promise<SupportTraceConfig>;
setTraceEnabled: (enabled: boolean, note?: string, durationMinutes?: number) => Promise<SupportTraceConfig>;
rotateDebugToken: () => Promise<{ path: string }>;
getRemoteDiagnostics: () => Promise<RemoteDiagnosticsInfo>;
enableRemoteDiagnostics: (input: EnableRemoteDiagnosticsInput) => Promise<RemoteDiagnosticsInfo>;
disableRemoteDiagnostics: () => Promise<RemoteDiagnosticsInfo>;
rotateRemoteDiagnosticsToken: () => Promise<RemoteDiagnosticsInfo>;
openRealDebridLogin: () => Promise<void>;
openAllDebridLogin: () => Promise<void>;
importBestDebridCookies: () => Promise<number>;
getAllDebridHostInfo: () => Promise<AllDebridHostInfo>;
getDebridLinkHostLimits: () => Promise<DebridLinkHostLimitInfo[]>;
checkDebridAccounts: () => Promise<DebridAccountStatus[]>;
checkMegaDebridAccount: (login: string, password: string) => Promise<DebridAccountStatus | null>;
retryExtraction: (packageId: string) => Promise<void>;
extractNow: (packageId: string) => Promise<void>;
resetPackage: (packageId: string) => Promise<void>;
getHistory: () => Promise<HistoryEntry[]>;
clearHistory: () => Promise<void>;
removeHistoryEntry: (entryId: string) => Promise<void>;
setPackagePriority: (packageId: string, priority: PackagePriority) => Promise<void>;
skipItems: (itemIds: string[]) => Promise<void>;
resetItems: (itemIds: string[]) => Promise<void>;
startItems: (itemIds: string[]) => Promise<void>;
reportRendererError: (report: RendererErrorReport) => void;
onStateUpdate: (callback: (snapshot: UiSnapshot) => void) => () => void;
onClipboardDetected: (callback: (links: string[]) => void) => () => void;
onUpdateInstallProgress: (callback: (progress: UpdateInstallProgress) => void) => () => void;
}

View File

@ -0,0 +1,329 @@
import type { AppSettings, DebridProvider } from "./types";
export type ProviderByteMap = Partial<Record<DebridProvider, number>>;
export type DebridLinkKeyByteMap = Record<string, number>;
type ProviderDailySettings =
Pick<AppSettings, "providerDailyLimitBytes" | "providerDailyUsageBytes" | "providerDailyUsageDay">
& Partial<Pick<AppSettings, "debridLinkApiKeyDailyLimitBytes" | "debridLinkApiKeyDailyUsageBytes">>
& Partial<Pick<AppSettings, "megaDebridDisabledAccountIds" | "megaDebridAccountDailyLimitBytes" | "megaDebridAccountDailyUsageBytes">>;
type ProviderUsageSettings =
ProviderDailySettings
& Partial<Pick<AppSettings, "providerTotalUsageBytes" | "debridLinkApiKeyTotalUsageBytes">>
& Partial<Pick<AppSettings, "megaDebridAccountTotalUsageBytes">>;
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<AppSettings, "providerDailyUsageDay" | "providerDailyUsageBytes"> {
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<AppSettings, "providerDailyUsageDay" | "providerDailyUsageBytes"> {
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<AppSettings, "providerTotalUsageBytes"> {
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<AppSettings, "providerDailyUsageDay" | "debridLinkApiKeyDailyUsageBytes"> {
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<AppSettings, "providerDailyUsageDay" | "debridLinkApiKeyDailyUsageBytes"> {
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<AppSettings, "debridLinkApiKeyTotalUsageBytes"> {
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<AppSettings, "providerDailyUsageDay" | "megaDebridAccountDailyUsageBytes"> {
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<AppSettings, "megaDebridAccountTotalUsageBytes"> {
const increment = normalizePositiveBytes(byteDelta);
const currentUsageBytes = { ...(settings.megaDebridAccountTotalUsageBytes || {}) };
if (increment <= 0) {
return {
megaDebridAccountTotalUsageBytes: currentUsageBytes
};
}
currentUsageBytes[accountId] = normalizePositiveBytes(currentUsageBytes[accountId]) + increment;
return {
megaDebridAccountTotalUsageBytes: currentUsageBytes
};
}

567
src/shared/types.ts Normal file
View File

@ -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<string, DebridProvider>;
providerDailyLimitBytes: Partial<Record<DebridProvider, number>>;
providerDailyUsageBytes: Partial<Record<DebridProvider, number>>;
providerTotalUsageBytes: Partial<Record<DebridProvider, number>>;
debridLinkApiKeyDailyLimitBytes: Record<string, number>;
debridLinkApiKeyDailyUsageBytes: Record<string, number>;
debridLinkApiKeyTotalUsageBytes: Record<string, number>;
megaDebridDisabledAccountIds: string[];
megaDebridAccountDailyLimitBytes: Record<string, number>;
megaDebridAccountDailyUsageBytes: Record<string, number>;
megaDebridAccountTotalUsageBytes: Record<string, number>;
debridAccountStatuses: Record<string, DebridAccountStatus>;
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<string, PackageEntry>;
items: Record<string, DownloadItem>;
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<string, number>;
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;
}

161
tests/account-check.test.ts Normal file
View File

@ -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}`));
});
});

View File

@ -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);
});
});

View File

@ -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);
});
});

49
tests/app-order.test.ts Normal file
View File

@ -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"]);
});
});

48
tests/audit-log.test.ts Normal file
View File

@ -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");
});
});

1089
tests/auto-rename.test.ts Normal file

File diff suppressed because it is too large Load Diff

View File

@ -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("");
});
});

View File

@ -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> = {}): 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);
});
});

Some files were not shown because too many files have changed in this diff Show More