Compare commits

...

No commits in common. "main" and "v2.0.0" have entirely different histories.
main ... v2.0.0

155 changed files with 84891 additions and 87125 deletions

53
.gitignore vendored
View File

@ -1,49 +1,18 @@
__pycache__/ node_modules/
*.pyc
*.pyo
*.pyd
.venv/
venv/
build/ build/
dist/ dist/
release/ release/
*.spec .vite/
coverage/
*.log
*.log.old
*.bak
rd_downloader_config.json rd_downloader_config.json
rd_downloader.log
rd_download_manifest.json rd_download_manifest.json
_update_staging/ _update_staging/
apply_update.cmd apply_update.cmd
.env
.claude/ .env.*
.github/ !.env.example
CHANGELOG.md .DS_Store
Thumbs.db
node_modules/
.vite/
coverage/
npm-debug.log*
yarn-debug.log*
yarn-error.log*
deploy/forgejo/.env
deploy/forgejo/forgejo/
deploy/forgejo/postgres/
deploy/forgejo/caddy/data/
deploy/forgejo/caddy/config/
deploy/forgejo/caddy/logs/
deploy/forgejo/backups/
.secrets
*.log.old
*.bak
rust-postprocess/
electron-postprocess/
python-postprocess/
scripts/*.py
scripts/*.ps1
scripts/*.md
scripts/fix-library-renames.mjs

View File

@ -1,28 +0,0 @@
## Release + Update Source (Wichtig)
- Primäre Plattform ist `https://git.24-music.de`
- Standard-Repo: `Administrator/real-debrid-downloader`
- Nicht mehr primär über Codeberg/GitHub releasen
## Releasen
Der Token liegt in `.secrets` (gitignored) und wird automatisch geladen.
Als KI-Agent: Token aus `.secrets` lesen und als Umgebungsvariable setzen, dann Release-Script ausführen:
```bash
export $(cat .secrets | xargs) && npm run release:gitea -- <version> [notes]
```
Manuell in PowerShell (falls nötig):
- `npm run release:gitea -- <version> [notes]` (Token ist bereits als Benutzervariable gesetzt)
Das Script:
- bumped `package.json`
- baut Windows-Artefakte
- pusht `main` + Tag
- erstellt Release auf `git.24-music.de`
- lädt Assets hoch
## Auto-Update
- Updater nutzt aktuell `git.24-music.de` als Standardquelle

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.

View File

@ -13,7 +13,7 @@ Desktop downloader for Windows with package-based queue management, multi-provid
- JDownloader-style workflow with packages, progress, extraction, history, and clean post-processing. - 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. - 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. - Built for large queues with session persistence, retries, reconnect handling, resume support, and integrity checks.
- Includes an in-app updater for releases published on `git.24-music.de`. - Includes an in-app updater for releases published on GitHub.
## Supported providers ## Supported providers
@ -126,7 +126,7 @@ Desktop downloader for Windows with package-based queue management, multi-provid
2. Start the app. 2. Start the app.
3. Add your provider credentials in `Settings > Accounts`. 3. Add your provider credentials in `Settings > Accounts`.
Releases: [git.24-music.de Releases](https://git.24-music.de/Administrator/real-debrid-downloader/releases) Releases: [GitHub Releases](https://github.com/Sucukdeluxe/multi-debrid-downloader/releases)
### Build from source ### Build from source
@ -152,9 +152,8 @@ npm run dev
| `npm run start` | Starts the built app in production mode | | `npm run start` | Starts the built app in production mode |
| `npm test` | Runs Vitest unit tests | | `npm test` | Runs Vitest unit tests |
| `npm run self-check` | Runs integrated self-checks | | `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 | | `npm run release:win` | Builds Windows installer and portable EXE |
| `npm run release:gitea -- <version> [notes]` | Builds, tags, and uploads a release to `git.24-music.de` |
| `npm run release:forgejo -- <version> [notes]` | Alias for the same release workflow |
## Typical workflow ## Typical workflow
@ -208,7 +207,7 @@ Runtime files are stored in Electron's `userData` directory, including:
- `rd_downloader.log` - `rd_downloader.log`
- `audit.log` - `audit.log`
- `rename.log` - `rename.log`
- `debug_ai_manifest.json` - `debug_support_manifest.json`
- `trace.log` - `trace.log`
- `trace_config.json` - `trace_config.json`
- `session-logs/session_*.txt` - `session-logs/session_*.txt`
@ -230,11 +229,11 @@ Enable it by creating these files in the same runtime folder that contains `rd_d
- `debug_host.txt` (optional) - `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. 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_ai_manifest.json` into the same runtime folder. This file is meant for support tooling and AI agents: it lists all available endpoints, the auth method, the related runtime files, and the one remaining external value the assistant may still need from you for remote access: the server IP or DNS name. 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. 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/AI-manifest/trace setup locally and now also reports free disk space, current support-log sizes, and an estimated support-bundle size. 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: Available endpoints after restart:
@ -300,8 +299,10 @@ This makes it easy to share one URL plus token during support, so current packag
## Changelog ## Changelog
Detailed release history is published on [git.24-music.de Releases](https://git.24-music.de/Administrator/real-debrid-downloader/releases). Detailed release history is published on [GitHub Releases](https://github.com/Sucukdeluxe/multi-debrid-downloader/releases).
## License ## License
MIT - see `LICENSE`. The project is licensed under the MIT License. See `LICENSE`.
Bundled JVM extractor licenses and redistribution notices are available in `resources/extractor-jvm`.

View File

@ -1,50 +0,0 @@
#define MyAppName "Real-Debrid Downloader"
#define MyAppExeName "Real-Debrid-Downloader.exe"
#ifndef MyAppVersion
#define MyAppVersion "1.0.0"
#endif
#ifndef MySourceDir
#define MySourceDir "..\\dist\\Real-Debrid-Downloader"
#endif
#ifndef MyOutputDir
#define MyOutputDir "release"
#endif
#ifndef MyIconFile
#define MyIconFile "..\\assets\\app_icon.ico"
#endif
[Setup]
AppId={{C0E95B39-389E-4D2C-8E1E-12A44E8AE8E0}
AppName={#MyAppName}
AppVersion={#MyAppVersion}
AppPublisher=Sucukdeluxe
DefaultDirName={autopf}\{#MyAppName}
DefaultGroupName={#MyAppName}
OutputDir={#MyOutputDir}
OutputBaseFilename=Real-Debrid-Downloader Setup {#MyAppVersion}
Compression=lzma
SolidCompression=yes
WizardStyle=modern
PrivilegesRequired=lowest
ArchitecturesInstallIn64BitMode=x64compatible
UninstallDisplayIcon={app}\{#MyAppExeName}
SetupIconFile={#MyIconFile}
[Languages]
Name: "german"; MessagesFile: "compiler:Languages\German.isl"
Name: "english"; MessagesFile: "compiler:Default.isl"
[Files]
Source: "{#MySourceDir}\\*"; DestDir: "{app}"; Flags: recursesubdirs createallsubdirs
Source: "{#MyIconFile}"; DestDir: "{app}"; DestName: "app_icon.ico"
[Icons]
Name: "{group}\{#MyAppName}"; Filename: "{app}\{#MyAppExeName}"; IconFilename: "{app}\app_icon.ico"
Name: "{autodesktop}\{#MyAppName}"; Filename: "{app}\{#MyAppExeName}"; IconFilename: "{app}\app_icon.ico"
[Run]
Filename: "{app}\{#MyAppExeName}"; Description: "{#MyAppName} starten"; Flags: nowait postinstall skipifsilent

41
package-lock.json generated
View File

@ -1,18 +1,18 @@
{ {
"name": "real-debrid-downloader", "name": "real-debrid-downloader",
"version": "1.7.45", "version": "2.0.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "real-debrid-downloader", "name": "real-debrid-downloader",
"version": "1.7.45", "version": "2.0.0",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"adm-zip": "^0.5.16", "adm-zip": "0.6.0",
"react": "^18.3.1", "react": "^18.3.1",
"react-dom": "^18.3.1", "react-dom": "^18.3.1",
"uuid": "^11.1.0" "uuid": "11.1.1"
}, },
"devDependencies": { "devDependencies": {
"@types/adm-zip": "^0.5.7", "@types/adm-zip": "^0.5.7",
@ -31,7 +31,8 @@
"typescript": "^5.7.3", "typescript": "^5.7.3",
"vite": "^6.0.5", "vite": "^6.0.5",
"vitest": "^2.1.8", "vitest": "^2.1.8",
"wait-on": "^8.0.1" "wait-on": "^8.0.1",
"yaml": "^2.9.0"
} }
}, },
"node_modules/@babel/code-frame": { "node_modules/@babel/code-frame": {
@ -2253,12 +2254,12 @@
} }
}, },
"node_modules/adm-zip": { "node_modules/adm-zip": {
"version": "0.5.16", "version": "0.6.0",
"resolved": "https://registry.npmjs.org/adm-zip/-/adm-zip-0.5.16.tgz", "resolved": "https://registry.npmjs.org/adm-zip/-/adm-zip-0.6.0.tgz",
"integrity": "sha512-TGw5yVi4saajsSEgz25grObGHEUaDrniwvA2qwSC060KfqGPdglhvPMA2lPIoxs3PQIItj2iag35fONcQqgUaQ==", "integrity": "sha512-XleryMhbuksdKtofnWZ9Sk+4CUTbms4Mb/EU32SZwToAyZ5RgVos/ki8n+yr0LWHOGKuakbXTuuYNHLQjhddgg==",
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=12.0" "node": ">=14.0"
} }
}, },
"node_modules/agent-base": { "node_modules/agent-base": {
@ -7828,9 +7829,9 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/uuid": { "node_modules/uuid": {
"version": "11.1.0", "version": "11.1.1",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.0.tgz", "resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.1.tgz",
"integrity": "sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==", "integrity": "sha512-vIYxrBCC/N/K+Js3qSN88go7kIfNPssr/hHCesKCQNAjmgvYS2oqr69kIufEG+O4+PfezOH4EbIeHCfFov8ZgQ==",
"funding": [ "funding": [
"https://github.com/sponsors/broofa", "https://github.com/sponsors/broofa",
"https://github.com/sponsors/ctavan" "https://github.com/sponsors/ctavan"
@ -9669,6 +9670,22 @@
"dev": true, "dev": true,
"license": "ISC" "license": "ISC"
}, },
"node_modules/yaml": {
"version": "2.9.0",
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz",
"integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==",
"dev": true,
"license": "ISC",
"bin": {
"yaml": "bin.mjs"
},
"engines": {
"node": ">= 14.6"
},
"funding": {
"url": "https://github.com/sponsors/eemeli"
}
},
"node_modules/yargs": { "node_modules/yargs": {
"version": "17.7.2", "version": "17.7.2",
"resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz",

View File

@ -1,6 +1,6 @@
{ {
"name": "real-debrid-downloader", "name": "real-debrid-downloader",
"version": "1.7.232", "version": "2.0.0",
"description": "Desktop downloader", "description": "Desktop downloader",
"main": "build/main/main/main.js", "main": "build/main/main/main.js",
"author": "Sucukdeluxe", "author": "Sucukdeluxe",
@ -17,14 +17,13 @@
"test": "vitest run", "test": "vitest run",
"self-check": "tsx tests/self-check.ts", "self-check": "tsx tests/self-check.ts",
"release:win": "npm run build && electron-builder --publish never --win nsis portable", "release:win": "npm run build && electron-builder --publish never --win nsis portable",
"release:gitea": "node scripts/release_gitea.mjs", "verify:release": "node scripts/verify_public_release.mjs"
"release:forgejo": "node scripts/release_gitea.mjs"
}, },
"dependencies": { "dependencies": {
"adm-zip": "^0.5.16", "adm-zip": "0.6.0",
"react": "^18.3.1", "react": "^18.3.1",
"react-dom": "^18.3.1", "react-dom": "^18.3.1",
"uuid": "^11.1.0" "uuid": "11.1.1"
}, },
"devDependencies": { "devDependencies": {
"@types/adm-zip": "^0.5.7", "@types/adm-zip": "^0.5.7",
@ -43,7 +42,8 @@
"typescript": "^5.7.3", "typescript": "^5.7.3",
"vite": "^6.0.5", "vite": "^6.0.5",
"vitest": "^2.1.8", "vitest": "^2.1.8",
"wait-on": "^8.0.1" "wait-on": "^8.0.1",
"yaml": "^2.9.0"
}, },
"build": { "build": {
"appId": "com.sucukdeluxe.realdebrid", "appId": "com.sucukdeluxe.realdebrid",
@ -52,12 +52,24 @@
"buildResources": "assets", "buildResources": "assets",
"output": "release" "output": "release"
}, },
"publish": {
"provider": "github",
"owner": "Sucukdeluxe",
"repo": "multi-debrid-downloader"
},
"files": [ "files": [
"build/main/**/*", "build/main/**/*",
"build/renderer/**/*", "build/renderer/**/*",
"resources/extractor-jvm/**/*", "resources/extractor-jvm/**/*",
"LICENSE",
"package.json" "package.json"
], ],
"extraResources": [
{
"from": "LICENSE",
"to": "LICENSE"
}
],
"asarUnpack": [ "asarUnpack": [
"resources/extractor-jvm/**/*" "resources/extractor-jvm/**/*"
], ],
@ -70,11 +82,15 @@
"signAndEditExecutable": false "signAndEditExecutable": false
}, },
"nsis": { "nsis": {
"artifactName": "${productName}-Setup-${version}.${ext}",
"oneClick": false, "oneClick": false,
"perMachine": false, "perMachine": false,
"allowToChangeInstallationDirectory": true, "allowToChangeInstallationDirectory": true,
"createDesktopShortcut": true "createDesktopShortcut": true
}, },
"portable": {
"artifactName": "${productName}-${version}-portable.${ext}"
},
"afterPack": "scripts/afterPack.cjs" "afterPack": "scripts/afterPack.cjs"
} }
} }

View File

@ -1,12 +1,7 @@
Bundled JVM extractor dependencies: Bundled JVM extractor dependencies
1) sevenzipjbinding (16.02-2.01) | Maven artifact | Version | License and restrictions | Included text | Upstream |
- Maven artifact: net.sf.sevenzipjbinding:sevenzipjbinding | --- | --- | --- | --- | --- |
- Maven artifact: net.sf.sevenzipjbinding:sevenzipjbinding-all-platforms | 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/ |
- Upstream: 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 |
2) zip4j (2.11.5)
- Maven artifact: net.lingala.zip4j:zip4j
- Upstream: https://github.com/srikanth-lingala/zip4j
Please review upstream licenses and notices before redistribution.

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!

View File

@ -1,51 +0,0 @@
import { DebridService } from "../src/main/debrid";
import { defaultSettings } from "../src/main/constants";
import { MegaWebFallback } from "../src/main/mega-web-fallback";
const links = [
"https://rapidgator.net/file/837ef967aede4935e3e0374c4e663b40/GTHDERTPIIP7P401.part1.rar.html",
"https://rapidgator.net/file/ef3c9d64c899f801d69d6888dad89dcd/GTHDERTPIIP7P401.part2.rar.html",
"https://rapidgator.net/file/b38130fcf1e8448953250b9a1ed7958d/GTHDERTPIIP7P401.part3.rar.html"
];
const settings = {
...defaultSettings(),
token: process.env.RD_TOKEN || "",
megaLogin: process.env.MEGA_LOGIN || "",
megaPassword: process.env.MEGA_PASSWORD || "",
bestToken: process.env.BEST_TOKEN || "",
allDebridToken: process.env.ALLDEBRID_TOKEN || "",
providerPrimary: "alldebrid" as const,
providerSecondary: "realdebrid" as const,
providerTertiary: "megadebrid" as const,
autoProviderFallback: true
};
if (!settings.token && !(settings.megaLogin && settings.megaPassword) && !settings.bestToken && !settings.allDebridToken) {
console.error("No provider credentials set. Use RD_TOKEN or MEGA_LOGIN+MEGA_PASSWORD or BEST_TOKEN or ALLDEBRID_TOKEN.");
process.exit(1);
}
async function main(): Promise<void> {
const megaWeb = new MegaWebFallback(() => ({
login: settings.megaLogin,
password: settings.megaPassword
}));
try {
const service = new DebridService(settings, {
megaWebUnrestrict: (link) => megaWeb.unrestrict(link)
});
for (const link of links) {
try {
const result = await service.unrestrictLink(link);
console.log(`[OK] ${result.providerLabel} -> ${result.fileName}`);
} catch (error) {
console.log(`[FAIL] ${String(error)}`);
}
}
} finally {
megaWeb.dispose();
}
}
main().catch(e => { console.error(e); process.exit(1); });

View File

@ -1,148 +0,0 @@
const LOGIN = process.env.MEGA_LOGIN || "";
const PASSWORD = process.env.MEGA_PASSWORD || "";
const LINKS = [
"https://rapidgator.net/file/90b5397dfc3e1a0e561db7d6b89d5604/scnb-rrw7-S08E01.part1.rar.html",
"https://rapidgator.net/file/8ddf856dc833310c5cae9db82caf9682/scnb-rrw7-S08E01.part2.rar.html",
"https://rapidgator.net/file/440eed67d266476866332ae224c3fad5/scnb-rrw7-S08E01.part3.rar.html"
];
if (!LOGIN || !PASSWORD) {
throw new Error("Set MEGA_LOGIN and MEGA_PASSWORD env vars");
}
function sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
function cookieFrom(headers) {
const cookies = headers.getSetCookie();
return cookies.map((x) => x.split(";")[0].trim()).filter(Boolean).join("; ");
}
function parseDebridCodes(html) {
const re = /processDebrid\((\d+),'([^']+)',0\)/g;
const out = [];
let m;
while ((m = re.exec(html)) !== null) {
out.push({ id: Number(m[1]), code: m[2] });
}
return out;
}
async function resolveCode(cookie, code) {
for (let attempt = 1; attempt <= 50; attempt += 1) {
const res = await fetch("https://www.mega-debrid.eu/index.php?ajax=debrid&json", {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
"User-Agent": "Mozilla/5.0",
Cookie: cookie,
Referer: "https://www.mega-debrid.eu/index.php?page=debrideur&lang=de"
},
body: new URLSearchParams({
code,
autodl: "0"
})
});
const text = (await res.text()).trim();
if (text === "reload") {
if (attempt % 5 === 0) {
console.log(` [retry] code=${code} attempt=${attempt}/50 (waiting for server)`);
}
await sleep(800);
continue;
}
if (text === "false") {
return { ok: false, reason: "false" };
}
try {
const parsed = JSON.parse(text);
if (parsed?.link) {
return { ok: true, link: String(parsed.link), text: String(parsed.text || "") };
}
return { ok: false, reason: text };
} catch {
return { ok: false, reason: text };
}
}
return { ok: false, reason: "timeout" };
}
async function probeDownload(url) {
const res = await fetch(url, {
method: "GET",
headers: {
Range: "bytes=0-4095",
"User-Agent": "Mozilla/5.0"
},
redirect: "manual"
});
return {
status: res.status,
location: res.headers.get("location") || "",
contentType: res.headers.get("content-type") || "",
contentLength: res.headers.get("content-length") || ""
};
}
async function main() {
const loginRes = await fetch("https://www.mega-debrid.eu/index.php?form=login", {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
"User-Agent": "Mozilla/5.0"
},
body: new URLSearchParams({
login: LOGIN,
password: PASSWORD,
remember: "on"
}),
redirect: "manual"
});
if (loginRes.status >= 400) {
throw new Error(`Login failed with HTTP ${loginRes.status}`);
}
const cookie = cookieFrom(loginRes.headers);
if (!cookie) {
throw new Error("Login returned no session cookie");
}
console.log("login", loginRes.status, loginRes.headers.get("location") || "");
const debridRes = await fetch("https://www.mega-debrid.eu/index.php?form=debrid", {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
"User-Agent": "Mozilla/5.0",
Cookie: cookie,
Referer: "https://www.mega-debrid.eu/index.php?page=debrideur&lang=de"
},
body: new URLSearchParams({
links: LINKS.join("\n"),
password: "",
showLinks: "1"
})
});
const html = await debridRes.text();
const codes = parseDebridCodes(html);
console.log("codes", codes.length);
if (codes.length === 0) {
throw new Error("No processDebrid codes found");
}
for (let i = 0; i < Math.min(3, codes.length); i += 1) {
const c = codes[i];
const resolved = await resolveCode(cookie, c.code);
if (!resolved.ok) {
console.log(`[FAIL] code ${c.code}: ${resolved.reason}`);
continue;
}
console.log(`[OK] code ${c.code} -> ${resolved.link}`);
const probe = await probeDownload(resolved.link);
console.log(` probe status=${probe.status} type=${probe.contentType} len=${probe.contentLength} loc=${probe.location}`);
}
}
await main().catch((e) => { console.error(e); process.exit(1); });

View File

@ -1,295 +0,0 @@
const RAPIDGATOR_LINKS = [
"https://rapidgator.net/file/837ef967aede4935e3e0374c4e663b40/GTHDERTPIIP7P401.part1.rar.html",
"https://rapidgator.net/file/ef3c9d64c899f801d69d6888dad89dcd/GTHDERTPIIP7P401.part2.rar.html",
"https://rapidgator.net/file/b38130fcf1e8448953250b9a1ed7958d/GTHDERTPIIP7P401.part3.rar.html"
];
const rdToken = process.env.RD_TOKEN || "";
const megaLogin = process.env.MEGA_LOGIN || "";
const megaPassword = process.env.MEGA_PASSWORD || "";
const bestToken = process.env.BEST_TOKEN || "";
const allDebridToken = process.env.ALLDEBRID_TOKEN || "";
let megaCookie = "";
if (!rdToken && !(megaLogin && megaPassword) && !bestToken && !allDebridToken) {
console.error("No provider credentials configured. Set RD_TOKEN and/or MEGA_LOGIN+MEGA_PASSWORD and/or BEST_TOKEN and/or ALLDEBRID_TOKEN.");
process.exit(1);
}
function asRecord(value) {
if (!value || typeof value !== "object" || Array.isArray(value)) {
return null;
}
return value;
}
function pickString(payload, keys) {
if (!payload) {
return "";
}
for (const key of keys) {
const value = payload[key];
if (typeof value === "string" && value.trim()) {
return value.trim();
}
}
return "";
}
function parseResponseError(status, bodyText, payload) {
return pickString(payload, ["response_text", "error", "message", "error_description"]) || bodyText || `HTTP ${status}`;
}
async function callRealDebrid(link) {
const response = await fetch("https://api.real-debrid.com/rest/1.0/unrestrict/link", {
method: "POST",
headers: {
Authorization: `Bearer ${rdToken}`,
"Content-Type": "application/x-www-form-urlencoded",
"User-Agent": "RD-Node-Downloader/1.1.12"
},
body: new URLSearchParams({ link })
});
const text = await response.text();
const payload = asRecord(safeJson(text));
if (!response.ok) {
return { ok: false, error: parseResponseError(response.status, text, payload) };
}
const direct = pickString(payload, ["download", "link"]);
if (!direct) {
return { ok: false, error: "Real-Debrid returned no download URL" };
}
return {
ok: true,
direct,
fileName: pickString(payload, ["filename", "fileName"])
};
}
async function callMegaDebrid(link) {
if (!megaCookie) {
const loginRes = await fetch("https://www.mega-debrid.eu/index.php?form=login", {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
"User-Agent": "Mozilla/5.0"
},
body: new URLSearchParams({ login: megaLogin, password: megaPassword, remember: "on" }),
redirect: "manual"
});
if (loginRes.status >= 400) {
return { ok: false, error: `Mega-Web login failed with HTTP ${loginRes.status}` };
}
megaCookie = loginRes.headers.getSetCookie()
.map((chunk) => chunk.split(";")[0].trim())
.filter(Boolean)
.join("; ");
if (!megaCookie) {
return { ok: false, error: "Mega-Web login returned no session cookie" };
}
}
const debridRes = await fetch("https://www.mega-debrid.eu/index.php?form=debrid", {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
"User-Agent": "Mozilla/5.0",
Cookie: megaCookie,
Referer: "https://www.mega-debrid.eu/index.php?page=debrideur&lang=de"
},
body: new URLSearchParams({ links: link, password: "", showLinks: "1" })
});
const html = await debridRes.text();
const code = html.match(/processDebrid\(\d+,'([^']+)',0\)/i)?.[1] || "";
if (!code) {
return { ok: false, error: "Mega-Web returned no processDebrid code" };
}
for (let attempt = 1; attempt <= 40; attempt += 1) {
const ajaxRes = await fetch("https://www.mega-debrid.eu/index.php?ajax=debrid&json", {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
"User-Agent": "Mozilla/5.0",
Cookie: megaCookie,
Referer: "https://www.mega-debrid.eu/index.php?page=debrideur&lang=de"
},
body: new URLSearchParams({ code, autodl: "0" })
});
const txt = (await ajaxRes.text()).trim();
if (txt === "reload") {
await new Promise((resolve) => setTimeout(resolve, 650));
continue;
}
if (txt === "false") {
return { ok: false, error: "Mega-Web returned false" };
}
const payload = safeJson(txt);
const direct = String(payload?.link || "");
if (!direct) {
const msg = String(payload?.text || txt || "Mega-Web no link");
if (/hoster does not respond correctly|could not be done for this moment/i.test(msg)) {
await new Promise((resolve) => setTimeout(resolve, 1200));
continue;
}
return { ok: false, error: msg };
}
return {
ok: true,
direct,
fileName: pickString(asRecord(payload), ["filename"]) || ""
};
}
return { ok: false, error: "Mega-Web timeout while generating link" };
}
async function callBestDebrid(link) {
const encoded = encodeURIComponent(link);
const requests = [
{
url: `https://bestdebrid.com/api/v1/generateLink?link=${encoded}`,
useHeader: true
},
{
url: `https://bestdebrid.com/api/v1/generateLink?auth=${encodeURIComponent(bestToken)}&link=${encoded}`,
useHeader: false
}
];
let lastError = "Unknown BestDebrid error";
for (const req of requests) {
const headers = {
"User-Agent": "RD-Node-Downloader/1.1.12"
};
if (req.useHeader) {
headers.Authorization = bestToken;
}
const response = await fetch(req.url, {
method: "GET",
headers
});
const text = await response.text();
const parsed = safeJson(text);
const payload = Array.isArray(parsed) ? asRecord(parsed[0]) : asRecord(parsed);
if (!response.ok) {
lastError = parseResponseError(response.status, text, payload);
continue;
}
const direct = pickString(payload, ["download", "debridLink", "link"]);
if (!direct) {
lastError = pickString(payload, ["response_text", "message", "error"]) || "BestDebrid returned no download URL";
continue;
}
return {
ok: true,
direct,
fileName: pickString(payload, ["filename", "fileName"])
};
}
return { ok: false, error: lastError };
}
async function callAllDebrid(link) {
const response = await fetch("https://api.alldebrid.com/v4/link/unlock", {
method: "POST",
headers: {
Authorization: `Bearer ${allDebridToken}`,
"Content-Type": "application/x-www-form-urlencoded",
"User-Agent": "RD-Node-Downloader/1.1.12"
},
body: new URLSearchParams({ link })
});
const text = await response.text();
const payload = asRecord(safeJson(text));
if (!response.ok) {
return { ok: false, error: parseResponseError(response.status, text, payload) };
}
if (pickString(payload, ["status"]) === "error") {
const err = asRecord(payload?.error);
return { ok: false, error: pickString(err, ["message", "code"]) || "AllDebrid API error" };
}
const data = asRecord(payload?.data);
const direct = pickString(data, ["link"]);
if (!direct) {
return { ok: false, error: "AllDebrid returned no download URL" };
}
return {
ok: true,
direct,
fileName: pickString(data, ["filename"])
};
}
function safeJson(text) {
try {
return JSON.parse(text);
} catch {
return null;
}
}
function hostFromUrl(url) {
try {
return new URL(url).host;
} catch {
return "invalid-url";
}
}
async function main() {
const providers = [];
if (rdToken) {
providers.push({ name: "Real-Debrid", run: callRealDebrid });
}
if (megaLogin && megaPassword) {
providers.push({ name: "Mega-Debrid", run: callMegaDebrid });
}
if (bestToken) {
providers.push({ name: "BestDebrid", run: callBestDebrid });
}
if (allDebridToken) {
providers.push({ name: "AllDebrid", run: callAllDebrid });
}
let failures = 0;
for (const link of RAPIDGATOR_LINKS) {
console.log(`\nLink: ${link}`);
const results = [];
for (const provider of providers) {
try {
const result = await provider.run(link);
results.push({ provider: provider.name, ...result });
} catch (error) {
results.push({ provider: provider.name, ok: false, error: String(error) });
}
}
for (const result of results) {
if (result.ok) {
console.log(` [OK] ${result.provider} -> ${hostFromUrl(result.direct)} ${result.fileName ? `(${result.fileName})` : ""}`);
} else {
console.log(` [FAIL] ${result.provider} -> ${result.error}`);
}
}
const fallbackPick = results.find((entry) => entry.ok);
if (fallbackPick) {
console.log(` [AUTO] Selected by fallback order: ${fallbackPick.provider}`);
} else {
failures += 1;
console.log(" [AUTO] No provider could unrestrict this link");
}
}
if (failures > 0) {
process.exitCode = 2;
}
}
await main().catch((e) => { console.error(e); process.exit(1); });

View File

@ -1,392 +0,0 @@
import fs from "node:fs";
import path from "node:path";
import { spawnSync } from "node:child_process";
const NPM_RELEASE_WIN = process.platform === "win32"
? {
command: process.env.ComSpec || "cmd.exe",
args: ["/d", "/s", "/c", "npm run release:win"]
}
: {
command: "npm",
args: ["run", "release:win"]
};
function run(command, args, options = {}) {
const result = spawnSync(command, args, {
cwd: process.cwd(),
encoding: "utf8",
stdio: options.capture ? ["pipe", "pipe", "pipe"] : "inherit"
});
if (result.status !== 0) {
const stderr = result.stderr ? String(result.stderr).trim() : "";
const stdout = result.stdout ? String(result.stdout).trim() : "";
const details = [stderr, stdout].filter(Boolean).join("\n");
throw new Error(`Command failed: ${command} ${args.join(" ")}${details ? `\n${details}` : ""}`);
}
return options.capture ? String(result.stdout || "") : "";
}
function runCapture(command, args) {
const result = spawnSync(command, args, {
cwd: process.cwd(),
encoding: "utf8",
stdio: ["pipe", "pipe", "pipe"]
});
if (result.status !== 0) {
const stderr = String(result.stderr || "").trim();
throw new Error(stderr || `Command failed: ${command} ${args.join(" ")}`);
}
return String(result.stdout || "").trim();
}
function runWithInput(command, args, input) {
const result = spawnSync(command, args, {
cwd: process.cwd(),
encoding: "utf8",
input,
stdio: ["pipe", "pipe", "pipe"],
timeout: 10000
});
if (result.status !== 0) {
const stderr = String(result.stderr || "").trim();
throw new Error(stderr || `Command failed: ${command} ${args.join(" ")}`);
}
return String(result.stdout || "");
}
function parseArgs(argv) {
const args = argv.slice(2);
if (args.includes("--help") || args.includes("-h")) {
return { help: true };
}
const dryRun = args.includes("--dry-run");
const cleaned = args.filter((arg) => arg !== "--dry-run");
const version = cleaned[0] || "";
const notes = cleaned.slice(1).join(" ").trim();
return { help: false, dryRun, version, notes };
}
function parseRemoteUrl(url) {
const raw = String(url || "").trim();
const httpsMatch = raw.match(/^https?:\/\/([^/]+)\/([^/]+)\/([^/]+?)(?:\.git)?$/i);
if (httpsMatch) {
return { host: httpsMatch[1], owner: httpsMatch[2], repo: httpsMatch[3] };
}
const sshMatch = raw.match(/^git@([^:]+):([^/]+)\/([^/]+?)(?:\.git)?$/i);
if (sshMatch) {
return { host: sshMatch[1], owner: sshMatch[2], repo: sshMatch[3] };
}
const sshAltMatch = raw.match(/^ssh:\/\/git@([^/:]+)(?::\d+)?\/([^/]+)\/([^/]+?)(?:\.git)?$/i);
if (sshAltMatch) {
return { host: sshAltMatch[1], owner: sshAltMatch[2], repo: sshAltMatch[3] };
}
throw new Error(`Cannot parse remote URL: ${raw}`);
}
function normalizeBaseUrl(url) {
const raw = String(url || "").trim().replace(/\/+$/, "");
if (!raw) {
return "";
}
if (!/^https?:\/\//i.test(raw)) {
throw new Error("GITEA_BASE_URL must start with http:// or https://");
}
return raw;
}
function getGiteaRepo() {
const forcedRemote = String(process.env.GITEA_REMOTE || process.env.FORGEJO_REMOTE || "").trim();
const remotes = forcedRemote
? [forcedRemote]
: ["gitea", "forgejo", "origin", "github-new", "codeberg"];
const preferredBase = normalizeBaseUrl(process.env.GITEA_BASE_URL || process.env.FORGEJO_BASE_URL || "https://git.24-music.de");
const preferredProtocol = preferredBase ? new URL(preferredBase).protocol : "https:";
for (const remote of remotes) {
try {
const remoteUrl = runCapture("git", ["remote", "get-url", remote]);
const parsed = parseRemoteUrl(remoteUrl);
const remoteBase = `https://${parsed.host}`.toLowerCase();
if (preferredBase && remoteBase !== preferredBase.toLowerCase().replace(/^http:/, "https:")) {
continue;
}
return { remote, ...parsed, baseUrl: `${preferredProtocol}//${parsed.host}` };
} catch {
}
}
if (preferredBase) {
throw new Error(
`No remote found for ${preferredBase}. Add one with: git remote add gitea ${preferredBase}/<owner>/<repo>.git`
);
}
throw new Error("No suitable remote found. Set GITEA_REMOTE or GITEA_BASE_URL.");
}
function getAuthHeader(host) {
const explicitToken = String(process.env.GITEA_TOKEN || process.env.FORGEJO_TOKEN || "").trim();
if (explicitToken) {
return `token ${explicitToken}`;
}
const credentialText = runWithInput("git", ["credential", "fill"], `protocol=https\nhost=${host}\n\n`);
const map = new Map();
for (const line of credentialText.split(/\r?\n/)) {
if (!line.includes("=")) {
continue;
}
const [key, value] = line.split("=", 2);
map.set(key, value);
}
const username = map.get("username") || "";
const password = map.get("password") || "";
if (!username || !password) {
throw new Error(
`Missing credentials for ${host}. Set GITEA_TOKEN or store credentials for this host in git credential helper.`
);
}
const token = Buffer.from(`${username}:${password}`, "utf8").toString("base64");
return `Basic ${token}`;
}
async function apiRequest(method, url, authHeader, body, contentType = "application/json") {
const headers = {
Accept: "application/json",
Authorization: authHeader
};
if (body !== undefined) {
headers["Content-Type"] = contentType;
}
const response = await fetch(url, {
method,
headers,
body
});
const text = await response.text();
let parsed;
try {
parsed = text ? JSON.parse(text) : null;
} catch {
parsed = text;
}
return { ok: response.ok, status: response.status, body: parsed };
}
function ensureVersionString(version) {
const trimmed = String(version || "").trim();
if (!/^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/.test(trimmed)) {
throw new Error("Invalid version format. Expected e.g. 1.4.42");
}
return trimmed;
}
function updatePackageVersion(rootDir, version) {
const packagePath = path.join(rootDir, "package.json");
const packageJson = JSON.parse(fs.readFileSync(packagePath, "utf8"));
if (String(packageJson.version || "") === version) {
process.stdout.write(`package.json is already at version ${version}, skipping update.\n`);
return;
}
packageJson.version = version;
fs.writeFileSync(packagePath, `${JSON.stringify(packageJson, null, 2)}\n`, "utf8");
}
function patchLatestYml(releaseDir, version) {
const ymlPath = path.join(releaseDir, "latest.yml");
let content = fs.readFileSync(ymlPath, "utf8");
const setupName = `Real-Debrid-Downloader Setup ${version}.exe`;
const dashedName = `Real-Debrid-Downloader-Setup-${version}.exe`;
if (content.includes(dashedName)) {
content = content.split(dashedName).join(setupName);
fs.writeFileSync(ymlPath, content, "utf8");
process.stdout.write(`Patched latest.yml: replaced "${dashedName}" with "${setupName}"\n`);
}
}
function ensureAssetsExist(rootDir, version) {
const releaseDir = path.join(rootDir, "release");
const files = [
`Real-Debrid-Downloader Setup ${version}.exe`,
`Real-Debrid-Downloader ${version}.exe`,
"latest.yml",
`Real-Debrid-Downloader Setup ${version}.exe.blockmap`
];
for (const fileName of files) {
const fullPath = path.join(releaseDir, fileName);
if (!fs.existsSync(fullPath)) {
throw new Error(`Missing release artifact: ${fullPath}`);
}
}
patchLatestYml(releaseDir, version);
return { releaseDir, files };
}
function ensureNoTrackedChanges() {
const output = runCapture("git", ["status", "--porcelain"]);
const lines = output.split(/\r?\n/).filter(Boolean);
const tracked = lines.filter((line) => !line.startsWith("?? "));
if (tracked.length > 0) {
throw new Error(`Working tree has tracked changes:\n${tracked.join("\n")}`);
}
}
function ensureTagMissing(tag) {
const result = spawnSync("git", ["rev-parse", "--verify", `refs/tags/${tag}`], {
cwd: process.cwd(),
stdio: "ignore"
});
if (result.status === 0) {
throw new Error(`Tag already exists: ${tag}`);
}
}
async function createOrGetRelease(baseApi, tag, authHeader, notes) {
const byTag = await apiRequest("GET", `${baseApi}/releases/tags/${encodeURIComponent(tag)}`, authHeader);
if (byTag.ok) {
return byTag.body;
}
const payload = {
tag_name: tag,
target_commitish: "main",
name: tag,
body: notes || `Release ${tag}`,
draft: true,
prerelease: false
};
const created = await apiRequest("POST", `${baseApi}/releases`, authHeader, JSON.stringify(payload));
if (created.ok) {
return created.body;
}
if (created.status === 409 || created.status === 422 || created.status === 500) {
const retry = await apiRequest("GET", `${baseApi}/releases/tags/${encodeURIComponent(tag)}`, authHeader);
if (retry.ok) {
process.stdout.write(`Release already exists, using existing release.\n`);
return retry.body;
}
}
throw new Error(`Failed to create release (${created.status}): ${JSON.stringify(created.body)}`);
}
async function uploadReleaseAssets(baseApi, releaseId, authHeader, releaseDir, files) {
const MAX_ATTEMPTS = 3;
for (const fileName of files) {
const filePath = path.join(releaseDir, fileName);
const fileSize = fs.statSync(filePath).size;
const uploadUrl = `${baseApi}/releases/${releaseId}/assets?name=${encodeURIComponent(fileName)}`;
for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt += 1) {
const fileStream = fs.createReadStream(filePath);
let response;
try {
response = await fetch(uploadUrl, {
method: "POST",
headers: {
Accept: "application/json",
Authorization: authHeader,
"Content-Type": "application/octet-stream",
"Content-Length": String(fileSize)
},
body: fileStream,
duplex: "half"
});
} catch (error) {
fileStream.destroy();
if (attempt < MAX_ATTEMPTS) {
process.stdout.write(`Upload ${fileName} abgebrochen (Netzwerk, Versuch ${attempt}/${MAX_ATTEMPTS}), neuer Versuch...\n`);
await new Promise((resolve) => setTimeout(resolve, 3000 * attempt));
continue;
}
throw new Error(`Asset upload failed for ${fileName} after ${MAX_ATTEMPTS} attempts: ${String(error?.message || error)}`);
}
const text = await response.text();
let parsed;
try {
parsed = text ? JSON.parse(text) : null;
} catch {
parsed = text;
}
if (response.ok) {
process.stdout.write(`Uploaded: ${fileName}\n`);
break;
}
if (response.status === 409 || response.status === 422) {
process.stdout.write(`Skipped existing asset: ${fileName}\n`);
break;
}
if (response.status >= 500 && attempt < MAX_ATTEMPTS) {
process.stdout.write(`Upload ${fileName} fehlgeschlagen (${response.status}, Versuch ${attempt}/${MAX_ATTEMPTS}), neuer Versuch...\n`);
await new Promise((resolve) => setTimeout(resolve, 3000 * attempt));
continue;
}
throw new Error(`Asset upload failed for ${fileName} (${response.status}): ${JSON.stringify(parsed)}`);
}
}
}
async function main() {
const rootDir = process.cwd();
const args = parseArgs(process.argv);
if (args.help) {
process.stdout.write("Usage: npm run release:gitea -- <version> [release notes] [--dry-run]\n");
process.stdout.write("Env: GITEA_BASE_URL, GITEA_REMOTE, GITEA_TOKEN\n");
process.stdout.write("Compatibility envs still supported: FORGEJO_BASE_URL, FORGEJO_REMOTE, FORGEJO_TOKEN\n");
process.stdout.write("Example: npm run release:gitea -- 1.6.31 \"- Bugfixes\"\n");
return;
}
const version = ensureVersionString(args.version);
const tag = `v${version}`;
const releaseNotes = args.notes || `- Release ${tag}`;
const repo = getGiteaRepo();
const tagExists = spawnSync("git", ["rev-parse", "--verify", `refs/tags/${tag}`], { cwd: process.cwd(), stdio: "ignore" }).status === 0;
if (tagExists) {
process.stdout.write(`Tag ${tag} already exists locally — skipping version bump and git operations (recovery mode).\n`);
} else {
ensureNoTrackedChanges();
if (args.dryRun) {
process.stdout.write(`Dry run: would release ${tag}. No changes made.\n`);
return;
}
updatePackageVersion(rootDir, version);
}
process.stdout.write(`Building release artifacts for ${tag}...\n`);
run(NPM_RELEASE_WIN.command, NPM_RELEASE_WIN.args);
const assets = ensureAssetsExist(rootDir, version);
if (!tagExists) {
run("git", ["add", "package.json"]);
run("git", ["commit", "-m", `Release ${tag}`]);
run("git", ["push", repo.remote, "main"]);
run("git", ["tag", tag]);
run("git", ["push", repo.remote, tag]);
}
const authHeader = getAuthHeader(repo.host);
const baseApi = `${repo.baseUrl}/api/v1/repos/${repo.owner}/${repo.repo}`;
const release = await createOrGetRelease(baseApi, tag, authHeader, releaseNotes);
await uploadReleaseAssets(baseApi, release.id, authHeader, assets.releaseDir, assets.files);
const published = await apiRequest("PATCH", `${baseApi}/releases/${release.id}`, authHeader, JSON.stringify({ draft: false }));
if (!published.ok) {
throw new Error(`Failed to publish release (${published.status}): ${JSON.stringify(published.body)}`);
}
process.stdout.write(`Release published: ${release.html_url || `${repo.baseUrl}/${repo.owner}/${repo.repo}/releases/tag/${tag}`}\n`);
}
main().catch((error) => {
process.stderr.write(`${String(error?.message || error)}\n`);
process.exit(1);
});

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

View File

@ -45,7 +45,7 @@ import { runInstallWithResume } from "./update-install-flow";
import { rotateDebugToken, startDebugServer, stopDebugServer, restartDebugServer, getDebugServerRuntimeStatus, getActiveDebugToken, getDebugAllowlist, writeDebugServerConfig, clearDebugToken } from "./debug-server"; import { rotateDebugToken, startDebugServer, stopDebugServer, restartDebugServer, getDebugServerRuntimeStatus, getActiveDebugToken, getDebugAllowlist, writeDebugServerConfig, clearDebugToken } from "./debug-server";
import { encodeConnectionCode, loadRemoteMeta, saveRemoteMeta } from "./connection-code"; import { encodeConnectionCode, loadRemoteMeta, saveRemoteMeta } from "./connection-code";
import { encryptBackup, decryptBackup } from "./backup-crypto"; import { encryptBackup, decryptBackup } from "./backup-crypto";
import { buildBackupPayload, planBackupImport, resolveMcpRemoteRestore, BackupMcpRemote } from "./backup-payload"; import { buildBackupPayload, planBackupImport, resolveRemoteDiagnosticsRestore, BackupRemoteDiagnostics } from "./backup-payload";
import { getAuditLogPath, initAuditLog, logAuditEvent, shutdownAuditLog } from "./audit-log"; import { getAuditLogPath, initAuditLog, logAuditEvent, shutdownAuditLog } from "./audit-log";
import { initAccountRotationLog, shutdownAccountRotationLog } from "./account-rotation-log"; import { initAccountRotationLog, shutdownAccountRotationLog } from "./account-rotation-log";
import { initConversionLog, shutdownConversionLog } from "./conversion-trace"; import { initConversionLog, shutdownConversionLog } from "./conversion-trace";
@ -379,8 +379,8 @@ export class AppController {
return this.getRemoteDiagnostics(); return this.getRemoteDiagnostics();
} }
private restoreMcpRemoteFromBackup(section: unknown, restartNow: boolean): void { private restoreRemoteDiagnosticsFromBackup(section: unknown, restartNow: boolean): void {
const restore = resolveMcpRemoteRestore(section); const restore = resolveRemoteDiagnosticsRestore(section);
if (!restore) { if (!restore) {
return; return;
} }
@ -748,10 +748,10 @@ public async checkDebridAccounts(): Promise<DebridAccountStatus[]> {
} }
public exportBackup(): Buffer { public exportBackup(): Buffer {
let mcpRemote: BackupMcpRemote | undefined; let remoteDiagnostics: BackupRemoteDiagnostics | undefined;
if (Boolean(this.settings.backupIncludeMcp)) { if (Boolean(this.settings.backupIncludeRemoteDiagnostics)) {
const status = getDebugServerRuntimeStatus(); const status = getDebugServerRuntimeStatus();
mcpRemote = { remoteDiagnostics = {
allowlist: getDebugAllowlist(), allowlist: getDebugAllowlist(),
port: status.port, port: status.port,
hostMode: status.host === "0.0.0.0" ? "network" : "local" hostMode: status.host === "0.0.0.0" ? "network" : "local"
@ -763,7 +763,7 @@ public async checkDebridAccounts(): Promise<DebridAccountStatus[]> {
exportedAt: new Date().toISOString(), exportedAt: new Date().toISOString(),
session: this.manager.getSession(), session: this.manager.getSession(),
history: loadHistoryForRetention(this.storagePaths, this.settings.historyRetentionMode, this.historyLimits()), history: loadHistoryForRetention(this.storagePaths, this.settings.historyRetentionMode, this.historyLimits()),
mcpRemote remoteDiagnostics
}); });
this.audit("INFO", "Backup exportiert", { this.audit("INFO", "Backup exportiert", {
kind: payloadObj.kind, kind: payloadObj.kind,
@ -837,7 +837,7 @@ public async checkDebridAccounts(): Promise<DebridAccountStatus[]> {
this.settings = restoredSettings; this.settings = restoredSettings;
saveSettings(this.storagePaths, this.settings); saveSettings(this.storagePaths, this.settings);
this.manager.setSettings(this.settings, { suppressRetroactiveCleanup: true }); this.manager.setSettings(this.settings, { suppressRetroactiveCleanup: true });
this.restoreMcpRemoteFromBackup(parsed.mcpRemote, true); this.restoreRemoteDiagnosticsFromBackup(parsed.remoteDiagnostics, true);
this.audit("INFO", "Backup importiert (nur Einstellungen)", { this.audit("INFO", "Backup importiert (nur Einstellungen)", {
accountSummary: buildAccountSummary(this.settings) accountSummary: buildAccountSummary(this.settings)
}); });
@ -874,7 +874,7 @@ public async checkDebridAccounts(): Promise<DebridAccountStatus[]> {
resetHistoryForRetention(this.storagePaths, this.settings.historyRetentionMode); resetHistoryForRetention(this.storagePaths, this.settings.historyRetentionMode);
this.restoreMcpRemoteFromBackup(parsed.mcpRemote, false); this.restoreRemoteDiagnosticsFromBackup(parsed.remoteDiagnostics, false);
this.manager.skipShutdownPersist = true; this.manager.skipShutdownPersist = true;
this.manager.blockAllPersistence = true; this.manager.blockAllPersistence = true;

View File

@ -2,7 +2,7 @@ import type { AppSettings, SessionState, HistoryEntry } from "../shared/types";
export type BackupKind = "full" | "settings-only"; export type BackupKind = "full" | "settings-only";
export interface BackupMcpRemote { export interface BackupRemoteDiagnostics {
allowlist: string[]; allowlist: string[];
port: number; port: number;
hostMode: "local" | "network"; hostMode: "local" | "network";
@ -16,7 +16,7 @@ export interface BackupPayload {
settings: AppSettings; settings: AppSettings;
session?: SessionState; session?: SessionState;
history?: HistoryEntry[]; history?: HistoryEntry[];
mcpRemote?: BackupMcpRemote; remoteDiagnostics?: BackupRemoteDiagnostics;
} }
export interface BuildBackupInput { export interface BuildBackupInput {
@ -26,7 +26,7 @@ export interface BuildBackupInput {
/** Only bundled when includeDownloads is true. */ /** Only bundled when includeDownloads is true. */
session: SessionState; session: SessionState;
history: HistoryEntry[]; history: HistoryEntry[];
mcpRemote?: BackupMcpRemote; remoteDiagnostics?: BackupRemoteDiagnostics;
} }
/** /**
@ -48,19 +48,19 @@ export function buildBackupPayload(input: BuildBackupInput): BackupPayload {
base.session = input.session; base.session = input.session;
base.history = input.history; base.history = input.history;
} }
if (Boolean(input.settings.backupIncludeMcp) && input.mcpRemote) { if (Boolean(input.settings.backupIncludeRemoteDiagnostics) && input.remoteDiagnostics) {
base.mcpRemote = input.mcpRemote; base.remoteDiagnostics = input.remoteDiagnostics;
} }
return base; return base;
} }
export interface McpRemoteRestore { export interface RemoteDiagnosticsRestore {
host?: "127.0.0.1" | "0.0.0.0"; host?: "127.0.0.1" | "0.0.0.0";
port?: number; port?: number;
allowlist?: string[]; allowlist?: string[];
} }
export function resolveMcpRemoteRestore(section: unknown): McpRemoteRestore | null { export function resolveRemoteDiagnosticsRestore(section: unknown): RemoteDiagnosticsRestore | null {
if (!section || typeof section !== "object") { if (!section || typeof section !== "object") {
return null; return null;
} }

View File

@ -37,7 +37,7 @@ export const MAX_LINK_ARTIFACT_BYTES = 256 * 1024;
export const SPEED_WINDOW_SECONDS = 1; export const SPEED_WINDOW_SECONDS = 1;
export const CLIPBOARD_POLL_INTERVAL_MS = 2000; export const CLIPBOARD_POLL_INTERVAL_MS = 2000;
export const DEFAULT_UPDATE_REPO = "Administrator/real-debrid-downloader"; export const DEFAULT_UPDATE_REPO = "Sucukdeluxe/multi-debrid-downloader";
export function defaultSettings(): AppSettings { export function defaultSettings(): AppSettings {
const baseDir = path.join(os.homedir(), "Downloads", "RealDebrid"); const baseDir = path.join(os.homedir(), "Downloads", "RealDebrid");
@ -109,7 +109,7 @@ export function defaultSettings(): AppSettings {
hideExtractedItems: true, hideExtractedItems: true,
confirmDeleteSelection: true, confirmDeleteSelection: true,
backupIncludeDownloads: false, backupIncludeDownloads: false,
backupIncludeMcp: false, backupIncludeRemoteDiagnostics: false,
notifyUrl: "", notifyUrl: "",
notifyMention: "", notifyMention: "",
notifyOnPackageCompleted: false, notifyOnPackageCompleted: false,

View File

@ -24,7 +24,7 @@ import type { DownloadItem, PackageEntry, UiSnapshot } from "../shared/types";
const DEFAULT_PORT = 9868; const DEFAULT_PORT = 9868;
const DEFAULT_HOST = "127.0.0.1"; const DEFAULT_HOST = "127.0.0.1";
const MAX_LOG_LINES = 10000; const MAX_LOG_LINES = 10000;
const AI_MANIFEST_FILE = "debug_ai_manifest.json"; const SUPPORT_MANIFEST_FILE = "debug_support_manifest.json";
type DebugEndpointDescriptor = { type DebugEndpointDescriptor = {
method: "GET"; method: "GET";
@ -103,12 +103,11 @@ function extractDebugClientIp(req: http.IncomingMessage): string {
if (realIp) { if (realIp) {
return realIp; return realIp;
} }
const remote = String(req.socket.remoteAddress || req.socket.address()?.address || "").trim(); return getPeerIp(req);
return remote.replace(/^::ffff:/i, "");
} }
function getAiManifestPath(baseDir: string = runtimeBaseDir): string { function getSupportManifestPath(baseDir: string = runtimeBaseDir): string {
return path.join(baseDir, AI_MANIFEST_FILE); return path.join(baseDir, SUPPORT_MANIFEST_FILE);
} }
function getDebugTokenPath(baseDir: string = runtimeBaseDir): string { function getDebugTokenPath(baseDir: string = runtimeBaseDir): string {
@ -343,26 +342,13 @@ function getEndpointSummaries(): string[] {
return DEBUG_ENDPOINTS.map((endpoint) => formatEndpointSummary(endpoint)); return DEBUG_ENDPOINTS.map((endpoint) => formatEndpointSummary(endpoint));
} }
function buildAiManifest(baseDir: string): Record<string, unknown> { function buildSupportManifest(baseDir: string): Record<string, unknown> {
const remoteHostHint = bindHost === "0.0.0.0"
? "Use the server IP or DNS name for remote access. Ask the user only for that host value if it is unknown."
: "If remote access is required and the bind host is local-only, switch debug_host.txt to 0.0.0.0 and reopen the firewall.";
return { return {
schemaVersion: 1, schemaVersion: 1,
generatedAt: new Date().toISOString(), generatedAt: new Date().toISOString(),
appVersion: APP_VERSION, appVersion: APP_VERSION,
runtimeBaseDir: baseDir, runtimeBaseDir: baseDir,
purpose: "Machine-readable support manifest for AI tools and remote troubleshooting.", purpose: "Machine-readable manifest for support tooling and remote troubleshooting.",
quickstart: [
"Read debug_token.txt and debug_port.txt from this runtime folder.",
"If remote access is needed, ask the user only for the server IP or DNS name.",
"Call /meta first to confirm the server is reachable and to re-read the endpoint list.",
"Use /self-check or /debug/setup to quickly verify whether token, host, manifest, trace, disk space, and log sizes are in a good support state.",
"Use /diagnostics for an overview, then drill into /logs/item, /logs/package, /logs/rename, /status, /packages, /items, /settings, /accounts, /stats, /history, or /logs/trace.",
"For provider stalls/cooldowns, call /providers for the live cooldown state (until/remaining/reason per account/key) and /logs/conversion for the per-item resolve lifecycle (token, API, web, rotation, aborts with timings).",
"If a full handoff is needed, download /support/bundle as a ZIP."
],
auth: { auth: {
required: true, required: true,
methods: [ methods: [
@ -392,13 +378,15 @@ function buildAiManifest(baseDir: string): Record<string, unknown> {
host: bindHost, host: bindHost,
port: bindPort, port: bindPort,
localBaseUrl: `http://127.0.0.1:${bindPort}`, localBaseUrl: `http://127.0.0.1:${bindPort}`,
remoteBaseUrlTemplate: `http://<SERVER_IP_OR_DNS>:${bindPort}`, remoteBaseUrlTemplate: `http://<SERVER_IP_OR_DNS>:${bindPort}`
remoteHostHint
}, },
setupCheckEndpoint: "/debug/setup", setupCheckEndpoint: "/debug/setup",
selfCheckEndpoint: "/self-check", selfCheckEndpoint: "/self-check",
askUserFor: [ remoteAccessRequirements: [
"Server IP or DNS name, if remote access is required and not already known." "A reachable server IP or DNS name.",
"The configured diagnostics port.",
"The token stored in debug_token.txt.",
"A network route and firewall rule that permit access to the configured port."
], ],
endpoints: DEBUG_ENDPOINTS.map((endpoint) => ({ endpoints: DEBUG_ENDPOINTS.map((endpoint) => ({
...endpoint, ...endpoint,
@ -407,11 +395,11 @@ function buildAiManifest(baseDir: string): Record<string, unknown> {
}; };
} }
function writeAiManifest(baseDir: string): void { function writeSupportManifest(baseDir: string): void {
try { try {
fs.writeFileSync(getAiManifestPath(baseDir), JSON.stringify(buildAiManifest(baseDir), null, 2), "utf8"); fs.writeFileSync(getSupportManifestPath(baseDir), JSON.stringify(buildSupportManifest(baseDir), null, 2), "utf8");
} catch (error) { } catch (error) {
logger.warn(`Debug-Server: KI-Support-Datei konnte nicht geschrieben werden: ${String(error)}`); logger.warn(`Debug-Server: Support-Manifest konnte nicht geschrieben werden: ${String(error)}`);
} }
} }
@ -421,7 +409,7 @@ export function rotateDebugToken(baseDir: string = runtimeBaseDir): { path: stri
fs.writeFileSync(tokenPath, `${token}\n`, "utf8"); fs.writeFileSync(tokenPath, `${token}\n`, "utf8");
if (baseDir === runtimeBaseDir) { if (baseDir === runtimeBaseDir) {
authToken = token; authToken = token;
writeAiManifest(baseDir); writeSupportManifest(baseDir);
} }
logger.info(`Debug-Server Token rotiert: ${tokenPath}`); logger.info(`Debug-Server Token rotiert: ${tokenPath}`);
logTraceEvent("INFO", "support", "Debug-Token rotiert", { tokenPath }); logTraceEvent("INFO", "support", "Debug-Token rotiert", { tokenPath });
@ -608,7 +596,7 @@ function handleRequest(req: http.IncomingMessage, res: http.ServerResponse): voi
port: bindPort port: bindPort
}, },
supportFiles: { supportFiles: {
aiManifest: getAiManifestPath(), supportManifest: getSupportManifestPath(),
traceConfig: getTraceConfigPath(), traceConfig: getTraceConfigPath(),
traceLog: getTraceLogPath() traceLog: getTraceLogPath()
}, },
@ -1073,7 +1061,7 @@ function openServerSocket(): Promise<void> {
bindPort = getPort(runtimeBaseDir); bindPort = getPort(runtimeBaseDir);
bindHost = getHost(runtimeBaseDir); bindHost = getHost(runtimeBaseDir);
allowlist = loadAllowlist(runtimeBaseDir); allowlist = loadAllowlist(runtimeBaseDir);
writeAiManifest(runtimeBaseDir); writeSupportManifest(runtimeBaseDir);
if (!authToken) { if (!authToken) {
logger.info("Debug-Server: Kein Token in debug_token.txt, Server wird nicht gestartet"); logger.info("Debug-Server: Kein Token in debug_token.txt, Server wird nicht gestartet");
resolve(); resolve();
@ -1177,7 +1165,7 @@ export function clearDebugToken(): void {
} catch { } catch {
} }
authToken = ""; authToken = "";
writeAiManifest(runtimeBaseDir); writeSupportManifest(runtimeBaseDir);
} }
export function stopDebugServer(): void { export function stopDebugServer(): void {

View File

@ -14,7 +14,7 @@ import type {
const DEFAULT_PORT = 9868; const DEFAULT_PORT = 9868;
const DEFAULT_HOST = "127.0.0.1"; const DEFAULT_HOST = "127.0.0.1";
const AI_MANIFEST_FILE = "debug_ai_manifest.json"; 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_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_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 LOW_FREE_PERCENT_BYTES_GUARD = Number(process.env.RD_SELF_CHECK_LOW_FREE_PERCENT_BYTES_GUARD || 50 * 1024 * 1024 * 1024);
@ -259,7 +259,7 @@ function getSupportBundleEstimate(
): SupportBundleEstimate { ): SupportBundleEstimate {
const storagePaths = createStoragePaths(baseDir); const storagePaths = createStoragePaths(baseDir);
const staticFiles = [ const staticFiles = [
path.join(baseDir, AI_MANIFEST_FILE), path.join(baseDir, SUPPORT_MANIFEST_FILE),
path.join(baseDir, "debug_host.txt"), path.join(baseDir, "debug_host.txt"),
path.join(baseDir, "debug_port.txt"), path.join(baseDir, "debug_port.txt"),
storagePaths.configFile, storagePaths.configFile,
@ -302,7 +302,7 @@ export function getDebugSetupCheck(baseDir: string): DebugSetupCheckResult {
const storagePaths = createStoragePaths(baseDir); const storagePaths = createStoragePaths(baseDir);
const settings = loadSettings(storagePaths); const settings = loadSettings(storagePaths);
const tokenPath = path.join(baseDir, "debug_token.txt"); const tokenPath = path.join(baseDir, "debug_token.txt");
const aiManifestPath = path.join(baseDir, AI_MANIFEST_FILE); const supportManifestPath = path.join(baseDir, SUPPORT_MANIFEST_FILE);
const traceConfigPath = path.join(baseDir, "trace_config.json"); const traceConfigPath = path.join(baseDir, "trace_config.json");
const traceLogPath = path.join(baseDir, "trace.log"); const traceLogPath = path.join(baseDir, "trace.log");
const traceConfig = readTraceConfig(baseDir); const traceConfig = readTraceConfig(baseDir);
@ -356,8 +356,8 @@ export function getDebugSetupCheck(baseDir: string): DebugSetupCheckResult {
} else { } else {
notes.push("Der Debug-Server ist für Remote-Zugriff konfiguriert. Firewall oder Provider-Regeln müssen separat offen sein."); notes.push("Der Debug-Server ist für Remote-Zugriff konfiguriert. Firewall oder Provider-Regeln müssen separat offen sein.");
} }
if (!fs.existsSync(aiManifestPath)) { if (!fs.existsSync(supportManifestPath)) {
warnings.push("debug_ai_manifest.json fehlt. App einmal neu starten, damit die KI-Support-Datei neu geschrieben wird."); warnings.push("debug_support_manifest.json fehlt. App einmal neu starten, damit das Support-Manifest neu geschrieben wird.");
} }
if (!fs.existsSync(traceConfigPath)) { if (!fs.existsSync(traceConfigPath)) {
warnings.push("trace_config.json fehlt. Trace-Funktionen sind lokal noch nicht initialisiert."); warnings.push("trace_config.json fehlt. Trace-Funktionen sind lokal noch nicht initialisiert.");
@ -410,8 +410,8 @@ export function getDebugSetupCheck(baseDir: string): DebugSetupCheckResult {
localOnly, localOnly,
tokenConfigured: Boolean(token), tokenConfigured: Boolean(token),
tokenPath, tokenPath,
aiManifestPath, supportManifestPath,
aiManifestPresent: fs.existsSync(aiManifestPath), supportManifestPresent: fs.existsSync(supportManifestPath),
traceConfigPath: fs.existsSync(traceConfigPath) ? traceConfigPath : null, traceConfigPath: fs.existsSync(traceConfigPath) ? traceConfigPath : null,
traceLogPath: fs.existsSync(traceLogPath) ? traceLogPath : null, traceLogPath: fs.existsSync(traceLogPath) ? traceLogPath : null,
traceEnabled: traceConfig.enabled, traceEnabled: traceConfig.enabled,

View File

@ -2196,7 +2196,7 @@ export class DownloadManager extends EventEmitter {
const credChanges: Array<{ prev: string; next: string; providers: string[] }> = [ const credChanges: Array<{ prev: string; next: string; providers: string[] }> = [
{ prev: previous.token || "", next: next.token || "", providers: ["realdebrid"] }, { prev: previous.token || "", next: next.token || "", providers: ["realdebrid"] },
{ prev: previous.allDebridToken || "", next: next.allDebridToken || "", providers: ["alldebrid"] }, { prev: previous.allDebridToken || "", next: next.allDebridToken || "", providers: ["alldebrid"] },
{ prev: previous.bestDebridApiKey || "", next: next.bestDebridApiKey || "", providers: ["bestdebrid"] }, { prev: previous.bestToken || "", next: next.bestToken || "", providers: ["bestdebrid"] },
{ prev: previous.debridLinkApiKeys || "", next: next.debridLinkApiKeys || "", providers: ["debridlink"] }, { prev: previous.debridLinkApiKeys || "", next: next.debridLinkApiKeys || "", providers: ["debridlink"] },
{ prev: previous.linkSnappyLogin + "|" + previous.linkSnappyPassword, next: next.linkSnappyLogin + "|" + next.linkSnappyPassword, providers: ["linksnappy"] }, { prev: previous.linkSnappyLogin + "|" + previous.linkSnappyPassword, next: next.linkSnappyLogin + "|" + next.linkSnappyPassword, providers: ["linksnappy"] },
{ prev: previous.ddownloadLogin + "|" + previous.ddownloadPassword, next: next.ddownloadLogin + "|" + next.ddownloadPassword, providers: ["ddownload"] }, { prev: previous.ddownloadLogin + "|" + previous.ddownloadPassword, next: next.ddownloadLogin + "|" + next.ddownloadPassword, providers: ["ddownload"] },
@ -11761,11 +11761,12 @@ export class DownloadManager extends EventEmitter {
} }
try { try {
const stat = await fs.promises.stat(item.targetPath); const stat = await fs.promises.stat(item.targetPath);
const minSize = expectedMinBytes(item.totalBytes, isLargeBinaryLikePath(item.fileName || item.targetPath)); const totalBytes = item.totalBytes;
const minSize = expectedMinBytes(totalBytes, isLargeBinaryLikePath(item.fileName || item.targetPath));
const persistedBytes = Math.max(0, Math.floor(Number(item.downloadedBytes) || 0)); const persistedBytes = Math.max(0, Math.floor(Number(item.downloadedBytes) || 0));
const preallocMismatchThreshold = resolvePreallocResumeMismatchThreshold(item.fileName || item.targetPath || ""); const preallocMismatchThreshold = resolvePreallocResumeMismatchThreshold(item.fileName || item.targetPath || "");
const suspiciousPreallocFootprint = item.totalBytes != null const suspiciousPreallocFootprint = totalBytes != null
&& item.totalBytes > 0 && totalBytes > 0
&& stat.size >= minSize && stat.size >= minSize
&& stat.size > persistedBytes + preallocMismatchThreshold; && stat.size > persistedBytes + preallocMismatchThreshold;
if (stat.size >= minSize) { if (stat.size >= minSize) {
@ -11777,7 +11778,7 @@ export class DownloadManager extends EventEmitter {
if (suspiciousPreallocFootprint) { if (suspiciousPreallocFootprint) {
logger.warn( logger.warn(
`Item-Recovery: ${item.fileName} uebersprungen pre-alloc-Verdacht ` + `Item-Recovery: ${item.fileName} uebersprungen pre-alloc-Verdacht ` +
`(stat=${humanSize(stat.size)}, bytes=${humanSize(persistedBytes)}, total=${humanSize(item.totalBytes)})` `(stat=${humanSize(stat.size)}, bytes=${humanSize(persistedBytes)}, total=${humanSize(totalBytes)})`
); );
try { try {
if (persistedBytes > 0) { if (persistedBytes > 0) {
@ -11790,8 +11791,8 @@ export class DownloadManager extends EventEmitter {
item.status = "queued"; item.status = "queued";
item.attempts = 0; item.attempts = 0;
item.downloadedBytes = persistedBytes; item.downloadedBytes = persistedBytes;
item.progressPercent = item.totalBytes > 0 item.progressPercent = totalBytes > 0
? Math.max(0, Math.min(99, Math.floor((persistedBytes / item.totalBytes) * 100))) ? Math.max(0, Math.min(99, Math.floor((persistedBytes / totalBytes) * 100)))
: 0; : 0;
item.speedBps = 0; item.speedBps = 0;
item.fullStatus = "Wartet (Auto-Recovery: pre-alloc)"; item.fullStatus = "Wartet (Auto-Recovery: pre-alloc)";

View File

@ -108,6 +108,13 @@ export type ExtractErrorCategory =
| "no_extractor" | "no_extractor"
| "unknown"; | "unknown";
export class ExtractionError extends Error {
constructor(message: string, public readonly category: ExtractErrorCategory) {
super(message);
this.name = "ExtractionError";
}
}
type ExtractionErrorWithHints = Error & { type ExtractionErrorWithHints = Error & {
suggestRedownload?: boolean; suggestRedownload?: boolean;
jvmFailureReason?: string; jvmFailureReason?: string;
@ -811,7 +818,8 @@ function withExtractionErrorHints(
return enhanced; return enhanced;
} }
export function classifyExtractionError(errorText: string): ExtractErrorCategory { export function classifyExtractionError(errorText: unknown): ExtractErrorCategory {
if (errorText instanceof ExtractionError) return errorText.category;
const text = String(errorText || "").toLowerCase(); const text = String(errorText || "").toLowerCase();
if (text.includes("aborted:extract") || text.includes("extract_aborted")) return "aborted"; if (text.includes("aborted:extract") || text.includes("extract_aborted")) return "aborted";
if (text.includes("timeout")) return "timeout"; if (text.includes("timeout")) return "timeout";
@ -819,7 +827,7 @@ export function classifyExtractionError(errorText: string): ExtractErrorCategory
if (text.includes("wrong password") || text.includes("falsches passwort") || text.includes("incorrect password")) return "wrong_password"; if (text.includes("wrong password") || text.includes("falsches passwort") || text.includes("incorrect password")) return "wrong_password";
if (text.includes("missing volume") || text.includes("next volume") || text.includes("unexpected end of archive") || text.includes("missing parts")) return "missing_parts"; if (text.includes("missing volume") || text.includes("next volume") || text.includes("unexpected end of archive") || text.includes("missing parts")) return "missing_parts";
if (text.includes("nicht gefunden") || text.includes("not found") || text.includes("no extractor")) return "no_extractor"; if (text.includes("nicht gefunden") || text.includes("not found") || text.includes("no extractor")) return "no_extractor";
if (text.includes("kein rar-archiv") || text.includes("not a rar archive") || text.includes("unsupported") || text.includes("unsupportedmethod")) return "unsupported_format"; if (isUnsupportedArchiveFormatError(text)) return "unsupported_format";
if (text.includes("disk full") || text.includes("speicherplatz") || text.includes("no space left") || text.includes("not enough space")) return "disk_full"; if (text.includes("disk full") || text.includes("speicherplatz") || text.includes("no space left") || text.includes("not enough space")) return "disk_full";
return "unknown"; return "unknown";
} }
@ -885,7 +893,9 @@ function isUnsupportedArchiveFormatError(errorText: string): boolean {
const text = String(errorText || "").toLowerCase(); const text = String(errorText || "").toLowerCase();
return text.includes("kein rar-archiv") return text.includes("kein rar-archiv")
|| text.includes("not a rar archive") || text.includes("not a rar archive")
|| text.includes("is not a rar archive"); || text.includes("is not a rar archive")
|| text.includes("is not archive")
|| text.includes("unsupported");
} }
function isUnsupportedExtractorSwitchError(errorText: string): boolean { function isUnsupportedExtractorSwitchError(errorText: string): boolean {
@ -2556,6 +2566,14 @@ function shouldFallbackToExternalZip(error: unknown): boolean {
return true; return true;
} }
export function selectZipFallbackError(internalError: unknown, externalError: unknown): unknown {
const category = classifyExtractionError(externalError);
if (category === "no_extractor" || category === "unsupported_format") {
return internalError;
}
return externalError;
}
async function extractZipArchive(archivePath: string, targetDir: string, conflictMode: ConflictMode, signal?: AbortSignal): Promise<void> { async function extractZipArchive(archivePath: string, targetDir: string, conflictMode: ConflictMode, signal?: AbortSignal): Promise<void> {
const mode = effectiveConflictMode(conflictMode); const mode = effectiveConflictMode(conflictMode);
const memoryLimitBytes = zipEntryMemoryLimitBytes(); const memoryLimitBytes = zipEntryMemoryLimitBytes();
@ -3091,10 +3109,7 @@ export async function extractPackageArchives(options: ExtractOptions): Promise<{
}, options.signal, hybrid, onPwAttempt, false, undefined, options.onLog); }, options.signal, hybrid, onPwAttempt, false, undefined, options.onLog);
rememberLearnedPassword(usedPassword); rememberLearnedPassword(usedPassword);
} catch (externalError) { } catch (externalError) {
if (isNoExtractorError(String(externalError)) || isUnsupportedArchiveFormatError(String(externalError))) { throw selectZipFallbackError(error, externalError);
throw error;
}
throw externalError;
} }
} }
} }

View File

@ -128,7 +128,7 @@ function createWindow(): BrowserWindow {
responseHeaders: { responseHeaders: {
...details.responseHeaders, ...details.responseHeaders,
"Content-Security-Policy": [ "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://git.24-music.de https://ddownload.com https://ddl.to https://debrid-link.com" "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"
] ]
} }
}); });

View File

@ -461,7 +461,7 @@ export function normalizeSettings(settings: AppSettings): AppSettings {
hideExtractedItems: settings.hideExtractedItems !== undefined ? Boolean(settings.hideExtractedItems) : defaults.hideExtractedItems, hideExtractedItems: settings.hideExtractedItems !== undefined ? Boolean(settings.hideExtractedItems) : defaults.hideExtractedItems,
confirmDeleteSelection: settings.confirmDeleteSelection !== undefined ? Boolean(settings.confirmDeleteSelection) : defaults.confirmDeleteSelection, confirmDeleteSelection: settings.confirmDeleteSelection !== undefined ? Boolean(settings.confirmDeleteSelection) : defaults.confirmDeleteSelection,
backupIncludeDownloads: settings.backupIncludeDownloads !== undefined ? Boolean(settings.backupIncludeDownloads) : defaults.backupIncludeDownloads, backupIncludeDownloads: settings.backupIncludeDownloads !== undefined ? Boolean(settings.backupIncludeDownloads) : defaults.backupIncludeDownloads,
backupIncludeMcp: settings.backupIncludeMcp !== undefined ? Boolean(settings.backupIncludeMcp) : defaults.backupIncludeMcp, backupIncludeRemoteDiagnostics: settings.backupIncludeRemoteDiagnostics !== undefined ? Boolean(settings.backupIncludeRemoteDiagnostics) : defaults.backupIncludeRemoteDiagnostics,
notifyUrl: asText(settings.notifyUrl) || defaults.notifyUrl, notifyUrl: asText(settings.notifyUrl) || defaults.notifyUrl,
notifyMention: asText(settings.notifyMention) || defaults.notifyMention, notifyMention: asText(settings.notifyMention) || defaults.notifyMention,
notifyOnPackageCompleted: settings.notifyOnPackageCompleted !== undefined ? Boolean(settings.notifyOnPackageCompleted) : defaults.notifyOnPackageCompleted, notifyOnPackageCompleted: settings.notifyOnPackageCompleted !== undefined ? Boolean(settings.notifyOnPackageCompleted) : defaults.notifyOnPackageCompleted,

View File

@ -18,7 +18,7 @@ import { getTraceConfig, getTraceConfigPath, getTraceLogPath } from "./trace-log
import { getCachedWindowsHostDiagnostics, getWindowsHostDiagnostics } from "./windows-host-diagnostics"; import { getCachedWindowsHostDiagnostics, getWindowsHostDiagnostics } from "./windows-host-diagnostics";
import type { DownloadManager } from "./download-manager"; import type { DownloadManager } from "./download-manager";
const AI_MANIFEST_FILE = "debug_ai_manifest.json"; const SUPPORT_MANIFEST_FILE = "debug_support_manifest.json";
async function safeReadJson(filePath: string): Promise<unknown> { async function safeReadJson(filePath: string): Promise<unknown> {
try { try {
@ -183,7 +183,7 @@ export async function buildSupportBundle(manager: DownloadManager, baseDir: stri
const recentErrors = getRecentErrors(); const recentErrors = getRecentErrors();
addJson(zip, "overview/recent-errors.json", { count: recentErrors.length, entries: recentErrors }); addJson(zip, "overview/recent-errors.json", { count: recentErrors.length, entries: recentErrors });
await addFileIfExists(zip, path.join(baseDir, AI_MANIFEST_FILE), `runtime/${AI_MANIFEST_FILE}`); 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_host.txt"), "runtime/debug_host.txt");
await addFileIfExists(zip, path.join(baseDir, "debug_port.txt"), "runtime/debug_port.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, getTraceConfigPath(), "runtime/trace_config.json");
@ -215,9 +215,9 @@ export async function buildSupportBundle(manager: DownloadManager, baseDir: stri
await addFileIfExists(zip, manager.getItemLogPath(itemId), `logs/live/item-${itemId}.txt`); await addFileIfExists(zip, manager.getItemLogPath(itemId), `logs/live/item-${itemId}.txt`);
} }
const aiManifest = await safeReadJson(path.join(baseDir, AI_MANIFEST_FILE)); const supportManifest = await safeReadJson(path.join(baseDir, SUPPORT_MANIFEST_FILE));
if (aiManifest) { if (supportManifest) {
addJson(zip, "overview/ai-manifest.json", aiManifest); addJson(zip, "overview/support-manifest.json", supportManifest);
} }
return zip.toBuffer(); return zip.toBuffer();

View File

@ -37,9 +37,7 @@ type ExpectedDigest = {
}; };
const UPDATE_SOURCES: UpdateSource[] = [ const UPDATE_SOURCES: UpdateSource[] = [
{ name: "git24", webBase: "https://git.24-music.de", apiBase: "https://git.24-music.de/api/v1" }, { name: "github", webBase: "https://github.com", apiBase: "https://api.github.com" }
{ name: "codeberg", webBase: "https://codeberg.org", apiBase: "https://codeberg.org/api/v1" },
{ name: "github", webBase: "https://github.com", apiBase: "https://api.github.com" },
]; ];
const PRIMARY_SOURCE = UPDATE_SOURCES[0]; const PRIMARY_SOURCE = UPDATE_SOURCES[0];
@ -82,9 +80,9 @@ function isValidRepoPart(value: string): boolean {
function extractOwnerRepo(input: string): string { function extractOwnerRepo(input: string): string {
const cleaned = input const cleaned = input
.replace(/^https?:\/\/(?:www\.)?(?:codeberg\.org|github\.com|git\.24-music\.de)\//i, "") .replace(/^https?:\/\/(?:www\.)?(?:codeberg\.org|github\.com)\//i, "")
.replace(/^(?:www\.)?(?:codeberg\.org|github\.com|git\.24-music\.de)\//i, "") .replace(/^(?:www\.)?(?:codeberg\.org|github\.com)\//i, "")
.replace(/^git@(?:codeberg\.org|github\.com|git\.24-music\.de):/i, "") .replace(/^git@(?:codeberg\.org|github\.com):/i, "")
.replace(/\.git$/i, "") .replace(/\.git$/i, "")
.replace(/^\/+|\/+$/g, ""); .replace(/^\/+|\/+$/g, "");
const parts = cleaned.split("/").filter(Boolean); const parts = cleaned.split("/").filter(Boolean);
@ -106,7 +104,6 @@ export function normalizeUpdateRepo(repo: string): string {
|| host === "www.codeberg.org" || host === "www.codeberg.org"
|| host === "github.com" || host === "github.com"
|| host === "www.github.com" || host === "www.github.com"
|| host === "git.24-music.de"
) { ) {
const result = extractOwnerRepo(url.pathname); const result = extractOwnerRepo(url.pathname);
if (result) return result; if (result) return result;

View File

@ -172,7 +172,7 @@ function buildDebugSetupDetails(setup: DebugSetupCheckResult): string {
`Host: ${setup.host}`, `Host: ${setup.host}`,
`Port: ${setup.port}`, `Port: ${setup.port}`,
`Token-Datei: ${setup.tokenPath}`, `Token-Datei: ${setup.tokenPath}`,
`KI-Manifest: ${setup.aiManifestPresent ? "vorhanden" : "fehlt"} (${setup.aiManifestPath})`, `Support-Manifest: ${setup.supportManifestPresent ? "vorhanden" : "fehlt"} (${setup.supportManifestPath})`,
`Trace aktiv: ${setup.traceEnabled ? "ja" : "nein"}`, `Trace aktiv: ${setup.traceEnabled ? "ja" : "nein"}`,
`Trace-Auto-Ende: ${setup.traceAutoDisableAt || "nicht gesetzt"}`, `Trace-Auto-Ende: ${setup.traceAutoDisableAt || "nicht gesetzt"}`,
"", "",
@ -856,7 +856,7 @@ const emptySnapshot = (): UiSnapshot => ({
autoReconnect: false, reconnectWaitSeconds: 45, completedCleanupPolicy: "never", autoReconnect: false, reconnectWaitSeconds: 45, completedCleanupPolicy: "never",
maxParallel: 4, maxParallelExtract: 2, extractCpuPriority: "high", retryLimit: 0, speedLimitEnabled: false, speedLimitKbps: 0, speedLimitMode: "global", maxParallel: 4, maxParallelExtract: 2, extractCpuPriority: "high", retryLimit: 0, speedLimitEnabled: false, speedLimitKbps: 0, speedLimitMode: "global",
updateRepo: "", autoUpdateCheck: true, clipboardWatch: false, minimizeToTray: false, updateRepo: "", autoUpdateCheck: true, clipboardWatch: false, minimizeToTray: false,
theme: "dark", collapseNewPackages: true, historyRetentionMode: "permanent", historyMaxEntries: 500, historyMaxAgeDays: 0, autoSortPackagesByProgress: true, autoSkipExtracted: false, hideExtractedItems: true, confirmDeleteSelection: true, backupIncludeDownloads: false, backupIncludeMcp: false, theme: "dark", collapseNewPackages: true, historyRetentionMode: "permanent", historyMaxEntries: 500, historyMaxAgeDays: 0, autoSortPackagesByProgress: true, autoSkipExtracted: false, hideExtractedItems: true, confirmDeleteSelection: true, backupIncludeDownloads: false, backupIncludeRemoteDiagnostics: false,
notifyUrl: "", notifyMention: "", notifyOnPackageCompleted: false, notifyOnPackageFailed: false, notifyOnRunFinished: false, notifyUrl: "", notifyMention: "", notifyOnPackageCompleted: false, notifyOnPackageFailed: false, notifyOnRunFinished: false,
accountListShowDetailedDebridLinkKeys: false, accountListShowDetailedDebridLinkKeys: false,
bandwidthSchedules: [], totalDownloadedAllTime: 0, totalCompletedFilesAllTime: 0, totalRuntimeAllTimeMs: 0, bandwidthSchedules: [], totalDownloadedAllTime: 0, totalCompletedFilesAllTime: 0, totalRuntimeAllTimeMs: 0,
@ -4810,7 +4810,7 @@ export function App(): ReactElement {
<button className="menu-submenu-trigger">Remote-Support</button> <button className="menu-submenu-trigger">Remote-Support</button>
{openSubmenu === "hilfe-remote" && ( {openSubmenu === "hilfe-remote" && (
<div className="menu-submenu-dropdown"> <div className="menu-submenu-dropdown">
<button className="menu-dropdown-item" onClick={() => { void onOpenRemoteDiagnostics(); }}><span>Ferndiagnose (MCP) </span></button> <button className="menu-dropdown-item" onClick={() => { void onOpenRemoteDiagnostics(); }}><span>Ferndiagnose </span></button>
<button className="menu-dropdown-item" onClick={() => { void onExportSupportBundle(); }}><span>Support-Bundle exportieren</span></button> <button className="menu-dropdown-item" onClick={() => { void onExportSupportBundle(); }}><span>Support-Bundle exportieren</span></button>
<button className="menu-dropdown-item" onClick={() => { void onToggleSupportTrace(); }}><span>{supportTraceEnabled ? "Support-Trace deaktivieren" : "Support-Trace aktivieren"}</span></button> <button className="menu-dropdown-item" onClick={() => { void onToggleSupportTrace(); }}><span>{supportTraceEnabled ? "Support-Trace deaktivieren" : "Support-Trace aktivieren"}</span></button>
</div> </div>
@ -5442,7 +5442,7 @@ export function App(): ReactElement {
<div className="setting-hint">Sicherheitsabfrage vor dem Entfernen ausgewählter Einträge.</div> <div className="setting-hint">Sicherheitsabfrage vor dem Entfernen ausgewählter Einträge.</div>
<label className="toggle-line"><input type="checkbox" checked={settingsDraft.backupIncludeDownloads} onChange={(e) => setBool("backupIncludeDownloads", e.target.checked)} /> Download-Liste mitsichern</label> <label className="toggle-line"><input type="checkbox" checked={settingsDraft.backupIncludeDownloads} onChange={(e) => setBool("backupIncludeDownloads", e.target.checked)} /> Download-Liste mitsichern</label>
<div className="setting-hint">Sicherung enthält auch die Download-Liste; Standard: nur Einstellungen.</div> <div className="setting-hint">Sicherung enthält auch die Download-Liste; Standard: nur Einstellungen.</div>
<label className="toggle-line"><input type="checkbox" checked={settingsDraft.backupIncludeMcp} onChange={(e) => setBool("backupIncludeMcp", e.target.checked)} /> Ferndiagnose-Einstellungen mitsichern</label> <label className="toggle-line"><input type="checkbox" checked={settingsDraft.backupIncludeRemoteDiagnostics} onChange={(e) => setBool("backupIncludeRemoteDiagnostics", e.target.checked)} /> Ferndiagnose-Einstellungen mitsichern</label>
<div className="setting-hint">Allowlist, Port und Freigabemodus (lokal/Netzwerk) reisen mit. Verbindungs-Token und eigene Adresse bleiben pro Server nach dem Import einmal Aktivieren" drücken.</div> <div className="setting-hint">Allowlist, Port und Freigabemodus (lokal/Netzwerk) reisen mit. Verbindungs-Token und eigene Adresse bleiben pro Server nach dem Import einmal Aktivieren" drücken.</div>
<label className="toggle-line"><input type="checkbox" checked={settingsDraft.theme === "light"} onChange={(e) => { <label className="toggle-line"><input type="checkbox" checked={settingsDraft.theme === "light"} onChange={(e) => {
const next = e.target.checked ? "light" : "dark"; const next = e.target.checked ? "light" : "dark";
@ -6032,8 +6032,8 @@ export function App(): ReactElement {
{remoteDiagOpen && ( {remoteDiagOpen && (
<div className="modal-backdrop" onClick={() => setRemoteDiagOpen(false)}> <div className="modal-backdrop" onClick={() => setRemoteDiagOpen(false)}>
<div className="modal-card" onClick={(event) => event.stopPropagation()}> <div className="modal-card" onClick={(event) => event.stopPropagation()}>
<h3>Ferndiagnose (MCP)</h3> <h3>Ferndiagnose</h3>
<p>Aktiviert einen abgesicherten Lesezugriff auf Status, Logs und Fehler dieses Servers. Den Verbindungscode dem Assistenten geben - er verbindet sich, sieht alles und behebt Probleme.</p> <p>Ermöglicht einer vertrauenswürdigen Support-Stelle den geschützten Lesezugriff auf Status, Logs und Fehler. Der Verbindungscode enthält das Zugriffstoken und ist wie ein Passwort zu behandeln.</p>
<div className="rd-status-line"> <div className="rd-status-line">
<span className={`rd-dot${remoteDiag?.status.running ? " on" : ""}`} /> <span className={`rd-dot${remoteDiag?.status.running ? " on" : ""}`} />
<span> <span>

View File

@ -134,7 +134,7 @@ export interface AppSettings {
hideExtractedItems: boolean; hideExtractedItems: boolean;
confirmDeleteSelection: boolean; confirmDeleteSelection: boolean;
backupIncludeDownloads: boolean; backupIncludeDownloads: boolean;
backupIncludeMcp: boolean; backupIncludeRemoteDiagnostics: boolean;
notifyUrl: string; notifyUrl: string;
notifyMention: string; notifyMention: string;
notifyOnPackageCompleted: boolean; notifyOnPackageCompleted: boolean;
@ -469,8 +469,8 @@ export interface DebugSetupCheckResult {
localOnly: boolean; localOnly: boolean;
tokenConfigured: boolean; tokenConfigured: boolean;
tokenPath: string; tokenPath: string;
aiManifestPath: string; supportManifestPath: string;
aiManifestPresent: boolean; supportManifestPresent: boolean;
traceConfigPath: string | null; traceConfigPath: string | null;
traceLogPath: string | null; traceLogPath: string | null;
traceEnabled: boolean; traceEnabled: boolean;

View File

@ -1,479 +0,0 @@
# Autonomer Audit-Loop — Download/Fehler/Rotation (Goal 2026-06-17, 8h)
## GOAL-ANPASSUNG (Nutzer, nach Runde 8 + Synthese-Start): noch Runde 9 + 10, dann Goal BEENDEN.
Plan: Synthese-Pass (wrexz7mdf, Capstone R1-8) auswerten → Runde 9 (Provider-spezifische
Unrestrict-/Rotations-Pfade: Mega-Web-Fallback Session/Single-Flight, AllDebrid Host-Cooldown/
Rapidgator-Backoff, DebridLink-Key-Rotation, Passwort-Cache-Race) → Runde 10 (Concurrency/Locking +
Settings/Persistenz-Integritaet: Hybrid-Race, targetPath-Claim/Release-Races, Settings-Migration,
Backup/Restore, account-check) → Abschlussbericht + Ende. Fixes je rot-bewiesen, buendeln zu v1.7.220 falls HIGH/MED.
Disziplin: erst BELEGEN (Code-Zitat + konkretes Szenario), dann adversarisch verifizieren,
dann TDD-Fix. Kein Blind-Fix. Tests gruen + tsc=6 nach jeder Runde. Periodisch releasen.
## Runde 9 (Provider-spezifische Unrestrict-/Rotations-Pfade) — Mega-Web-Single-Flight + DebridLink-Key + Concurrency
Fokus: Mega-Web-Fallback Session/Single-Flight-Queue, DebridLink-Key-Rotation, In-Flight-Verteilung.
- **CONFIRMED HIGH (GEFIXT) MW-1 Selbst-Cooldown eines GESUNDEN, nur in der Queue wartenden Web-Accounts:**
Der Caller-Timeout (`DEFAULT_UNRESTRICT_TIMEOUT_MS`=60s) feuert, waehrend die zweite Umwandlung eines
Accounts noch SERIELL in der Mega-Web-Single-Flight-Queue (90s `QUEUE_WAIT_TIMEOUT_MS`) auf den laufenden
Vorgaenger wartet — also bevor ueberhaupt echte Arbeit begann. `raceWithAbort` warf bisher unbedingt
`aborted:mega-web`; `unrestrictViaWeb` (debrid.ts:1887) flachte JEDEN signal-aborted-Fall zu `aborted:debrid`
ab; die Rotation (debrid.ts:2072) wertete das via `/aborted/i && !/timeout/i``ranLongEnough`
(elapsedMs schliesst die Queue-Wartezeit ein, also >= 8s) → **120s Account-Cooldown auf einen voellig
gesunden Account**. Genau die vom Nutzer gemeldete „Tool sperrt sich selbst"-Klasse (Web-Variante).
Zweiteiliger Fix:
(1) `MegaWebFallback.runExclusive` (mega-web-fallback.ts) trackt `workStarted` und reicht eine
`abortErrorFactory` an `raceWithAbort`: abgebrochen-bevor-Arbeit-begann → `Mega-Web Queue-Timeout (…)`
(matcht `/queue.?timeout/i`), nur ein echter In-Arbeit-Abbruch bleibt `aborted:mega-web`.
(2) `unrestrictViaWeb` (debrid.ts:1887) bewahrt einen `/queue.?timeout/i`-klassifizierten lastError statt
ihn zu `aborted:debrid` zu plaetten → Rotation trifft die bestehende Queue-Timeout-Ausnahme
(classifyAccountFailure 2243 → cooldownMs 0) statt den Abbruch-Cooldown-Zweig.
Rot-bewiesen NICHT-vakuum (zwei Tests, beide per Temp-Revert rot verifiziert):
- Teil 1 (mega-web-fallback.test.ts): echtes `MegaWebFallback`, Queue mit langsamem Erst-Job belegt,
Zweit-Call WAEHREND in Queue abgebrochen → `rejects.toThrow(/queue.?timeout/i)` (ohne Fix: `aborted:mega-web`).
- Teil 2 (debrid.test.ts): durch `DebridService.unrestrictLink` mit `RD_MEGA_ABORT_MIN_RUN_MS=0` (besiegt die
Vakuum-Falle: der buggy Pfad WUERDE selbst bei Sofort-Abbruch cooldownen), Signal bricht WAEHREND des
Calls ab, megaWeb meldet Queue-Timeout → `getMegaDebridAccountCooldownState` bleibt null (ohne Fix: 120s
„Abbruch/Timeout nach 0s"). tsc=6, volle mega-web+debrid-Suite 103 gruen inkl. der bestehenden
Abbruch-Cooldown-Tests (echter langsamer Account cooled WEITERHIN — keine Regression).
- **Rotations-Verifikation (Advisor-Shippability-Diskriminator):** Nach dem No-Cooldown-Pfad retried der
Manager `unrestrictWithAccounts` frisch. Die Account-Wahl sortiert per `megaDebridInFlight`-TIEFE
(debrid.ts:1981-1986, least-busy zuerst). Invariante: jeder Belegt-Halter der MegaWebFallback-Queue
entspricht einem in-flight `client.unrestrictLink`, das `megaDebridInFlight` inkrementiert hat (2033,
Dekrement nur im finally 2152). Also sieht der Retry des queue-getimeouteten Links den saturierenden
Account bei Tiefe>=1 und rotiert auf einen freien — MW-1 ist NACHWEISLICH besser als das alte 120s-Lockout
(das einen nur-belegten gesunden Account sperrte). Kein Tight-Retry-Loop auf saturierter Queue, solange
irgendein Account frei ist; sind ALLE saturiert, queued der least-busy (unvermeidbar — keine freie Kapazitaet,
aber das alte Cooldown haette es via Faux-Park SCHLIMMER gemacht).
- **DOKUMENTIERT, nicht gefixt (LOW) DL-1 DebridLink-Key-Cooldown bei User-Cancel-Abbruch:** classifyKeyFailure
(debrid.ts:3118-3126) gibt fuer Text mit „aborted" (via isRetryableErrorText 597 + isTransport 3119)
`cooldownMs: 15_000` zurueck — auch bei einem SCHNELLEN User-Cancel (kein Min-Run-Gate wie bei Mega 2072).
Symptom: 15s Key-Cooldown nach Nutzer-Abbruch. Advisor (revidiert eigenen frueheren „clean fix"-Call):
classifyKeyFailure hat KEIN elapsedMs und DebridLink hat keinen Min-Run-Knopf-Aequivalent zu
`getMegaDebridAbortMinRunMs()`. Der treue Fix (Caller-Gate bei 2787 spiegeln) braucht entweder cross-Provider-
Wiederverwendung des Mega-Knopfes oder einen neuen DebridLink-Knopf = neue Oberflaeche im Live-Hot-Path fuer
einen LOW-Bug (Symptom: 15s nach User-Cancel). Klart die eigene „clean + null-Regression"-Schwelle NICHT →
dokumentiert, nicht gefixt.
- **DOKUMENTIERT, nicht gefixt (MED) DL-CONCURRENCY-PILEUP:** Bei gleichzeitigen Umwandlungen, die ALLE
Mega-Accounts saturieren, queued der least-busy-Account weitere Links seriell (per-Account-Single-Flight) →
Wartezeiten stapeln sich. MW-1 entfernt die FRUEHERE versehentliche Backpressure (der falsche 120s-Cooldown
wirkte als grobe Ratenbegrenzung), routet aber korrekt per In-Flight-Tiefe um belegte Accounts herum statt sie
faelschlich zu sperren — strikt besser. Echter Fix (In-Flight-Tiefen-Spread groesser ziehen / globale
Web-Parallelitaetsgrenze) = groessere Aenderung, 4.-Bug-Risiko auf Live-Server → deferred. MW-1 reduziert das
Pileup-in-Park-Risiko bereits (kein Faux-Lockout merely-busy Accounts).
## Runde 10 (Concurrency/Locking + Settings/Persistenz-Integritaet) — LETZTE RUNDE (Nutzer: „nach der Runde ist Schluss")
4 Finder (hybrid/targetPath-Races, Slot-Accounting, Settings-Migration/Persistenz, Backup-Restore/account-check)
→ adversarisch 3 Lenses → Synthese. Workflow wwyqsdrf9: 2 Finder (slot-accounting, settings-persistence) starben
an Stream-Idle-Timeout (Riesen-Dateien) → 0/7 confirmed war NUR fuer 2 von 4 Dimensionen ehrlich. KEIN stilles
Coverage-Loch akzeptiert → Re-Run wwyqsdrf9b (wfmu4pw2n) mit engerem Scope (Grep-dann-Region statt Ganzdatei).
- **Dim hybrid/targetPath + backup/account-check (wwyqsdrf9): 7 Kandidaten, ALLE refutiert (>=2/3), quell-reverifiziert.**
Kern-Invarianten halten: synchrones `claimTargetPath` (dl-mgr:6257) = nie zwei Items auf einem Pfad → alle drei
Hybrid-„Races" kollabieren zu Sub-Sekunden-Redundanzarbeit, kein Collision/Korruption; jeder Loss-Ausgang von
CRC/Extraction-Fail re-queued (`hybridExtractRequeue.add` 11739). Restore ist fail-closed (encrypted-or-reject,
Binaerheader „MDD1" wirft in JSON.parse vor normalizeSettings). account-check `valid:false` ist reiner
Renderer-Badge (kein src/main liest ihn als Gate; Disable laeuft ueber megaDebridDisabledAccountIds).
- **CONFIRMED MED (DOKUMENTIERT, nicht gefixt) BYTE-DROP-RETRY-1 (wfmu4pw2n):** Integrity-/too-small-/tiny-Retry
doppelzaehlt eine volle Datei in die Byte-Statistik. `dropItemContribution` (6247) loescht den
itemContributedBytes-Eintrag OHNE von session.totalDownloadedBytes abzuziehen (eigener Kommentar: „retry path
subtracts on its own"), aber die EINZIGE Subtraktion (9991-9997) liest genau diesen geloeschten Eintrag → 0 →
Subtraktion tot → Re-Download addiert N nochmal → (k+1)*fileSize nach k Integrity-Fails. enableIntegrityCheck
default true → organisch. Verdict isReal (CORRECTNESS+REPRO), aber BLAST-RADIUS = NUR Statistik: kein
Slot/Admission/Semaphore liest diese Counter (grep-belegt), Session-Counter self-healen bei jedem Neustart,
nur persistiertes totalDownloadedAllTime + avg-Speed bleiben dauerhaft inflationiert = kosmetisch. NICHT clean
(dropItemContribution ueber 26 Call-Sites ueberladen; ~10 im Retry-Bereich; ein 3-Site-Patch liefert
partielles/inkonsistentes Accounting = arguably schlimmer; Guard-Test dl-mgr.test.ts:6152 sperrt die
Completion-Removal-Semantik) → DOKUMENTIERT mit Rezept (dropItemContribution splitten in ForCompletion/ForRetry
nach Klassifikation aller Retry-Sites re-download-vs-terminal). Kosmetisch + nicht-clean → klart Live-Schwelle nicht.
- **CONFIRMED MED (GEFIXT) SET-MIG-01 (wfmu4pw2n):** Pre-v1.6.90-Config-Migration tot → Mega-Debrid wird beim
ersten Settings-Panel-Save still aus der Provider-Reihenfolge demotet. readSettingsFile (storage.ts:633) merged
`{...defaultSettings(), ...parsed}`; defaultSettings setzt megaDebridApiEnabled/WebEnabled:false (constants.ts:50-51)
BEVOR normalizeSettings laeuft → der `=== undefined`-Migrationszweig (355-360) ist tot auf dem Disk-Pfad (Git:
tot seit v1.6.90/0003d78). Renderer (App.tsx:447-494) gated Mega-Inklusion am false-Flag → persistDraftSettings
schreibt eine Mega-bereinigte providerOrder zurueck. Fix: reine `migrateLegacyMegaEnableFlags(parsed)` in
readSettingsFile — seedet apiEnabled=preferApi/webEnabled=!preferApi NUR wenn BEIDE Flags im RAW-parsed fehlen
UND Creds da sind (Creds-Erkennung wie normalizeSettings: asText(login)&&asText(password)). Lokalisiert, keine
normalizeSettings-Signatur-Aenderung, getypt (kein Cast). EHRLICHER Scope (Advisor): rettet NUR Legacy-Configs,
die seit dem Upgrade noch NICHT ueber das Settings-Panel neu gespeichert wurden (ein Post-Upgrade-Save schreibt
present-false → Trigger feuert nicht mehr) — schmales historisches Fenster, NICHT „rettet die Live-Mega dieses
Nutzers". HARTE Grenze (Advisor): Trigger bleibt absent-both; present-false NICHT anfassen (= bewusst-deaktiviert,
ununterscheidbar → das geparkte entscheidungen-offen #2-Migrationsrisiko). Rot-bewiesen NICHT-vakuum (Assertion NUR
auf den geladenen Flags — providerOrder demotet backend-seitig nicht = waere vakuum; via Temp-Revert rot bestaetigt:
apiEnabled true→false). Boundary-Test (bewusst-deaktiviert bleibt false + ohne Creds keine Migration) bleibt beim
Revert gruen = unabhaengig. Volle Suite 890 gruen, tsc=6.
- **REFUTED (wfmu4pw2n) SET-PERSIST-02:** Residual-Lost-Update nach R2-Generations-Guard — der einzige verlorene
Payload ist totalRuntimeAllTimeMs (`<=`-Ratchet, naechste Session re-derived) + contrived Sub-ms-Race der die
Prozess-Teardown gewinnen muss → self-healing kosmetisch, nicht erreichbar.
- **Coherence-Verdikt:** Concurrency-Accounting + Settings-Persistenz sind kohaerent. Byte-Counter sind
telemetry-only, voll entkoppelt von Admission/Slot (grep-belegt) → BYTE-DROP kann nicht stranden/freezen/
ueber-admiten. Persistenz kohaerent bis auf EINE benannte Inkohaerenz: Backend normalizeConfiguredProvider
(storage.ts:144) mapt „megadebrid"→konkret unabhaengig der Flags, Renderer gated auf dem Flag = Wurzel von
SET-MIG-01 (jetzt am Loader gefixt).
- **EHRLICHE Coverage-Luecke (vom Audit selbst aufgedeckt, R10-HYB-3):** Die in project_pending genannte
Passwort-Cache-Race liegt NICHT in download-manager.ts (dort kein passwordCache/resolvePassword), sondern in
src/main/extractor.ts — ausserhalb des Round-10-Scopes. Bewusst NICHT in dieser letzten Runde auditiert (Nutzer:
„nach der Runde ist Schluss"); als das eine identifizierte, un-auditierte Concurrency-Seam fuer eine etwaige
kuenftige Runde dokumentiert statt still fallengelassen.
## GOAL-ABSCHLUSS
Runde 9 + 10 erledigt (Nutzer-Anpassung). Release v1.7.220 buendelt: 85c8d6b (Synthese C1/C2/RANGE1-Haertung) +
MW-1 (HIGH, Web-Selbstcooldown) + SET-MIG-01 (MED, Legacy-Mega-Demotion). Gitea + GitHub-Mirror MIT .exe (4 Assets).
Dokumentiert-nicht-gefixt: DL-1, DL-CONCURRENCY-PILEUP, BYTE-DROP-RETRY-1, extractor.ts-Passwort-Cache-Seam.
Beim Nutzer (nicht autonom): 60s-Failover-Kappung + gespiegelter Mega-API/Web-Schalter (entscheidungen-offen.md).
## Nutzer-Nachforderung (nach Goal-Abschluss): die 3 dokumentierten Funde DOCH umsetzen → v1.7.221
Nutzer: "dann mach das beides erstmal" (BYTE-DROP-RETRY-1 + DL-1 + DL-CONCURRENCY-PILEUP). Nicht relitigiert OB,
nur WIE (Advisor-gefuehrt, je rot-bewiesen, je full-suite gruen + tsc=6 single-pass).
- **BYTE-DROP-RETRY-1 (MED) GEFIXT.** Scope-Disziplin (Advisor): NUR Session-Counter + totalDownloadedAllTime,
NICHT recordProviderDownloadedBytes/providerDailyUsageBytes (das gated isProviderDailyLimited = Verhalten,
und ist nicht provider-keyed → naive Subtraktion wuerde den falschen Provider-Bucket korrumpieren → bewusst
ausgeklammert). Mechanismus (a): an den 3 bestaetigten rm-dann-frisch-Sites (Integrity 8979, too-small 9020,
tiny 10486) den `dropItemContribution`-Aufruf ENTFERNT → der itemContributedBytes-Eintrag ueberlebt → die
bestehende writeMode-"w"-Reconciliation (9991) subtrahiert ihn korrekt (selbst-korrigierend nach writeMode,
Append undercounted nicht). Plus: am selben Punkt (9991) `totalDownloadedAllTime -= previouslyContributed`
ergaenzt (spiegelt den Add bei 10311; All-Time wurde NIE subtrahiert → doppelte bei JEDEM frischen Re-Download,
nicht nur den dropItemContribution-Pfaden). KEINE 23-Site-Reklassifikation (Advisor: Provider-Usage off-limits
→ jede Restfehlklassifikation ist bounded Telemetrie). Guard-Test dl-mgr.test.ts:6152 (Completion-Removal behaelt
Session-Total) bleibt gruen. Rot-bewiesen NICHT-vakuum, BEIDE Beine einzeln: Integration durch echten
Integrity-Fail-Retry (.md5-Manifest, lokaler HTTP-Server serviert wrong-dann-correct), Assert session==1x UND
allTime==1x; Bein 1 (All-Time-Zeile raus) → allTime rot (2x) session gruen; Bein 2 (dropItemContribution zurueck)
→ session rot (2x).
- **DL-1 (LOW) GEFIXT.** Advisor revidierte den frueheren "braucht neue Oberflaeche"-Call: am Rotations-Catch (2789)
ist elapsedMs bereits da → Mega-Gate (2072) gespiegelt. abort-ohne-timeout + elapsedMs < getMegaDebridAbortMinRunMs()
→ KEIN Key-Cooldown (User-Cancel bestraft den Key nicht); ran-long-enough → DEBRID_LINK_KEY_COOLDOWN_MS (120s)
damit der Retry rotiert; throw bailt die Rotation (verhindert auch die zuvor moegliche Transport-Kaskade ueber
mehrere Keys bei aborted-Signal). Neuer Test-Getter getDebridLinkKeyCooldownStateForTests. Rot-bewiesen
(quick-cancel → null; ohne Fix 15s gesetzt; long-abort → >60s, ohne Fix 15s).
- **DL-CONCURRENCY-PILEUP (MED): untersucht → BEREITS STRUKTURELL GELOEST, kein Eingriff (Nutzer-Entscheidung
"Akzeptieren").** getSerializedValidatingLimit("megadebrid-web") = Anzahl nutzbarer (nicht-gecoolter) Accounts
(dl-mgr 8047-8055); shouldDelayStartForItem erzwingt es in der Kandidatenwahl (8526) → Ueberschuss-Konvertierungen
warten als "queued" im Scheduler, NICHT in den per-Account-Single-Flight-Queues; Depth-Spread (debrid 1981-1986)
verteilt die erlaubten 1-pro-Account. MW-1 haelt usableAccounts (= das Limit) korrekt hoch. Ein weiterer
Scheduler-Eingriff = redundant ODER schaedlich (Ueber-Admission = echter Pileup) → Advisor-4.-Bug-Risiko. Dem
Nutzer vorgelegt (AskUserQuestion) → "Akzeptieren, kein Eingriff".
## Runde 1 (laeuft)
- Discover+Verify-Workflow ueber 7 Subsysteme (scheduler-slots, unrestrict-retry, mega-rotation,
classify-cooldown, mega-web-token, provider-chain-timeout, account-availability).
### Meine unabhaengigen Verdachtsfaelle (Cross-Check gegen Workflow)
1. **Web-Selbst-Cooldown (Analog zum API-214-Bug, HOCH):** Caller-Timeout = 60s
(`DEFAULT_UNRESTRICT_TIMEOUT_MS`, download-manager 114) umschliesst die GANZE Kette.
Mega-Web braucht legitim laenger (per-Account-Queue bis 90s + Login + Generate).
Feuert die 60s nach >=8s (`MEGA_DEBRID_ABORT_MIN_RUN_MS_DEFAULT`=8000), setzt die
Rotation `aborted:debrid` → 120s Account-Cooldown (debrid.ts ~2037-2048), obwohl der
Account GESUND ist — die App hat aufgegeben. → Kaskade ueber Accounts. Live im jf.zip
belegt: `Mega-Debrid Web | TIMEOUT_COOLDOWN | reason=aborted:debrid | cooldownSec=120`.
Fix-Kandidat: (a) per-Provider-Timeout statt globaler 60s; und/oder (b) Caller-Timeout-
Abort NICHT als Account-Cooldown werten (EMA-Demotion regelt langsame Accounts bereits),
oder nur sehr kurz.
2. **Globaler 60s-Timeout kappt Failover (Advisor-bewiesen, HOCH):** download-manager 8759
`AbortSignal.any([cancel, timeout])` → bei Provider1-Verbrauch des Budgets abortet das
Signal → debrid.ts 3805 `signal.aborted` → throw, kein nextProvider. Fix: per-Provider-
AbortSignal.timeout, Stop nur bei USER-Cancel.
3. **Exponential-Backoff bis 120s** (generic unrestrict retry) — Item sitzt bis 2 min.
Pruefen ob fuer haeufige transiente Faelle zu lang.
## Bestaetigte Bugs (Workflow R1: 14 confirmed / 11 refuted) — priorisiert
- [IN ARBEIT] #1 HIGH Scheduler-Freeze: findNextQueuedItem ohne activeTasks-Guard → synchroner
Admission-Loop dreht endlos wenn ein reset/overwrite-Item noch im activeTasks parkt (non-abort-
observing await, z.B. Integrity-Check). Fix: `if (this.activeTasks.has(itemId)) continue;`. TDD-Test
(Freeze-Repro mit non-abort Mock + resetItems) geschrieben.
- #2/#3 MED mega_debrid_cooldown:<ms> Delay verworfen — kein Parser (nur debrid_link_cooldown). Fix:
Parser fuer beide Praefixe, queueRetry mit echtem delayMs. (Erklaert Rapid-Retry-trotz-Cooldown im jf.zip.)
- #7/#8 MED Mega per-Account Daily-Usage wird am Tagesgrenze NIE resettet → Accounts faelschlich "am
Limit" → schrumpft MEIN neues serialized-limit. Fix: megaDebridAccountDailyUsageBytes in
ensureProviderDailyUsageFresh resetten.
- #4 MED transiente leere Web-Antwort → permanenter until-restart-Park (limitSignal vom generischen
"antwort leer"). Fix: limitSignal nur vom echten Daily-Limit (NO_SERVER_RE).
- #5 MED Web echte Bad-Credentials erreichen invalid-Branch nicht (werden ewig retried). Fix: echte
Web-Login-Fehlerphrasen in invalid-Branch.
- #6 MED onefichier/ddownload-Routing ignoriert autoProviderFallback=off. Fix: Guard in catch.
- #14 LOW Regex-Ordering classify: quota-Branch shadowt rate_limit. Fix: rate_limit vor quota.
- #9 LOW overwrite wipet frisch geclaimten targetPath via altem .finally.
- #10 LOW HTTP416 shared counter mit genericErrorRetries.
- #11 LOW fresh-retry preempt typed transient handlers.
- #12 LOW 15-failure-shelve + shared counters → mehr Retries als retryLimit.
- #13 LOW self-poison: queue-wait zaehlt zu elapsedMs → abort-cooldown (Analog zu meinem Web-Verdacht #1).
## Refutiert / Nicht-Bug (11) — nicht anfassen
providerStartReservations dead-state; debrid_link_cooldown cleanup; supprimé-fallthrough; mega-web 180s
aborts whole rotation; EMA-removed-premise; quota-no-park asymmetry; connectApi single-flight cancel-couple;
per-account queue chain-break (NON-BUG); mega-web slot-hold (NON-BUG); provider abort-vs-timeout heuristic;
daily-limit aggregate early-exit.
## Fixes (TDD, mit Test + Release)
### Batch 1 → v1.7.215 (Suite laeuft)
- [x] #1 HIGH Scheduler-Freeze: `findNextQueuedItem` activeTasks-Guard. Repro-Test (ohne Fix haengt der
Event-Loop so hart, dass nicht mal vitest-Timeout feuert = Freeze empirisch bewiesen). Mit Fix 288ms.
- [x] #2/#3 MED parseMegaDebridCooldownRetry (export) + Handler VOR transient/generic branch → Item wartet
den ECHTEN Cooldown (min ueber alle Accounts) statt 5s-Busy-Loop. 5 Parser-Tests.
- [x] #7/#8 MED megaDebridAccountDailyUsageBytes Reset in ensureProviderDailyUsageFresh (laeuft via
getSnapshot, also auch im Stall). Test: Tagesgrenze → leer.
- [x] #14 LOW rate_limit-Branch VOR quota (quota matchte "limit" in "rate limit"). Test: rate_limit-Kategorie.
- [deferred] #4 empty-response→until-restart-park: 3-consecutive-streak ist reale Mitigation gegen transiente
Blips; Mega-empty-Semantik nicht sicher verifizierbar → kein Blind-Change.
### Strategie-Update (LIVE-Server, Advisor-bestaetigt)
- LOW-Fix-Schwelle HOCH: nur fixen bei NULL plausibler Regression UND einem Test der OHNE Fix rot ist.
Sonst dokumentieren ("gefunden & charakterisiert" ist valides Audit-Ergebnis). Server laeuft live,
auto-update, ~1 TB/h → jede unnoetige Verhaltensaenderung = Risiko.
- Releases BUENDELN (alle 2-3 Runden / Roll-up), nicht pro Fix. Weniger Update-Churn auf dem Live-Server.
- #5 NICHT raten: conversion.log faengt den echten Web-Login-Fehler-String schon (web-queue-Phase-Detail).
Aus naechstem Bundle ernten, dann erst invalid-Phrasen ergaenzen. Kein Phrasen-Halluzinieren.
- #13 defer: Web ist seit v1.7.214 nur noch Fallback (API-first), Selbstcooldown trifft kaum mehr;
braucht workMs-Threading → groesserer Eingriff, nicht LOW-billig.
- Vor Runde 4-5: SYNTHESE-Pass — ist Retry/Cooldown/Rotation END-TO-END kohaerent selbstheilend?
## Runde 3 (Failover/Reconnect/Cooldown-Lifecycle/Scheduler/IPC-Toggle/Updater) — Workflow wcwztx7e9
7 confirmed / 1 refuted. provider-failover-Finder crashte (Socket) → diese Dimension via MEINER
unabhaengigen Code-Verifikation abgedeckt (60s-Timeout kappt Failover, debrid.ts 3845 + dl-mgr 8814).
### Batch 3 → v1.7.217 (GEFIXT, je rot-bewiesener Test)
- [x] #R3-1 HIGH (3/3) Self-Cooldown bis Neustart — DER vom Nutzer gemeldete „Tool sperrt sich selbst".
(a) limitSignal aus MEGA_DEBRID_NO_SERVER_RE-Zweig entfernt (Hoster-Problem != Account-Limit),
(b) until-restart-Park laeuft jetzt zum Tagesreset (lokale Mitternacht) ab statt MAX_SAFE_INTEGER →
heilt <=24h selbst. Texte „bis Neustart"→„bis zum Tagesreset". Commit 76b3f99.
- [x] #R3-5 HIGH (3/3) Fehlgeschlagenes Update → Queue-Stillstand bis Neustart. runInstallWithResume()
(neue reine Funktion) resumt bei started:false UND throw. Commit dfd1926.
- [x] #R3-3 MED (3/3) Account-Edit ueberschreibt megaDebridPreferApi. Hardcode entfernt → ...settings
reicht Nutzerwahl durch. Commit 1e04b7b.
### Dokumentiert / NICHT autonom gefixt (Advisor-Disziplin)
- #R3-2 HIGH (2/3, UMSTRITTEN) Mega API/Web-Account-Zeilen teilen EINE login-only Enable-Flag →
Toggle spiegelt sich (= Nutzer-Report „API aus → Web an"). KEIN Auto-Fix: Daten-Modell-Fix braucht
Settings-Migration (kann deaktivierte Accounts re-aktivieren), UI-Collapse = Layout-Redesign (Nutzer
UI-Geschmack-sensibel). → DEM NUTZER vorlegen: gemeinsamer Schalter vs. unabhaengige pro-Modus-Flags.
- 60s-Failover-Kappung (HIGH, mein Fund, Finder gecrasht) — debrid.ts 3845 wertet JEDEN combined-signal-
Abort (cancel ODER 60s-Timeout) als kein-Failover; langsamer Provider1 hungert Provider2 aus, auch ueber
Retries. Post-214 (API-first) groesstenteils latent. → eigene Runde: gecrashten Finder ERST neu laufen
lassen (unabhaengige Verifikation fehlt), dann per-Provider-Timeout-Design mit Advisor. NICHT in 217.
- #R3-6 MED (3/3) Update-Mirror-Failover feuert nie (nur Gitea). NICHT fixen: aendert den Update-Fetch-Pfad
= der Kanal, ueber den jeder Fix den Nutzer erreicht; faellt heute sicher aus (App behaelt alte Version).
- #R3-4 LOW (2/3) providerPrimary kann auf disabled Mega normalisieren — self-heilt zur Laufzeit. Belassen
(Refuter: Fix riskanter als Bug — schreibt persistierte Absicht um).
- #R3-7 LOW (3/3) Update-Integritaet hash-only, kein Authenticode — ehrliche Grenze, faellt sicher aus.
- REFUTIERT (0/3): all-accounts-parked wirft plain error ohne cooldown-retry-Token.
## Runde 4 (Failover-Routing-Slice + Entscheidungs-Doku)
Follow-on aus R3-Failover-Fund. Advisor-Disziplin: nur die SICHERE Scheibe autonom, der
Produkt-Tradeoff geht an den Nutzer (tasks/entscheidungen-offen.md).
### Autonom gefixt (TDD, rot-bewiesen)
- [x] MED Failover-Routing: Manager berechnete bei Provider-Cooldown (>=20 Fehler in Folge,
auto-Fallback an) einen Ersatz-Provider (`findFallbackProviderNotInCooldown`), WARF ihn aber
weg — `unrestrictLink(item.url, signal)` ohne Hint → debrid.ts baut `order` neu aus
providerOrder und fuehrt WIEDER mit dem ausgebremsten Provider1 an (wahrsch. 60s-Timeout
verschwendet). Fix: reine `leadProviderChainWith(order, preferred)` (debrid.ts, export) +
4. optionaler Param `preferredLeadProvider` an `unrestrictLink`; Manager reicht den Ersatz
durch (dl-mgr 8772/8828). REORDER nicht SKIP → ausgebremster Provider bleibt als letzter
Notnagel in der Kette, kein Stranding. All-cooled-Fall erreicht `unrestrictLink` gar nicht
(else-Zweig queueRetry'd). Tests: integration (control=realdebrid, preferred=debridlink) +
3 reine Helper-Tests (null→unchanged, in-order→leads+keeps-all, not-in-order→unchanged).
Suite 875 gruen, tsc=6. Commit folgt; HALTEN fuer Roll-up-Release (kein HIGH/dringend).
### An den Nutzer vorgelegt (NICHT autonom) → tasks/entscheidungen-offen.md
- 60s-Failover-Kappung (HIGH): A) pro-Provider-Timeout (Failover immer, aber bis 3×60s
Worst-Case, Drehregler ueber Pro-Provider-Wert) vs B) globales Budget mit Failover-Reserve
(langsamer Provider1 frueher abgeschnitten). Produkt-Tradeoff = Nutzerwahl. Post-214 weitgehend latent.
- Gespiegelter Mega API/Web-Schalter (HIGH, #R3-2): gemeinsamer Schalter vs unabhaengige
pro-Modus-Flags (Migration + UI-Redesign noetig, Nutzer UI-sensibel).
### SYNTHESE-Pass (Cross-Layer: Manager-Cooldown-Keys vs Debrid Account/Daily-Park) — Workflow wrfxpjudj
2 Kandidaten, 1 confirmed (3/3), 1 refuted (0/3). Advisor-Hypothese (Key-Mismatch) WIDERLEGT.
- **REFUTED (0/3) KSM-1 Key-Mismatch:** normalizeProviderOrder (storage.ts:144/411) speichert IMMER
den aufgeloesten 'megadebrid-api'/'-web', NIE den virtuellen 'megadebrid'. Also fallen
Cooldown-WRITE (recordProviderFailure), CHECK (getProviderFailureKeyForItem), CLEAR und READ
(findFallbackProviderNotInCooldown) auf denselben aufgeloesten Key → KEINE Divergenz. Der
986fbab-Routing-Fix ist fuer den Mega-Fall nachweislich sicher (No-Stranding haelt).
- **CONFIRMED (3/3) LOW MEGA-UNTILRESTART-MISCLASS (GEFIXT):** Der untilRestart-Park-Throw
(debrid.ts:2167) trug KEINEN Maschinen-Token → Manager-Catch klassifiziert ihn als generischen
Unrestrict-Fehler (isUnrestrictFailure matcht "mega_debrid") → Retry alle ~2min den ganzen Tag
+ recordProviderFailure (Circuit-Breaker-Verschmutzung), statt einmal bis Tagesreset zu parken.
Default-Config (retryLimit=0=∞) self-heilt um Mitternacht, KEIN Stranding. Genau die
Round-3-untilRestart-Park-Absicht, die hier unterlaufen wurde. Predates 986fbab.
Fix: debrid.ts emittiert jetzt `mega_debrid_reset_park:<msBisReset>:` (megaDebridDailyParkExpiry);
neue reine parseMegaDebridResetPark (kein 15min-Clamp, 26h-Cap) + Catch-Branch VOR der
Cooldown-Klassifikation queued bis Tagesreset OHNE recordProviderFailure. Rot-bewiesen
(Parser-Tests + debrid-Token-Assertion). tsc=6.
- **End-to-end-Verdikt:** Retry/Cooldown/Rotation/Failover ist unter Default-Config kohaerent
self-heilend (jeder Park hat Zeitgrenze + Auto-Clear). Einzige Rest-Inkohaerenz war die
Layering-Naht oben (in-memory untilRestart-Park unsichtbar fuer getAvailableMegaDebridAccounts
+ isProviderDailyLimited) — mit dem konservativen String-Klassifikations-Fix geschlossen.
Der breitere Fix (Selektierbarkeit cooldown-aware machen) bewusst NICHT gemacht (groesserer
Blast-Radius auf Live-Server).
## Boot-Verifikation (Advisor-Punkt: Suite gruen != App bootet)
Smoke-Test des gebauten 1.7.218-Binaries (release\win-unpacked, throwaway userData, leere Session):
laeuft >10s stabil, spawnt die normalen 4 Electron-Prozesse (main+GPU+renderer+utility), schreibt
userData/runtime → BOOTET. Boot-Pfad (main.ts-Boot, app-controller-Init, Window/IPC-Registrierung)
von diesem Audit NICHT angefasst; alle editierten Methoden sind Download-Zeit (von der 884er-Suite
importiert+konstruiert). Risiko Startup-Regression empirisch ausgeschlossen.
## Advisor-Leitlinie (Stand Runde 8)
- Nach Runde 8 KEINE weitere Discovery-Runde, sondern ein SYNTHESE/Regressions-Pass ueber den
KUMULATIVEN Diff (Interaktion der 6 gestagten/releasten Fixes) — Confirmed-Yield faellt (R5:0/R6:1/R7:1),
Risiko ist jetzt Fix-Interaktion, nicht unentdeckte Bugs.
- Advisor NICHT auf Kadenz pollen — nur bei echter Ship/Fix-Entscheidung mit neuer Info.
- Die 2 HIGH Produkt/UI-Entscheidungen bleiben beim Nutzer (nicht autonom shippen).
## Release-Status: v1.7.219 RELEASED (Gitea 6ae9f5d + GitHub-Mirror df3670a)
Roll-up Runde 7+8: be15419 MED VP-1 dt.-Tonspur + eacd0c9 HIGH RANGE-1 Silent-Corruption + REWIND-TRUNCATE.
HIGH rechtfertigt Release. Advisor explizit cleared (vor Implementierung konsultiert, Check A verifiziert).
Mirror kuratiert ohne CLAUDE.md/tasks/ (Leak-Check sauber). NAECHSTER SCHRITT (Advisor): Synthese/Regressions-
Pass ueber kumulativen Diff aller Session-Fixes (Interaktion), KEINE weitere Discovery-Runde.
## Release-Status: v1.7.218 RELEASED (Gitea bbb9355 + GitHub-Mirror 94d143b)
Roll-up Runde 4+5+6: 986fbab MED Provider-Cooldown-Routing + 03c908b No-Stranding-Test +
f1e35f5 LOW Mega-Tagesreset-Park + d2a1b83 HIGH Shelve-Loop-RetryLimit. HIGH rechtfertigt das
Release. Gitea (Live-Update-Quelle): .../releases/tag/v1.7.218. GitHub-Mirror (kuratierter
Single-Commit ohne CLAUDE.md/tasks/, Leak-Check sauber): Sucukdeluxe/multi-debrid-downloader v1.7.218.
Advisor war die GANZE Session ueberlastet → autonom released auf Basis: 3x rot-bewiesene Tests
(je per Temp-Revert verifiziert), volle Suite 882 gruen, tsc=6, unabhaengige Code-Verifikation +
Multi-Agent-adversarisch, Routing-Fix frueher advisor-gesegnet, Praezedenz 215/216/217 autonom.
Runde-5/6-Charakterisierungen (PP-SEM-1 benign, DISK-1 deferred, deferred-LOW-Cluster #10/#11/#13
benign) NICHT released — dokumentiert.
## SYNTHESE/Regressions-Pass (Fix-Interaktion ueber kumulativen Diff v1.7.212..HEAD) — Workflow wrexz7mdf
3 Reviewer (Catch-Cascade / Streaming / Provider-Chain) + adversarisch verifizieren. 3 confirmed (1 MED, 2 LOW),
2 refuted. Bestaetigt: Rest komponiert sauber; Risiko war (wie Advisor sagte) Fix-Interaktion, nicht neue Bugs.
- **CONFIRMED MED (GEFIXT) C1 reset_park maskiert kurzen Cooldown:** Bei BEIDEN Mega-Modi aktiv aggregiert die
Provider-Kette beide Token (`mega_debrid_reset_park:LONG` von API-Park + `mega_debrid_cooldown:30000` von
Web-Cooldown). Manager prueft reset_park VOR cooldown → Item ~24h geparkt obwohl Web in ~30s erholt. Fix:
Cooldown-Zweig VOR reset_park (kuerzerer Delay gewinnt). Rot-bewiesen (Single-Pass-processItem-Test: Aggregat
beider Token → retryAfter < 60s, fullStatus "Cooldown" nicht "Tagesreset"; ohne Reorder ~24h).
- **CONFIRMED LOW (GEFIXT) C2 Token-Truncation:** reset_park-Token konnte von compactErrorText (220-Zeichen-Cap)
abgeschnitten werden, wenn Mega nicht Lead + vorheriger Provider verbose → Park still uebersprungen. Fix: Mega-
Token aus der UNGEKUERZTEN error.message parsen (megaRawError). (Im selben Edit wie C1.)
- **CONFIRMED LOW (GEHAERTET + Doku korrigiert) RANGE1-TRUNCATEFAIL-FINALIZE:** Mein 219-Commit OVERCLAIMte —
bei FEHLGESCHLAGENEM finalem Rewind-truncate finalisiert tryFinalizeItemFromDisk(9238) die Garbage-Datei
(size==totalBytes) VOR dem Re-Entry. NET-NEUTRAL vs pre-audit (v1.7.212 hatte denselben Finalize), KEINE
Regression. Haertung: bei truncate-Fehler jetzt rmSync(Teil-Datei)+downloadedBytes=0 → Finalize lehnt ab →
sauberer Re-Download (best-effort; faellt der rm auch, bleibt es net-neutral). Macht den 219-Claim wahr.
- **Refuted (1/3 je):** reset_park von shelve-guard/unrestrictRetries-Exhaustion unter finite retryLimit
verdraengt — beide nicht bestaetigt (compound/nicht erreichbar).
- **Capstone-Verdikt:** Die 8-Runden-Fixes komponieren — inner-rewind success-only-reset und final-rewind sind
per-attempt mutually exclusive; prealloc-reconcile double-truncated nicht nach erfolgreichem Rewind; Routing
+ cooldown + park kohaerent NACH C1/C2-Fix. Diese 3 Synthese-Fixes buendeln zu v1.7.220.
## Runde 8 (Byte-Streaming downloadToFile: Range/Resume/Append/Truncation) — Workflow wwjz4srkq
3 Finder + adversarisch verifizieren. 3 confirmed (1 HIGH + 2 MED, alle Silent-Corruption-Familie), 1 refuted.
Advisor VOR Implementierung konsultiert (HIGH-Hot-Path) — Design + Check-A-Branch (totalBytes-null) bestaetigt.
- **CONFIRMED HIGH (GEFIXT, known-total) RANGE-1 Silent-Mid-File-Corruption:** Auf dem LETZTEN inneren Versuch
wird ein injizierter Garbage-Tail nicht zurueckgespult (`attempt < maxAttempts`-Guard greift nicht),
resumeRewindBytesNextAttempt ist funktions-lokal (ueberlebt downloadToFile-Re-Entry nicht), der Outer-Handler
nimmt fuer terminated-class den generic-retry-Zweig (kein File-Delete), und der Fresh-Link-Resume haengt
echte Bytes NACH dem Garbage an → exakt-laengen-Datei besteht die Length-only-Completion-Pruefung; bei
manifestlosen .mkv/.mp4 nie erkannt. Fix: Rewind-vor-Throw am Exhaustion-Punkt (truncate letzte
RESUME_REWIND_BYTES + downloadedBytes ZUERST setzen → binary-Re-Entry-prealloc-reconcile robust auch bei
truncate-Fehler), GEGATED auf `totalBytes != null && > 0`. Check A verifiziert: known-total → rewound
size < totalBytes=minBytes tryFinalizeItemFromDisk(9238) REJECTET Re-Entry ueberschreibt Garbage.
EHRLICHER Scope (Advisor): GEFIXT fuer known-total Medien (Debrid liefert fast immer fileSize);
**null-total behaelt die separate, vor-bestehende Silent-Corruption** unter dem dokumentierten
Size-only-Validation-Blindspot (durch Rewind NICHT fixbar — kein Laengensignal; nur Hard-Reset wuerde
helfen, groesserer Eingriff). Rot-bewiesen: Cross-Call-Test (final-attempt Garbage → Exhaustion →
queueRetry → 2. downloadToFile → CONTENT-Gleichheit); ohne Fix Length-Assert gruen + Content-Assert rot.
- **CONFIRMED MED (GEFIXT) REWIND-TRUNCATE-FAIL:** Das `finally` setzte resumeRewindBytesNextAttempt=0
UNBEDINGT, auch wenn die Rewind-truncate (9633) warf → transienter win32-EBUSY/AV-Lock-Fehler liess den
Garbage-Tail + cleart das Flag (nie retried). Fix: Reset NUR im Success-Branch → fehlgeschlagenes Rewind
wird naechsten Versuch erneut probiert. Defensive Haertung (Advisor "ship it"); Happy-Path von Test 1113
+ RANGE-1-Test abgedeckt.
- **DOKUMENTIERT, nicht gefixt (MED, schwaechste, Workload-immun) PREALLOC-ZEROS-ACCEPTED:** win32-Prealloc-
Nullen am Ende als komplett akzeptiert fuer NICHT-binaere Typen (1MB-Slack) wenn truncate skip/faellt.
Dominante Medien/Archive sind immun (threshold=0). Narrow Conjunction (non-binary >20MB, Gap<1MB, Crash/
truncate-fail). Fix bekannt (binary-strict footprint im 416-accept + recovery-finalize), aber deferred.
- **Refuted (1/3) TRUNC-1:** fsync auf resume-append fehlt — als Power-Loss-Edge eingestuft, nicht confirmed.
- **Cross-cutting (Advisor: NICHT jetzt anfassen):** Size-only-Completion-Validation ist der gemeinsame
Blindspot; kein billiger Content-Check fuer manifestlose Medien → validateDownloadedFileCompletion NICHT
umbauen (Risiko 4. Bug). Punkt-Fixes sind korrekt; Blindspot bleibt langfristiges Item.
## Runde 7 (Post-Download: Extraction + Video-Processor + Companion/Orchestrierung) — Workflow wcxk08n5i
3 Finder + adversarisch verifizieren. 2 confirmed, 0 refuted. Schliesst den deferred-Extraction-Cluster ab.
- **CONFIRMED MED (GEFIXT) VP-1 Falsche Tonspur:** isGermanStream (video-processor.ts:99-108) wertet die
Titel-Regex /\b(german|deutsch)\b/ fuer JEDEN nicht-deutsch-getaggten Stream — NICHT auf "Sprach-Tag fehlt"
gegated, obwohl der Kommentar (104-106) genau das als Absicht nennt. pickAudioTrack (124, EINZIGER Caller)
nimmt den ERSTEN Treffer → eine eng-Spur mit Titel "...German..." vor der echten ger-Spur GEWINNT → Remux
behaelt Englisch, verwirft die korrekte dt. Spur, ersetzt das Original atomar in-place (500) und strippt
.DL. → irreversibler Datenverlust + falsche Sprache, als Erfolg gemeldet. Single-Trigger (nicht compound).
Betrifft genau die vom Nutzer bestaetigte dt.-Tonspur-Funktion [[project_german_audio_feature]]. Fix:
`if (lang) return false;` VOR dem Titel-Fallback (deckt sich mit der Kommentar-Absicht). Rot-bewiesen
(2 Tests: eng-Titel-"German" vs echte ger → audioRelIndex 1; eng-Titel-"Deutsch entfernt" → skip). tsc=6.
- **DOKUMENTIERT, nicht gefixt (LOW compound) PP-1/#13 Companion-Overwrite:** renameCompanionFiles/
moveCompanionFiles (dl-mgr 3735/3791) ohne Uniqueness-Guard (anders als MKV via buildUniqueFlattenTargetPath);
cross-volume copyFile ohne COPYFILE_EXCL (3696) → ueberschreibt einen vorhandenen Orphan-Companion still
(nur .srt/.idx/.nfo, nie Archiv/Video). Compound (Orphan + Namenskollision), Sekundaerdateien, Integrations-
TDD noetig → dokumentiert. Fix bekannt (Uniqueness-Guard spiegeln).
- **Deferred-Extraction-Cluster re-klassifiziert (aus Runde 2):** #10 CRC-Kleindatei-Delete BENIGN (failed
Archive aus cleanupSources ausgeschlossen, nicht geloescht); #12 7z-Exit-1-als-Erfolg BENIGN (nur Erfolg
wenn KEINE Error-Marker — echte CRC/Korruption korrekt als Fehler); #11 resume-empty-output NOT-CONFIRMED
(compound, unbewiesene Hybrid-Collect-Ordering-Annahme); #13 = PP-1.
## Runde 6 (Retry/Backoff/Error-Klassifikations-State-Machine + Disk/IO) — Workflow wqurq83ma
3 Finder + adversarisch verifizieren. 1 confirmed (2/3), 3 refuted. Schliesst den deferred-LOW-Cluster ab.
- **CONFIRMED HIGH (GEFIXT) SHELVE-LOOP-RETRYLIMIT:** Bei FINITEM retryLimit>=5 sind alle drei Per-Klasse-
Caps = retryLimit; die 15-Failure-Shelve-Zweige (dl-mgr 9166 stall + 9352 error) feuern aber auf
hartkodiertem `sum>=15` OBERHALB der Per-Klasse-Terminal-Fails und HALBIEREN danach alle Counter →
Per-Klasse-Caps werden nie gleichzeitig ueberschritten → Item failt NIE, schleift ewig, item.retries
waechst ueber das konfigurierte Limit, Slot gestrandet, providerFailures.delete besiegt wiederholt den
Circuit-Breaker → Hoster-Hammering. resetStaleRetryState rettet nicht (<=90s-Re-Admit haelt updatedAt
frisch < 10min-Stale). Default retryLimit=0=∞ NICHT betroffen (Shelve ist dort der gewollte Park-Backstop).
Fix: in BEIDEN Shelve-Zweigen vor dem queueRetry `if (configuredRetryLimit > 0 && item.retries >=
configuredRetryLimit)` → terminal failen statt requeue (∞-Modus unberuehrt). Rot-bewiesen (Single-Pass-
Integrationstest: genericErrorRetries=15 vorgeseedet + injizierter Generic-Error → status "failed" statt
requeue). tsc=6.
- **Deferred-LOW-Cluster re-klassifiziert (aus Runden 1-2):** #10 HTTP416-shared-counter BENIGN (maxHttp416Retries
eigenes Budget, speist Shelve-Summe NICHT); #11 fresh-retry-preempt BENIGN (One-Shot-Booleans);
STALL-CAP-OFF-BY-ONE real aber +1 (Zutat von CONFIRMED-1, kein eigener Strand); #13 queue-wait→elapsedMs
BENIGN (queueRetry resettet attempts/updatedAt, Stall-Detektor misst nur aktiven Download). #12 = der
gefixte Bug. DISK-1 (ENOSPC/EACCES via Generic-Budget) kein eigener Bug (in finite vom Cap + diesem Fix
begrenzt; in ∞ by-design) → optionale Klassifikations-Erweiterung, deferred bis Nutzer Full-Disk-Hammering meldet.
## Runde 5 (unberuehrte Subsysteme: auto-reconnect, Scheduler-Fairness, Crash/Persistenz) — Workflow w7woztsxx
3 Finder (reconnect / scheduler / persistence) → adversarisch verifizieren (3 Lenses). 1 Kandidat, 0 confirmed.
**Sauberes „kein bestaetigter Bug"-Ergebnis.** Auto-reconnect-Resume, Slot-Accounting/Fairness und
Crash-Recovery/targetPath-Lifecycle sind solide (jeder Park/Slot hat Auto-Clear, stop/reset/abort drainen
Waiter + nullen Counter).
- **DOKUMENTIERT, nicht gefixt (LOW, benign): PP-SEM-1** Post-Process-Semaphore (acquire/releasePostProcessSlot,
~7269-7304). Mechanismus real: ein geweckter Waiter, dem ein Fast-Path-Queue-Jumper im Microtask-Gap den
Slot klaut, laeuft trotzdem (Re-Check nach next.resolve() nicht autoritativ) und released spaeter einen nie
inkrementierten Slot → packagePostProcessActive UNTER-zaehlt um 1/Vorkommen. Wirkung: **Ueber-Admission**
(mehr als maxParallelExtract parallele Extraktionen) = gebundene CPU/IO-Verschwendung, NIE Strand/Loss/
Korruption. Der einzige Strand-Pfad (`<=0`-Guard skippt Waiter-Wake) war NICHT organisch erreichbar (nur
durch manuelles Nullen des Counters). Self-heilt bei jedem stop/reset/drain. Fix bekannt (Re-Check
autoritativ machen ODER Increment in den Releaser ziehen) + rot-testbar, aber benign → kein Live-Server-
Hotfix wert (LOW-Fix-Schwelle: Wirkung ist Verschwendung, keine Korrektheit).
## Runde 2 (Download-Ausfuehrung: stream/resume/disk/integrity/extract/persist) — Workflow whspc8ddv
14 confirmed / 7 refuted (>=2/3 adversarisch). Alle HIGH/MED unten unabhaengig am echten Code
verifiziert (Zeilen zitiert) bevor gefixt. Jeder Fix mit rot-bewiesenem Test, tsc bleibt 6.
### Batch 2b → noch nicht released (buendeln, dann v1.7.216)
- [x] #R2-1/4 HIGH Pre-alloc-stat-Reconciliation blaeht `written` auf Padding-Groesse auf → stille
Null-Byte-Korruption auf win32. reconcileFinalizedSize() (download-completion.ts, rein+getestet),
nur noch ABWAERTS-Korrektur bei preAllocated. Commit 2646cba.
- [x] #R2-3 HIGH Nicht-Archiv-".001" ohne Signatur wurde als extrahiert gezaehlt → ganze .00x-Familie
beim Cleanup geloescht (Datenverlust). skippedNonArchives (pathSetKey) aus cleanupSources gefiltert,
frisch + resume. End-to-end-Test. Commit 56bae4a.
- [x] #R2-9 MED + #R2-14 LOW Settings-async-Writer ohne Generations-Schutz → Lost Update; shutdown rief
cancelPendingAsyncSaves nicht. Eigener syncSettingsSaveGeneration (Spiegel des Session-Pfads) +
cancel in shutdown. Commit 1a33fc2.
- [x] #R2-6 MED Teildatei verwaist beim Entfernen eines laufenden Downloads (catch-early-return vor
Cancel-Cleanup). rmSync im catch vor dem return (nach Stream-Close, kein Race). Commit 2b639b7.
- [x] #R2-8 MED Hash-Manifest: Pro-Zeile-Algorithmus von Dateiendung ueberschrieben → gute Datei
geloescht bei fehl-etikettiertem Manifest. parseHashLine-Algorithmus uebernehmen. Commit 4578991.
- [x] #R2-7 MED Startup-Dedup ersetzt gute kanonische Datei durch kleineres Duplikat (+ EXDEV-Loss-
Fenster). Size-Guard (kanonisch >= Duplikat → behalten) + rename-zu-.dedupbak-Reihenfolge mit
Restore. Commit folgt nach voller DM-Suite.
- [deferred/dokumentiert] #R2-5 stream-end akzeptiert truncated download (ohne Laengensignal nicht
entscheidbar; Web ist post-214 nur Fallback) — nur WARN-Log sinnvoll, kein sicherer Fix.
- [deferred/dokumentiert] #R2-10 CRC-verifizierte Kleindatei vom suspicious-small-Heuristik geloescht;
#R2-11 Resume-empty-output-Bypass; #R2-12 7z-Exit-1-Warnung als Erfolg; #R2-13 Companion-.srt/.nfo-
Overwrite. Je narrow/heuristisch → charakterisiert, nicht blind gefixt (LIVE-Server-Schwelle).
### Batch 2 (commit, noch nicht released — buendeln mit Runde-2-Findings)
- [x] #6 MED onefichier/ddownload-catch respektiert autoProviderFallback=off (Guard nach abort-rethrow).
Test: 1fichier KO + Fallback aus → reject, mega getLink NICHT aufgerufen (rot-ohne-Fix beweisbar).
ACHTUNG-Notiz: replace_all matchte faelschlich auch den getLinkInfos-Filename-catch (~3614) → revertet
(Filename-Aufloesung muss nicht-fatal bleiben). Nur die zwei Hoster-catch-Bloecke geaendert. Commit 21fb09b.
- [deferred LOW, dokumentiert statt blind-fix] #9 overwrite targetPath-wipe, #10 HTTP416 shared counter,
#11 fresh-retry preempt typed handlers, #12 shelve+shared counter, #13 queue-wait→elapsedMs, #5 Web-bad-creds.
→ je nur fixen wenn rot-ohne-Fix billig beweisbar + null Regression; sonst bleibt's charakterisiert.

View File

@ -1,164 +0,0 @@
# Offene Entscheidungen für dich (Audit-Loop 2026-06-17)
Diese zwei Punkte habe ich BEWUSST nicht autonom „gefixt", weil jede Lösung
einen Produkt-/Geschmacks-Kompromiss enthält, den du entscheiden solltest, nicht ich.
Beide sind verifiziert (Code-Zitat + Szenario), nur die Richtung ist deine Wahl.
---
## 1. Failover-Kappung durch globalen 60-Sekunden-Timeout (HIGH)
**Was passiert (belegt):**
`download-manager.ts` baut EINEN Timeout fürs gesamte Unrestrict:
```
const unrestrictTimeoutSignal = AbortSignal.timeout(getUnrestrictTimeoutMs()); // 60s
const unrestrictedSignal = AbortSignal.any([active.abortController.signal, unrestrictTimeoutSignal]);
```
Dieses EINE Signal geht an die komplette Provider-Kette in `debrid.ts`. Die 60s sind
also ein Budget für ALLE Provider zusammen, nicht pro Provider. Wenn Provider 1
(z.B. Mega-Web mit Account-Queue) das Budget verbraucht, dann sieht Provider 2 ein
bereits abgelaufenes Signal → `debrid.ts` wertet den Abbruch als „kein Failover" und
wirft, ohne Provider 2 echt zu versuchen.
**Aktuell weitgehend latent:** Seit v1.7.214 wird die API zuerst probiert (schneller Pfad),
Mega-Web ist nur noch Fallback. Das Szenario „langsamer Provider 1 hungert Provider 2 aus"
trifft in der Produktion derzeit selten. Deshalb dokumentiert statt dringend gefixt.
**Deine Entscheidung — zwei Richtungen (gleiche Spannung, andere Seite):**
- **A) Pro-Provider-Timeout:** Jeder Provider bekommt sein eigenes frisches Budget.
Failover bekommt IMMER einen echten Versuch.
*Kosten:* Worst-Case-Wartezeit pro Item steigt. Das Budget ist dabei ein DREHregler, kein
fixer Wert: 60s/Provider = bis 180s Worst-Case (3 Provider), 30s/Provider = bis 90s usw.
Du akzeptierst langsameres Worst-Case-pro-Item für vollständigeres Failover — und stellst
über den Pro-Provider-Wert ein, wie viel langsamer.
- **B) Globales Budget behalten, aber Slice für Failover reservieren:** Provider 1 wird auf
z.B. 35s gedeckelt, damit garantiert Zeit für Provider 2 bleibt.
*Kosten:* Ein legitim langsamer Provider 1 (Mega-Web-Account-Queue bis ~90s) wird früher
abgeschnitten → mehr Failover, auch wenn Provider 1 noch erfolgreich gewesen wäre.
Kernfrage, die nur du beantworten kannst: **Wie lange darf ein Item bei einem langsamen
aber funktionierenden Provider hängen, bevor wir ihn zugunsten des nächsten aufgeben?**
---
## 2. Gespiegelter Mega API/Web-Schalter (HIGH, in Runde 3 als 2/3 bestätigt)
**Was passiert (belegt):** Die Mega-Debrid API- und Web-Account-Zeilen teilen sich EINE
login-only Enable-Flag (`hasMegaDebridCredentials` gilt für beide; die Pro-Modus-Auswahl
läuft über `isMegaDebridModeEnabled(settings, "api"|"web")`). Dein Report: „API ausschalten
schaltet Web an" — weil der Schalter sich spiegelt.
**Warum ich es NICHT autonom geändert habe:**
- Daten-Modell-Fix (echte unabhängige Flags) braucht eine Settings-Migration, die
deaktivierte Accounts versehentlich re-aktivieren könnte.
- Die saubere UI-Variante (zwei echte unabhängige Schalter) ist ein Layout-Redesign — und
du bist UI-Geschmack-sensibel; das will ich nicht ungefragt umbauen.
**Deine Entscheidung:** Ein gemeinsamer Schalter (API+Web zusammen an/aus, klar beschriftet)
ODER zwei unabhängige Pro-Modus-Schalter (mehr Kontrolle, aber UI + Migration nötig)?
---
## Erledigt in dieser Runde (zur Info, kein Handlungsbedarf)
- **Download-Statistik zählt eine Datei nach einem Integritäts-/Zu-klein-Neuversuch nicht mehr doppelt:**
Schlug eine Datei die CRC-/Hash-Prüfung fehl (oder kam zu klein an) und wurde komplett neu geladen, wurde die
Dateigröße bisher pro Versuch erneut in die Statistik addiert — die Anzeige „insgesamt heruntergeladen" (Session
und Gesamt-Zähler) sowie die daraus berechnete Durchschnittsgeschwindigkeit waren dadurch bei flatterhaften Hostern
um die jeweilige Dateigröße aufgebläht (bei großen Archiven mit wiederholten CRC-Fehlern um mehrere GB). Jetzt zählt
jede gelieferte Datei genau einmal. Reine Anzeige-/Statistik-Korrektur — Slot-Vergabe, Tageslimits und der
Download-Ablauf waren nie betroffen (die Tageslimit-Zähler werden bewusst nicht angefasst, da sie die Provider-Auswahl
steuern und der echte Datenverkehr über die Leitung ging). Rot-bewiesener Test, beide Zähler einzeln geprüft.
- **Debrid-Link: ein abgebrochener Vorgang sperrt den Key nicht mehr unnötig:**
Wenn du einen Vorgang abgebrochen hast (oder der Gesamttimeout zuschlug), bevor echte Arbeit lief, bekam der
Debrid-Link-Key bisher trotzdem eine 15-Sekunden-Sperre — bei mehreren Abbrüchen in Folge konnte das sogar über
mehrere Keys kaskadieren und eine längere providerweite Sperre auslösen. Jetzt wird ein schneller Abbruch (vor der
Mindest-Laufzeit) nicht mehr als Key-Fehler gewertet: keine Sperre. Lief der Vorgang dagegen lange genug und brach
dann ab (echter langsamer/hängender Key), wird er weiterhin gesperrt, damit der nächste Versuch sauber auf den
nächsten Key rotiert. Spiegelt exakt das Verhalten, das es bei Mega-Debrid schon gibt. Rot-bewiesener Test.
- **Mega-Konvertierungs-Stau (geprüft, kein Eingriff nötig):** Die Zahl gleichzeitiger Mega-Umwandlungen ist bereits
auf die Anzahl nutzbarer Accounts gedeckelt — Überschuss wartet sauber im Scheduler statt sich in den Account-
Warteschlangen zu stapeln, und der oben beschriebene Mega-Web-Fix hält dieses Limit jetzt korrekt hoch. Ein
zusätzlicher Eingriff wäre überflüssig oder würde durch Über-Vergabe erst echten Stau erzeugen. Auf deine
Entscheidung hin daher bewusst NICHT verändert.
- **Alte Konfiguration: Mega-Debrid fällt nach einem Upgrade nicht mehr still aus der Provider-Reihenfolge:**
Eine Konfigurationsdatei, die noch von einer sehr alten Version (vor v1.6.90) stammt, kannte die getrennten
Mega-Debrid „API aktiv"/„Web aktiv"-Schalter noch nicht. Beim Laden wurden diese fehlenden Schalter still auf
„aus" gesetzt, obwohl Mega-Zugangsdaten vorhanden waren — und sobald man danach das erste Mal die Einstellungen
speicherte, wurde Mega-Debrid dadurch lautlos aus der Provider-Reihenfolge entfernt. Jetzt wird beim Laden einer
solchen alten Datei erkannt, dass die Schalter komplett fehlen, und Mega-Debrid passend zu deiner Bevorzugung
(API oder Web) aktiviert — genau die Migration, die ursprünglich gedacht war, aber durch einen Default-Vorrang
nie ausgelöst hatte. Ehrlicher Umfang: Das betrifft nur alte Dateien, die seit dem Upgrade noch NICHT über die
Einstellungen neu gespeichert wurden — wer seit dem Update schon einmal in den Einstellungen gespeichert hat, hat
die Schalter bereits als „aus" stehen und greift dort weiterhin manuell ein (das ist Absicht: ein bewusst auf
„aus" gestellter Schalter wird NICHT wieder angeschaltet). Rot-bewiesener Test; voller Testlauf grün.
- **Mega-Web: gesunder Account sperrt sich nicht mehr selbst, nur weil er gerade belegt war (deine „Tool sperrt sich selbst"-Klasse, Web-Variante):**
Wenn mehrere Links gleichzeitig über DENSELBEN Mega-Account umgewandelt wurden, laufen sie absichtlich nacheinander
(eine Warteschlange pro Account, damit nicht doppelt eingeloggt/gehämmert wird). Wartete ein Link in dieser Schlange
noch auf seinen Vorgänger und lief dabei der 60-Sekunden-Gesamttimeout ab, wurde der Abbruch fälschlich wie ein echter
Account-Fehler gewertet → der völlig gesunde Account bekam 120 Sekunden Sperre. Jetzt wird ein Abbruch, der NOCH IN DER
Warteschlange passiert (bevor echte Arbeit begann), als reiner Warteschlangen-Timeout erkannt: KEINE Account-Sperre,
der Link wird einfach erneut versucht und rotiert dann von selbst auf einen freien Account. Ein echter Abbruch MITTEN
in der Arbeit sperrt den Account weiterhin (damit langsame Accounts korrekt übersprungen werden) — das blieb unverändert.
Zwei rot-bewiesene Tests (jeder ohne Fix nachweislich rot; der zweite läuft komplett durch die echte Account-Rotation
und prüft, dass keine Sperre gesetzt wird). Die Rotation auf einen freien Account ist über die Auslastungs-Verteilung
(am-wenigsten-belegter-Account-zuerst) abgesichert — strikt besser als die alte 120s-Pauschalsperre.
- **Stille Datei-Beschädigung beim Resume nach Verbindungsabbruch behoben (für Dateien mit bekannter Größe):**
Wenn ein Debrid-Server beim Abbruch einen kleinen Fehler-Müll-Block mitten in den Datenstrom schreibt und
das genau im LETZTEN Wiederhol-Versuch passierte, blieb dieser Müll in der Datei und der anschließende
Neuversuch (mit frischem Link) hängte die echten Bytes DAHINTER an — die Datei hatte am Ende exakt die
richtige Größe und galt deshalb als fertig, obwohl mittendrin Müll steckte. Bei .mkv/.mp4 ohne Prüfsumme
fiel das nie auf. Jetzt wird der verdächtige Datei-Schwanz vor der Linkerneuerung zurückgespult, sodass der
Neuversuch ihn sauber überschreibt. Rot-bewiesener Test (Inhalt byte-genau geprüft, nicht nur die Länge).
Ehrlich eingeordnet: Das ist behoben für Dateien, bei denen der Anbieter die Größe meldet (fast immer der
Fall). Für die seltenen Fälle ganz ohne Größenangabe bleibt eine separate, schon vorher bestehende Lücke
(ohne Längensignal nicht über dieses Rückspulen lösbar) — dokumentiert als langfristiges Thema.
- Zusätzlich gehärtet: Ein fehlgeschlagenes Zurückspulen (z.B. Datei kurz von Virenscanner gesperrt) wird
jetzt im nächsten Versuch erneut probiert statt still übergangen.
- **Deutsche Tonspur: falsche Spur-Auswahl behoben (Datenverlust-Schutz):** Die Erkennung der
deutschen Tonspur hat den Titel-Text einer Spur („...German...") auch dann ausgewertet, wenn die
Spur bereits ein anderssprachiges Tag hatte (z.B. eine englische Spur mit „German" im Titel, wie
„German Commentary"). Lag so eine Spur VOR der korrekt mit „ger" getaggten Spur, wurde die falsche
(englische) behalten und die echte deutsche Spur beim Remux unwiderruflich verworfen — das Ergebnis
wurde als Erfolg gemeldet. Jetzt wird der Titel nur noch dann herangezogen, wenn gar kein Sprach-Tag
vorhanden ist (so war es ohnehin gemeint). Korrekt getaggte deutsche Spuren gewinnen jetzt immer.
Rot-bewiesener Test. (Noch nicht released — wird mit der nächsten Runde gebündelt.)
- **Endlos-Wiederholung bei festem Wiederholungslimit behoben:** Wenn du ein FESTES Retry-Limit
(z.B. 5) eingestellt hattest UND ein Link sprunghaft verschiedene Fehlerarten produzierte
(mal Umwandlungs-Timeout, mal Abbruch mitten im Download, mal allgemeiner Fehler), konnte ein
Eintrag in einer Endlosschleife hängen: eine interne „Viele-Fehler"-Pause halbierte die Zähler,
sodass das eingestellte Limit nie erreicht wurde — der Eintrag scheiterte nie, blockierte dauerhaft
einen Download-Slot und hämmerte den Anbieter (weil dabei auch die Anbieter-Sperre zurückgesetzt
wurde). Jetzt wird das von dir eingestellte Limit hart eingehalten: nach N Versuchen scheitert der
Eintrag sauber. Standard-Einstellung („unendlich", der Auslieferungs-Default) war nie betroffen.
Rot-bewiesener Test.
- **Mega „bis Tagesreset gesperrt" parkt jetzt wirklich (statt alle 2 min neu zu versuchen):**
Wenn ALLE Mega-Accounts wegen wiederholt leerer Antworten bis zum Tagesreset geparkt waren,
hat das Tool den Fehler bisher als normalen Umwandlungsfehler behandelt und den ganzen Tag
alle ~2 Minuten neu probiert (und dabei den Provider-Circuit-Breaker mit Fehlern vollgemüllt).
Jetzt erkennt es den Park und legt das Paket EINMAL bis zum Tagesreset schlafen — genau das,
was der „bis Tagesreset"-Park eigentlich erreichen sollte. Bei Standard-Einstellungen heilte
sich das vorher schon um Mitternacht selbst (kein Datenverlust), war aber unnötige Log-Flut
und Churn. Rot-bewiesener Test.
- **MED Failover-Routing:** Wenn ein Provider in den Manager-Cooldown läuft (≥20 Fehler in
Folge) und auto-Fallback an ist, hat der Manager bisher zwar einen Ersatz-Provider berechnet,
ihn aber WEGGEWORFEN — die Kette führte trotzdem wieder mit dem ausgebremsten Provider an.
Jetzt wird der Ersatz-Provider als „Lead" durchgereicht und die Kette führt mit ihm an, OHNE
einen Provider zu verlieren (der ausgebremste bleibt als letzter Notnagel in der Kette).
Greift nur, wenn der Provider bereits nachweislich degradiert ist → strikt-besser-wenn-aktiv,
kein Timeout/Cancel-Vertrag berührt. Rot-bewiesener Test. (Hält für Roll-up-Release bereit.)

View File

@ -1,361 +0,0 @@
# Lessons
## 2026-06-17 — "Permanent/tot" NIE annehmen ohne Transienz-Gegenprobe (supprimé war transient)
**Muster:** Mega-Debrid lieferte 479x "Fichier supprimé chez l'hébergeur". Ich nahm
"supprimé = gelöscht = toter Link" als permanent an, baute Fix (sofort scheitern, kein
Web-Fallback) + released v1.7.210. Der User fragte: "welcher Link soll tot sein, hast du
das hinterfragt?" Gegenprobe an den Logs: von 18 Links mit "supprimé" haben **4 Sekunden
später ein OK** geliefert (1x Web, 3x API-Retry 766s). Der Fehler war TRANSIENT. 210
hätte erholbare Links dauerhaft gekillt. Korrektur v1.7.211: temporär statt permanent.
**Regel:**
- Bevor ein Fehler als permanent/fatal/tot klassifiziert wird: an echten Daten prüfen, ob
derselbe Link/dieselbe Ressource mit demselben Fehler **jemals danach ein OK** bekam.
Intersection(failed-links, ok-links) ≠ ∅ → transient → permanent-Klassifizierung ist
falsch. Wortbedeutung ("supprimé"=gelöscht, "deleted") beweist KEINE Permanenz —
besonders bei flakigen Multihostern (Mega-Debrid), die per-Link kurzzeitig falsch melden.
- **Die Linse (Advisor):** bei jedem Fehlersignal fragen — ist das über den ACCOUNT
(→ ggf. Cooldown), über den LINK PERMANENT (→ Item scheitern), oder nur über DIESEN
VERSUCH (→ Retry)? Beide Bugs hier (Account-Cooldown-Vergiftung UND falsch-permanent)
waren derselbe Fehler: ein Per-Versuch-Signal als account-/link-globaler Zustand behandelt.
- Wieder die 05-31-Regel verletzt (empirisch bestätigen vor Release). Wenn der User
skeptisch nachfragt ("hast du das hinterfragt?"), ist das fast immer ein echter
ungeprüfter Sprung — sofort an Daten gegenprüfen, nicht verteidigen.
- Retry-Pacing verifizieren, nicht annehmen: cooldownMs:0 entfernt Account-Cooldown,
aber der Download-Manager bremst per 5s-Exponential-Backoff (unrestrictDelayMs) pro
Item — getraced, weit unter Mega-Debrid 50 req/s. Account-Cooldown ≠ Retry-Pacing.
## 2026-05-31 — Fix-Diagnose EMPIRISCH bestätigen, bevor man released (Timeout ≠ Account-Hänger)
**Muster:** "acc2/acc3 nie versucht" wurde als "acc1 hängt → Per-Account-Timeout +
Rotation" diagnostiziert und als v1.7.168 released. Falsch: Mega-Debrid-**Web** ist eine
180s-Polling-Schleife (`mega-web-fallback.ts`) — acc1 *pollte* legitim, der 60s-Global-
Timeout (nicht "Hängen") schnitt es ab. Mein 25s-Per-Account-Cap machte es SCHLIMMER
(endlose 25s-Rotation, Datei nie aufgelöst). Erst der User-Log + Lesen der Provider-
Impl deckte es auf. Revert v1.7.169.
**Regel:**
- Ein Timeout bei einem langsam-pollenden Provider ist KEIN Account-Fehler → darf keine
Rotation/kein Skippen auslösen. Vor "Account hängt"-Annahmen die Provider-Impl lesen
(Polling? internes Ceiling? wie lange dauert ein Erfolg legitim?).
- Bei zwei gegensätzlichen Diagnosen (hier: Timeout-zu-kurz vs. IP-Block — stand in der
EIGENEN Memory!) NICHT die bequeme wählen + releasen. Erst empirisch diskriminieren
(Env-Var auf Server, Beobachtung, oder gezielte User-Frage). Ein Symptom, das BEIDE
Hypothesen gleich gut erklärt ("Timeout nach Xs"), beweist keine.
- NICHT lokal "verifizieren" wenn das Problem umgebungsspezifisch ist (geblockte
Server-IP) — lokaler Erfolg ist falsch-positiv.
## 2026-05-30 — Abgestürzten/„aufgehängten" Chat fortsetzen: zuerst reflog lesen
**Muster:** User bat, einen anderen, aufgehängten Chat-Strang „zu Ende zu bringen".
Der Working Tree sah harmlos aus (nur untracked), aber der eigentliche Fortschritt lag
in einem per `reset --hard HEAD~1` weggesetzten Commit, der nur noch im **reflog**
(dangling) lebte.
**Regel:** Bei „mach weiter wo es hing":
1. `git reflog` + `git log --oneline -20` zuerst — Ground Truth, NICHT der
(evtl. stale) gitStatus-Snapshot oder Konversations-interne Annahmen.
2. Reset-weggesetzte/dangling Commits (`git fsck --lost-found`, reflog) inspizieren
(`git show <sha>`) — dort steckt oft die unfertige Arbeit.
3. **Verstehen WARUM weggesetzt**, bevor man blind cherry-picked: hier brach ein
bestehender Test (`.toBe(signal)`-Identitätscheck), den der Fix zwingend ändert.
Der Reset war die Reaktion darauf, nicht „Fix war falsch". Erst die Reset-Ursache
beheben (Test auf Verhalten umstellen), dann den Fix recovern.
4. Eigene Memory (`project_*`) lesen — sie dokumentierte Bug + intendierten Fix exakt.
## 2026-05-30 — Release verifizieren BEVOR "fertig" gesagt wird; curl -F mit Leerzeichen im Pfad
**Muster A (Edit ins Leere + trotzdem released):** Ein Edit schlug fehl ("String not
found"), ich habe es übersehen, committet und v1.7.165 released — die Datei enthielt
das Feature NICHT. Erst der nächste Blick zeigte es.
**Regel:** Nach jedem Feature-Edit VOR dem Release `git show HEAD:datei | grep <marker>`
— bestätigen dass der Code wirklich im Release-Commit ist, nicht nur dass `git commit`
durchlief.
**Muster B (Gitea UNIQUE constraint):** `npm run release:gitea` pusht erst den Tag,
dann erstellt es den Release. Gitea legt beim Tag-Push automatisch einen Tag-Release-
Eintrag an (name=null). `fetchExistingRelease` im Script matcht den nicht → POST create
`UNIQUE constraint failed: release.repo_id, release.tag_name`. Commit + Tag sind dann
schon gepusht, nur der Release+Assets fehlen.
**Recovery:** `GET /api/v1/repos/.../releases/tags/<tag>` → id holen → `PATCH releases/<id>`
mit name/body/draft:false → Assets per `POST releases/<id>/assets?name=<url-encoded>` hochladen.
**Muster C (curl -F Datei mit Leerzeichen):** `curl -F "attachment=@release/Datei mit
Leerzeichen.exe.blockmap"` lädt FALSCHEN Inhalt hoch (Server-Size != lokale Size).
**Regel:** Datei mit Leerzeichen im Namen erst nach `/tmp/leerzeichenfrei` kopieren,
DAS hochladen, Asset-Name über `?name=<url-encoded>` setzen. Danach Server-Size gegen
lokale Size prüfen.
## 2026-05-30 — Nicht in chaotische Parallel-Tool-Batches verfallen (User-Korrektur: "bist du in nem endless loop")
**Muster:** Bei einem großen Multi-File-Edit habe ich Dutzende Tool-Calls (Bash-Probes,
Reads, Edits, Python-Inline-Skripte, mehrfache tsc-Läufe) in EINEN Message-Block gepackt.
Resultat: Ein einzelner Fehler/Cancel hat die ganze parallele Kette abgebrochen, Edits
landeten halb, ich verlor den Überblick welche Änderung wirklich auf Disk war, und es
wirkte wie eine Endlosschleife. Dazu: wegwerf-`scripts/_*.py`/`_*.txt` als Workaround
gegen Output-Encoding statt der dedizierten Tools.
**Regel:**
- Edits über mehrere Dateien **sequenziell, einer nach dem anderen**, mit kurzer
Verifikation dazwischen — nicht 20 spekulative Calls auf einmal.
- Nach jedem Edit, der fehlschlagen kann (Anchor evtl. nicht eindeutig), das Ergebnis
lesen, bevor der nächste folgt. Edit/Write erroren laut — darauf vertrauen.
- KEINE Wegwerf-Python-Skripte ins Repo schreiben, um Shell-Output zu parsen. `Grep`/
`Read`/`Edit` nutzen. Wenn doch ein Temp nötig ist: nach `os.tmpdir()`, nie nach
`scripts/`, und sofort wieder löschen.
- Verifikation gebündelt am ENDE (1× tsc, 1× build, 1× vitest), nicht 10× zwischendrin.
## 2026-05-28 — Analyse-Befund gegen beobachtete Realität gaten (Advisor-Korrektur)
**Muster:** Meine Analyse sagte einen *häufigen* Bug voraus (jede letzte Datei im
Standard-Modus + jede Nested-Datei landet unbenannt), während der User nur "1-2 pro
Staffel" meldete. Ich habe die Diskrepanz bemerkt ("zu schwer um unbemerkt zu bleiben")
und sie mit weiterem Timing-Argument wegrationalisiert.
**Regel:** Wenn die eigene Analyse etwas vorhersagt, das der beobachteten Realität
widerspricht, NICHT die bequeme Lesart wählen — **mit einem Reproduktions-Test gaten**,
bevor man fixt. Failing Test gegen den Ist-Stand zuerst (TDD/systematic-debugging Phase 4):
- reproduziert → Bug bestätigt, mit Sicherheit fixen.
- reproduziert nicht → Analyse hat eine Mitigation übersehen, kein Fix für Nicht-Bug.
## 2026-05-28 — Crash-Debris im Working Tree: stashen, nicht verwerfen
**Muster:** Eine abgestürzte Session (API 400) hinterließ ein uncommittetes Working Tree,
das drei releaste Commits revertierte. Verlockung: `git checkout`/discard, um clean HEAD
zu bekommen.
**Regel:** Fremde/unverstandene uncommittete Änderungen **`git stash`** (non-destruktiv,
recoverable), nie blind verwerfen. Gibt clean HEAD, nichts geht verloren, kein Stall auf
User-Rückfrage. Danach dem User sagen WAS gestasht wurde und WARUM.
## Wiring-Lock vs. Mechanism-Test
Ein Test, der eine Hilfsfunktion mit dem richtigen Flag direkt aufruft, beweist nur, dass
das Flag funktioniert — NICHT, dass der Produktionspfad das Flag setzt. Für echte
Absicherung einen End-to-End-Test durch den realen Einstiegspunkt fahren und per
Negativ-Gate (Flag temporär entfernen → Test muss fallen) verifizieren.
## 2026-05-31 — Log-Symptom ≠ User-Wortlaut: greppen, bevor man auf eine Meldung triggert
**Muster:** User meldete Mega-Debrid-Tageslimit als „Kein Server für diesen Hoster". Ich
wollte den Fix an genau diese Meldung (`MEGA_DEBRID_NO_SERVER_RE`) hängen. Der Advisor
stoppte: der Screenshot zeigte als Cooldown-Grund **„Antwort leer"**, nicht „Kein Server".
**Beweis (Support-Bundle gegrept):** „Kein Server"/„Erreur"/„aucun serveur" = **0** Treffer
im ganzen Bundle, „Antwort leer" = **20.861** Treffer. Der limitierte Account liefert im
Web-Pfad NIE eine unterscheidbare Meldung — `generate()` findet ohne `processDebrid`-Code
keinen Code → `return null` → der Aufrufer macht daraus „Antwort leer". Ein Trigger auf
„Kein Server" wäre toter Code gewesen (= die v1.7.172-Falle, zum 2. Mal fast getreten).
**Regel:** Bevor man einen Fix an einen bestimmten Meldungstext hängt, in den ECHTEN Logs
greppen, ob dieser Text dort überhaupt vorkommt (`count`-Mode, alt-Text vs. Ist-Text). Sind
zwei Fälle auf Message-Ebene nicht unterscheidbar (Tageslimit vs. transienter Blip → beide
„Antwort leer"), nicht raten — über ein **Verhaltens-Signal** klassifizieren: hier eine
Streak (3× hintereinander leer → geparkt), nicht der einmalige Wortlaut.
**Wiring-Test nicht vergessen** (eigene Lesson): die Helfer-Unit-Tests beweisen nur den
Zähler. Ein E2E-Test muss eine ECHTE leere Antwort durch den realen Einstiegspunkt
(`unrestrictWithAccounts` → `classifyAccountFailure` → catch → Park) treiben, sonst bleibt
unbewiesen, dass der Produktionspfad das Signal überhaupt setzt.
## 2026-06-01 — Ein Verifizierer muss dieselbe Pfad-Normalisierung nutzen wie die verifizierte Operation
**Muster:** Neues Renaming-Logging sollte nach jedem Rename verifizieren, ob die Datei
wirklich unter dem Zielnamen liegt. `verifyRename` machte statSync/readdirSync auf den
ROHEN Pfaden — der echte Rename lief aber über `toWindowsLongPathIfNeeded` (\?\-Prefix
ab >=248 Zeichen). Bei langen Scene-Release-Pfaden (genau das, was die App routinemäßig
umbenennt) scheiterten die rohen fs-Calls → falsches „Ziel nicht gefunden" UND — schlimmer —
die Quell-Prüfung scheiterte ebenfalls → `sourceGone` fälschlich true → **falsches „OK"**,
das einen halb-fertigen Verschiebevorgang maskiert. Der Diagnose-Log hätte genau die
schwersten Fälle vergiftet. (Adversarialer Review-Workflow fand es, Confidence 0.8.)
**Regel:** Wenn Code eine Operation VERIFIZIERT, muss er exakt dieselbe Pfad-/Encoding-/
Normalisierung verwenden wie die Operation selbst (hier: \?\-Long-Path-Prefix). Sonst
mis-reportet der Verifizierer still — und am verlässlichsten bei den Edge-Cases, die man
eigentlich fangen wollte. Ein falsches OK in einem Diagnose-Log ist schlimmer als ein
falsches ERROR. Zusatz: readdir-Fehler darf nicht zu „Schreibweise ok" degradieren
(stilles False-OK) → eigenes WARN-Level „nicht verifizierbar".
**Meta:** Bei einem Feature, dessen ganzer Zweck Beobachtbarkeit/Verifikation ist, lohnt
ein adversarialer Review mit Fokus „würde die Verifikation auf der ECHTEN Last (lange
Pfade, case-insensitive FS, EXDEV) korrekt urteilen?" — nicht nur „kompiliert + Happy-Path-Test".
## 2026-06-03 — Renaming „nie 100%": entkoppelte Scans + Namens-Fabrikation aus token-losen Ordnern
**Symptom (aus dem Desktop-Rename-Log diagnostiziert):** 17 Dateien landeten ROH in der
Library ("tvarchiv...s07e12-720.mkv", "4sf-...s04e01.mkv"). KEINE [ERROR]-Zeile — alle [INFO],
weil die Verifikation nur „liegt die Datei am Zielnamen?" prüft, nicht „ist der Zielname
sinnvoll?". Das Logging hat den Bug sichtbar gemacht (genau sein Zweck).
**Root Cause 1 (entkoppelte Scans):** Auto-Rename (scannt nur extractDir, nur present-and-
stable Dateien, Freshness-Gate loggt nur via logger.info → keine Session-Spur) und
collectMkvFilesToLibrary (verschiebt JEDE .mkv, behielt den rohen Basename) sind getrennte
Scans. Eine von Auto-Rename verpasste Datei (verpasster Zyklus ODER lag in „Downloader
Unfertig" außerhalb extractDir) wurde von collect roh weggeschoben. **Fix:** collect leitet
den sauberen Namen SELBST ab — über dieselbe Funktion wie Auto-Rename (decideAutoRenameBaseName,
single source of truth) → Race wird egal, beide Pfade können nicht mehr divergieren.
**Root Cause 2 (latente Fabrikation, vom Advisor gefunden):** decideAutoRenameBaseName
fabrizierte „Mega-Direct-Pack.S01E01" für einen generischen Paketordner, weil
`hasSceneGroupSuffix("Mega-Direct-Pack")` auf „-Pack" falsch-positiv matcht und Guard B dann
die Quell-Episode an einen token-losen Ordner anhängt. Das hätte AUTO-RENAME genauso getroffen
(nur dormant, weil echte Releases saubere Ordner haben). **Fix an der Wurzel:** Rename nur,
wenn IRGENDEIN folderCandidate einen echten Season-/Episode-Token trägt — ein token-loser
Ordner kann keine Episode autoritativ benennen.
**Meta-Lektionen:**
1. Bei „X nie 100%": die Fehler aus dem ECHTEN Log ziehen (greppen), nicht raten. Hier:
„Kein Server" 0×, „Antwort leer" 20k×; und 17 vs vermutete 12 (5 begannen mit Ziffer „4").
2. Symptom-Fix vs Wurzel-Fix: ein collect-seitiger Guard (Quell-Auflösung+Codec) hätte das
Symptom kaschiert + eine Restlücke gelassen; der Wurzel-Fix in der gemeinsamen Funktion
schließt BEIDE Pfade + ermöglicht ehrliches 100%.
3. Wenn ein (Sub-)Agent eine empirische Behauptung aufstellt, die der beobachteten Realität
widerspricht (Review: „liefert no-target" vs Test: „benennt um"), NICHT raten — mit einem
Wegwerf-Diagnose-Test die echte Rückgabe sichtbar machen, DANN entscheiden.
4. „raw-keep ist der Boden" als Guard-Prinzip: ein Rename darf nie einen schlechteren Namen
erzeugen als der Originalname.
## 2026-06-03 (2) — Renaming „verschlimmbessert" guten Quellnamen (Scene-Gruppe mit Unterstrich)
**Symptom (neues Desktop-Log):** `castle.s08e02.german.dl.720p.web.h264-idtv_int.mkv` (bereits
SAUBER) im Ordner `Castle.S08E02.GERMAN.DL.720p.WEB.H264-idTV_iNT` (Paket `scn2-cstl7`) wurde zu
`scn2-cstl7.S08E02.mkv` — also GUTER Name → obfuskierter Paketname. Andere Klasse als die 17
(roh→nicht-angefasst); hier gut→schlechter.
**Ursache (reproduziert, kein Raten):** `hasSceneGroupSuffix("...H264-idTV_iNT")` = false, weil
`SCENE_GROUP_SUFFIX_RE`/`_FALLBACK_RE` Unterstriche im Gruppen-Suffix verbieten. → buildAutoRenameBaseName
verwarf den sauberen Episoden-Ordner (return null) → fiel auf den Paketordner `scn2-cstl7` zurück
→ Episode angehängt = `scn2-cstl7.S08E02`. Guard A (Quelle-besser) griff nicht, weil
`hasMeaningfulSeriesPrefix("scn2-cstl7.S08E02")=true` (Gruppe sieht aus wie Serien-Prefix).
**Fix:** `extractFlexibleSceneGroupSuffix` (existierte, war nicht verdrahtet) in hasSceneGroupSuffix
einbinden → Unterstrich-Gruppen erkannt → sauberer Ordner gewinnt → idealer Name.
**Meta-Lektionen:**
1. „100%" gilt nur fuer die DATEN, die man hatte. Mein lueckenloser Check des 2026-06-02-Logs war
korrekt — aber ein NEUER Download (Castle/idTV_iNT) brachte eine Gruppen-Form, die im alten Log
nicht vorkam. Bei „nie 100%" ehrlich sagen: „fuer die bekannten Faelle 100%, neue Muster brauchen
neue Logs". Das Desktop-Log liefert genau diese neuen Muster.
2. Reproduzieren statt raten: ein 3-Zeilen-Diagnose-Test (buildAutoRenameBaseName pro Ordner +
decideAutoRenameBaseName) zeigte sofort, WELCHER Ordner verworfen wird und warum — nicht spekulieren.
3. Offener Backstop-Gedanke fuer echte Robustheit: ein generelles Guard "ersetze nie einen bereits
VOLLSTAENDIGEN Quellnamen (Serie+Episode+Aufloesung+Codec) durch einen, der die Serien-Identitaet
verliert" wuerde KUENFTIGE unbekannte Gruppen-Formate abfangen — riskanter Eingriff in Guard A,
nur mit Tests + auf User-Wunsch.
## 2026-06-03 (3) — Renaming-Klasse „Junk-Quellname + sauberer Release-Ordner" (Folge-Nummer statt SxxExx)
**Symptom (Log 18-18):** „Kreuzfahrt ins Glück" — 25 Folgen `bet_kig_01_hdt.mkv` (obfuskiert, KEIN
SxxExx-Token) im sauberen Episoden-Ordner `Kreuzfahrt.ins.Glueck.01.Hochzeitsreise.nach.Burma.2007.
German.720p.HDTV.x264-BET` (Episode als bloße „01"). Auto-Rename: „kein Zielname" → 25× roh in die
Library. Diesmal SICHTBAR als 25 [WARN] (vorher 0 WARN) — das Log zeigt die Klasse direkt.
**Ursache (reproduziert):** `buildAutoRenameBaseName` gibt null zurück, sobald die QUELLE keinen
SxxExx-Token hat (Z.1288) — egal wie sauber der Ordner ist. Das „Folge 01"-Nummernformat (kein
S01E01) wurde nie unterstuetzt. VORBESTEHEND, nicht meine v1.7.178/179.
**Fix:** Fallback in decideAutoRenameBaseName — wenn kein Zielname UND Quelle hat keinen
Episode-Token, den ersten folderCandidate nehmen, der ein VOLLSTAENDIGER Scene-Release-Ordner ist:
`hasSceneGroupSuffix(f) && (RESOLUTION_RE.test(f) || CODEC_RE.test(f)) && !SCENE_SEASON_ONLY_RE.test(f)`.
Greift NUR ohne Quell-Episode-Token → schliesst sich mit dem Fabrikations-Guard aus (Mega-Direct hat
Quell-Token → unerreicht). note:"folder-as-is".
**Advisor-Punkt (wichtig):** NICHT nur Aufloesung pruefen — alte deutsche TV-Serien gibt es als
DVDRip/XviD OHNE 720p-Token. `RESOLUTION_RE ODER CODEC_RE` → sonst die naechste Runde. Pin-Test:
DVDRip-Variante (kein 720p, nur x264).
**Edge (Advisor):** Bonus/Sample muss VOR diesem Fallback gefiltert werden (sonst kriegt ein
Featurette/Sample im Episoden-Ordner den Episodennamen). Bestaetigt: Auto-Rename-Loop (Sample-Size +
BONUS_FILENAME_RE) und Collect filtern beide vor der Namensherleitung → gedeckt.
**Meta:** 3. „anderes Format" in Folge — diese Klasse (Junk-Quelle + sauberer Ordner) ist die
groesste verbleibende. Scene-Naming hat aber einen langen Schwanz: ehrlich „diese Klasse ist
abgedeckt", nicht „jetzt 100%". Das Desktop-Log liefert jede neue Klasse sofort.
## 2026-06-04 — KEINE „Claude/AI"-Spuren in oeffentlichen Releases (GitHub)
**Korrektur:** „kein SCHAU MAL wie ich mit claude gearbeitet hab release … entfern alles was da drin
steckt." Beim einmaligen GitHub-Sync (Sucukdeluxe/real-debrid-downloader) waren oeffentlich: `CLAUDE.md`,
`design-mockups/`, `tasks/lessons.md`+`todo.md`, historisch `.claude/`, und **357 Commits mit
`Co-Authored-By: Claude`-Trailer**.
**Regel ab jetzt:** Fuer dieses Projekt KEINE `Co-Authored-By: Claude`-Trailer mehr an Commits
(ueberschreibt die Default-Git-Anweisung — User-Wunsch hat Vorrang). Keine KI-Artefakte (CLAUDE.md,
Mockups, lessons/todo, .claude/) in irgendetwas, das oeffentlich gepusht wird.
**Wie sauber gemacht (ohne Gitea/lokal anzufassen):** isolierter `git clone``git filter-repo`
(`--invert-paths --path …` + `--message-callback` der Trailer-Zeilen droppt) → Force-Push NUR main +
v1.7.180 zu GitHub. Alte Tags NICHT geloescht, sondern via `.git/filter-repo/commit-map` auf ihre
sauberen Commits **umgehaengt** (89 Tags, alle Releases bleiben erhalten) — besser als Loeschen.
**Ehrliche Grenze (Advisor):** Force-Push säubert nur ref-erreichbare Historie. Verwaiste alte Commits
bleiben per voller SHA erreichbar, bis GitHub GC'd ODER das Repo neu angelegt wird (nur der User kann
das — Token hat kein `delete_repo`). Lokaler Klon verifiziert ≠ GitHub-Zustand: immer per `gh api`
gegenpruefen (Datei 404 am Tag, Commit-Messages trailer-frei).
**Methodik:** vor Force-Push Voll-Range-Secret-Scan (push-protection killt sonst mitten im Push) +
Tree-Content-Grep auf `claude|anthropic` (filter-repo tilgt Pfad-NAMEN + Trailer, nicht Datei-INHALTE).
## 2026-06-04 — Folge bleibt bei „Downloader Fertig" haengen: Episodentitel == Bonus-Wort
**Symptom (User-Screenshot + rd-support-bundle):** `Revenge.2011.S04E19.Interview...mkv` extrahiert +
korrekt umbenannt, aber NIE in die Library verschoben — kein Fehler. „selten, 4-5 Folgen pro 1,5TB".
**Diagnose (Bundle):** Paket-Log zeigte 22/23 „MKV verschoben", E19 fehlte, KEIN WARN/ERROR. Im
HAUPT-Log (`rd_downloader.log`) dann 5× `MKV-Sammelordner: Bonus-Datei uebersprungen: ...S04E19.Interview`.
**Root Cause:** `BONUS_FILENAME_RE` enthaelt `interview` (+ outtakes/special/featurette/bloopers/...). Der
Episodentitel „Interview" (UND der Episoden-Ordnername — `isInsideBonusDir` macht `.includes()` Substring)
matchte → `collectMkvFilesToLibrary` stufte die echte Folge als Bonus/Extras ein und skippte sie. Trifft
auch ganze Serien deren NAME ein Bonus-Wort ist. Skip war nur `logger.info` → im Paket-Log UNSICHTBAR
(darum „silent orphan", nur via Forensik gefunden).
**Fix:** neue exportierte `isBonusContent(filePath, packageDir, nameWithoutExt)` — eine Datei MIT echtem
SxxExx-Token (`extractEpisodeToken`) ist eine nummerierte Episode, NIE Bonus (egal welches Titelwort).
Echte Extras (kein Token / Extras-Subordner) bleiben gefiltert. Beide Call-Sites umgestellt (Auto-Rename
~4312 + Collect ~5054). 2 Integrationstests (Interview wird gesammelt / Making.Of bleibt) + 5 Unit-Tests.
**Diagnose-Lektion (Advisor-Gate):** „4-5 Folgen" plural → NICHT beim 1. Fund stoppen. Bundle-weit
gegengeprueft: 0 Move-Fehler, nur 1 Bonus-Skip. 4 weitere „noch frisch"-Defers sahen wie Orphans aus,
waren aber FALSE POSITIVES — Moves loggen NICHT ins Haupt-Log (nur Paket-Log), und deren Paket-Logs fehlten
im Bundle. Per Code bewiesen: finaler Deferred-Collect laeuft fuer jedes fertige Paket (`success` =
completed-Items, Z.11904) mit `deferFreshFiles=false` → faengt Frische-Defers. Also Frische orphan't NICHT;
Bonus schon (Filter ignoriert deferFreshFiles, skippt in JEDEM Pass inkl. final). Lehre: bevor man „X ist
Orphan" behauptet, pruefen ob der GEGENBEWEIS (Move) im verfuegbaren Log ueberhaupt sichtbar WAERE.
## 2026-06-05 — Folge bleibt ROH: vollstaendiger Episoden-Ordner OHNE -GROUP-Suffix
**Symptom (rename-session 2026-06-04):** `safari-fm-s04e08a.avi` / `...b.avi` landeten ROH in der Library
(entpackt2). Log: `Auto-Rename übersprungen: kein Zielname`. Funktionierende S01E02 hatte Ordner
`...XviD-SAFARi` (Gruppe), die kaputten S04E08a/b hatten `...SATRiP.XviD` (KEIN -GROUP).
**Root Cause (Wegwerf-Diagnose, NICHT geraten):** Erste Hypothese „a/b-Token nicht erkannt" war FALSCH —
`extractEpisodeToken("...s04e08a")`="S04E08" (das Lookahead `(?!\d)` verbietet nur Ziffern, nicht Buchstaben).
Echte Ursache: das Gate in `buildAutoRenameBaseName` (`isLegacy4sf || isSceneGroupFolder`) lehnt einen
vollstaendigen Episoden-Ordner OHNE -GROUP ab (endet auf bare Codec `.XviD`). Die QUELLE hat aber einen
Token → der v1.7.180-Fallback (greift NUR ohne Quell-Token) feuert nicht → no-target → roh gemoved.
**Fix:** Gate um `isCompleteEpisodeFolder` erweitert = echter Episoden-Token IM Ordner UND Codec-/
Aufloesungs-Marker (neue Module-Consts `SCENE_RESOLUTION_MARKER_RE` / `SCENE_CODEC_MARKER_RE`, inkl.
xvid/divx). Part-Buchstabe a/b bleibt erhalten (Ordnername dient unveraendert als Zielname; nur der
RANGE-Zweig schreibt Token um, und a/b ist kein Range). Konservativ: bare „Show.S01E01" ohne Marker bleibt
abgelehnt (kein Over-Firing). v1.7.180-Fallback nutzt jetzt dieselben Module-Consts (DRY). Greift in
Auto-Rename UND Collect (beide via decideAutoRenameBaseName). 5 Unit- + 1 Collect-Integrationstest.
**Methodik-Lektion:** Die naheliegende Hypothese (a/b-Suffix) per Diagnose-Test widerlegt, BEVOR gefixt —
das Lookahead genau gelesen statt angenommen. Spart einen Fix am falschen Ort.
## 2026-06-05 — Collect zerstoert fertigen S01E01-Namen via Episoden-Titel-Ordner (Miniserie)
**Symptom (rename-session 2026-06-05):** Miniserie "Steven Spielbergs Taken" landete als
"...E01.Hinter.dem.Himmel...-GTVG.S01E01.mkv" (Episodentitel + hinten angehaengtes S01E01) statt sauber
"...S01E01...-GTVG.mkv". User: "keine Staffel, nur Episodentitel".
**Root Cause (diagnostisch bewiesen):** Auto-Rename benannte korrekt zu "...S01E01...-GTVG.mkv" (kombiniert
S01 aus dem Paket/Season-Ordner + E01 aus der Quelle). Der COLLECT (deriveCleanCollectFileName ->
decideAutoRenameBaseName) leitet die Datei NEU ab — Quelle ist nun der schon-saubere Name. Der per-Episode-
Ordner traegt aber nur einen Episode-only-Token + Titel ("...E01.Hinter.dem.Himmel...-GTVG", KEIN S01).
buildAutoRenameBaseName nimmt den Ordner (Gruppen-Suffix -GTVG vorhanden). In Guard B `if (!targetEpisodeToken)`
wird der Quell-Token an den Ordnernamen ANGEHAENGT (applyEpisodeTokenToFolderName) -> "...-GTVG.S01E01"
(Token HINTER der Gruppe = verkrueppelt). Der Root-Guard greift NICHT, weil der Season-Ordner einen S01-Token
liefert (anyFolderHasSeasonOrEpisode=true).
**Fix:** In Guard B, im `!targetEpisodeToken`-Zweig VOR dem Anhaengen: ist die QUELLE ein NICHT
obfuskierter Scene-Name (`!looksLikeObfuscatedSceneFileName(sourceName)`), dann
`return {kind:"skip", reason:"source-better"}` -> Collect behaelt den fertigen Namen. In diesem Zweig
traegt die Quelle den EINZIGEN SxxExx-Token (Ordner hat keinen) -> obfuskiert? -> Ordner gewinnt (Append),
sauber? -> Quelle gewinnt. Greift NUR im `!targetEpisodeToken`-Zweig (Ordner ohne SxxExx); safari
(Ordner MIT Token) unberuehrt. 4 Unit- + 1 Collect-Integrationstest. tsc 6 (Baseline), 700/700 gruen, Build gruen.
**Methodik:** Erst Diagnose (decideAutoRenameBaseName mit Collect-Inputs) -> exakt der mangled Name
reproduziert. Per User-Wunsch adversarial via Workflow gegengeprueft (ultracode, 3 Lenses + Synthese).
**Adversarialer Befund (Workflow fing's):** Mein erster Guard hatte einen ZWEITEN Konjunkt
`hasMeaningfulSeriesPrefix(sourceBaseName)` (>=3 Alpha vor S0x). Der ist sachfremd: KURZE Serien (ER, V,
24, Yu) fallen durch -> selber verkrueppelter Name. Gestrichen -> nur `!obfuskiert` gaten. Lehre: ein
zusaetzlicher "klingt-vernuenftig"-Konjunkt (Praefix-Laenge) kann eine ganze reale Klasse (Kurz-Titel)
stumm ausschliessen; adversariale Verifikation mit konkretem Gegenbeispiel (ER.S01E01) hat's gefunden.

View File

@ -1,104 +0,0 @@
# Plan: „Nur deutsche Tonspur behalten" (.DL.) als Tool-Funktion
Quelle der Idee: User-Script `Remove Non German Audio.py` (ffmpeg `-map 0:v:0 -map 0:a:0
-c copy -map_metadata -1`, + `.DL.`→`.` Rename). Soll als **toggle­barer Post-Extract-Schritt**
nach jedem Entpacken laufen, nur für **MKV/MP4 mit `.DL.` im Namen** (Dual-Language),
und nur die **deutsche** Spur behalten. Fundiert per 6-Agent-Analyse + Advisor.
## 1. Verhalten (Soll)
- Läuft automatisch nach dem Entpacken eines Pakets (wenn Toggle an), bevor MKV-Collect.
- Pro extrahierter Video-Datei mit `.DL.` im Namen (case-insensitive, nur .mkv/.mp4):
1. Audiospuren prüfen → deutsche/erste Spur bestimmen (Modus = User-Entscheidung, s.u.).
2. Wenn >1 Audiospur: remux (stream-copy, kein Re-Encode) → behält Video + 1 Audio
(+ optional dt. Untertitel) → Temp-Datei → atomar ersetzen.
3. `.DL.` aus dem Dateinamen strippen (`.DL.`→`.`, `.DL`→``), Companion-Dateien (Untertitel/.nfo) mitziehen.
4. Wenn nur 1 Audiospur: **kein** Remux (spart Neuschreiben großer Dateien), ABER `.DL.`-Strip trotzdem.
- Status pro Item sichtbar (z.B. „Tonspur wird bereinigt" / „Deutsche Spur behalten").
## 2. Architektur
- **NEUES Modul `src/main/video-processor.ts`** (spiegelt `extractor.ts`: exportierte async-Funktion
+ Options-Bag, KEINE DI-Klasse — es gibt keinen Constructor-Seam). Enthält:
- ffmpeg/ffprobe-Spawn nach dem `runExtractCommand`-Muster (extractor.ts:1296): `spawn(cmd,args,{windowsHide:true})`,
Promise-Wrapper, Timeout-Watchdog → `killProcessTree` (taskkill /T /F), **AbortSignal IN den Child** geben.
- **Pure exportierte Helfer** für Unit-Tests: `pickGermanAudioTrack(probeJson, mode)`, `stripDualLangMarker(name)`,
`buildFfmpegRemuxArgs(...)`, `computeRemuxTimeoutMs(bytes)`.
- ffmpeg-Exit-Codes ≠ 7-Zip (NICHT die „exit 1 = ok"-Logik kopieren — nur das Spawn/Await/Kill-Gerüst).
- ffprobe-JSON auf stdout NICHT durch den 48KB-Tail-Cap (`appendLimited`) — stdout separat voll puffern.
- **ffmpeg-Discovery (Option a, empfohlen):** System-PATH + `RD_FFMPEG_BIN` env + lazy `ffmpeg -version`-Probe
gecacht (spiegelt `RD_7Z_BIN`, extractor.ts:1030-1083). **Nicht bündeln** (~80-150MB → triggert den
eigenen 150MB-Large-Bundle-Selfcheck debug-setup.ts:22 + GPL-Lizenzpflicht). Wenn ffmpeg fehlt → Schritt
überspringen + WARN loggen + (optional) in Health-Check/Errors surfacen. NIE Downloads blockieren.
- **CPU-Priorität:** `lowerExtractProcessPriority(pid, priority)` + `extractOsPriority` wiederverwenden,
Priorität als **expliziten Param** (nicht das Modul-Global `currentExtractCpuPriority` — Cross-Talk-Gefahr).
Honoriert `settings.extractCpuPriority`.
## 3. Einhängepunkte (BEIDE Pfade — kritisch!)
Post-Processing ist **pro Paket**, zwei Pfade; Hybrid-Pakete durchlaufen NIE den Deferred-Pass:
- **Deferred** (download-manager.ts ~11614): nach `autoRenameExtractedVideoFiles`, VOR archive-cleanup/collect.
- **Hybrid** (download-manager.ts ~10944): zwischen Rename und Collect im detached Block.
- Beide: **innerhalb `chainPackageFileOp(pkg.id, ...)`** (serialisiert Datei-Ops pro Paket), nur auf
`pkg.extractDir` operieren — NIE im geteilten `mkvLibraryDir` (= der v1.7.107-revertierte Cross-Package-Crash;
autoRename bricht bei Overlap ab, 3905-3919).
- **Gate:** neuen Flag in den Post-Process-Aggregator OR-en (~7078-7084), sonst läuft der Schritt nie
standalone. Hängt inhärent an `autoExtract` (braucht entpackte Dateien).
- Datei-Enumeration: `collectVideoFiles(rootDir)` (rekursiv, SAMPLE_VIDEO_EXTENSIONS, constants.ts:28) — nur
.mkv/.mp4 verarbeiten; Sample/Bonus-Dateien per vorhandenem Skip-Prädikat auslassen.
## 4. Der .DL.-Knoten (LÖST den „Feature no-op"-Fehler)
- Selektion = „Datei hat `.DL.`"; der Schritt strippt `.DL.`. → KEIN früherer Schritt darf den Marker entfernen.
- **autoRename NICHT ändern** (behält `.DL.` verbatim) → Marker überlebt bis zum Video-Schritt.
- Video-Schritt läuft **nach** autoRename → sieht `.DL.` → remuxt + strippt `.DL.` atomar pro Datei.
- **NUR `collectMkvFilesToLibrary.deriveCleanCollectFileName`** bekommt den `.DL.`-Strip als Post-Transform
(läuft NACH dem Video-Schritt → kann den Selektor nicht brechen, verhindert nur Re-Einführung aus dem
Ordner-Token). Companion-Files via `renameCompanionFiles`/`moveCompanionFiles` mitziehen.
## 5. Sicherheitsmodell (Original NIE verlieren)
- Remux → Temp-Datei → Größe > 0 (idealerweise ~plausibel) prüfen → erst dann atomar ersetzen/umbenennen
(`renamePathWithExdevFallback` + `verifyRenameAsync`). ffmpeg-Fehler/Abbruch → Temp löschen, Original bleibt.
- **Disk-Space-Pre-Check**: vor Remux freien Platz ≥ Dateigröße (+Marge) prüfen, sonst skip+log
(Temp verdoppelt transient den Platz auf einer Platte, die grad entpackt hat / parallel lädt).
- **AbortSignal in den ffmpeg-Child** (Deferred-/Hybrid-Controller) → Stop/Cancel/Reset killt laufenden Remux.
- **mtime erhalten** (`fs.utimes` nach Remux) → sonst überspringt Hybrid-Collect (deferFreshFiles=true) die
frisch angefasste Datei.
- **Sicherheits-Invariante (BEIDE Modi):** Original nur ersetzen, wenn die behaltene Spur sicher die richtige
ist. Bei Unsicherheit (keine Tags / kein Deutsch gefunden) → Datei UNANGETASTET lassen + loggen, statt
versehentlich die einzige brauchbare Spur zu löschen.
- Dispositions-Flag der behaltenen Spur auf „default" setzen.
- Best-effort pro Datei: ein Fehler markiert NICHT das Paket als failed und blockiert nicht den Collect anderer Dateien.
## 6. ffmpeg/ffprobe-Aufrufe (Stream-Copy, schnell)
- Probe (nur im Tag-Modus): `ffprobe -v error -select_streams a -show_entries stream=index:stream_tags=language,title -of json INPUT`
- Remux erste Spur (Script-Parität): `ffmpeg -i INPUT -map 0:v:0 -map 0:a:0 [-map 0:s? je nach Untertitel-Option] -c copy -map_metadata -1 -disposition:a:0 default -y TEMP`
- Remux deutsche Spur (Tag-Modus): `-map 0:v:0 -map 0:a:<dt-Index> ...` (Index aus ffprobe).
## 7. Settings/UI-Wiring (5 Pflicht-Stellen, +1 optional)
1. `src/shared/types.ts` AppSettings: `keepGermanAudioOnly: boolean` (+ ggf. `germanAudioMode`, `keepGermanSubs`, `ffmpegPath`).
2. `src/main/constants.ts` defaultSettings: `keepGermanAudioOnly: false` etc.
3. `src/main/storage.ts` normalizeSettings: `Boolean(...)` (Pfad: `asText`, NICHT normalizeAbsoluteDir → leer = System-ffmpeg).
4. `src/renderer/App.tsx` Settings-Tab „entpacken" neben collectMkvToLibrary: Toggle + eingerückte Sub-Optionen (disabled wenn aus).
5. `src/renderer/App.tsx` **emptySnapshot()-Literal** (~840-859) — sonst tsc-Fehler (Feld non-optional).
6. (optional) `src/main/support-data.ts` ~95: Flag in Diagnose-Export spiegeln.
## 8. Tests + Verifikations-Gate
- ffmpeg in Tests **gemockt** (kein echter ffmpeg-Lauf): neues Modul via `vi.mock` in download-manager.test.ts
(assert: korrekt aufgerufen + Sequenz nach autoRename / vor collect, Deferred + Hybrid). KEIN blankes
`vi.mock("node:child_process")` in download-manager.test.ts (bricht echte Extractor-ZIP-Tests).
- Separate `video-processor.test.ts`: `node:child_process` mocken → ffmpeg/ffprobe-ARGS asserten (Track-Wahl, Untertitel-Option).
- Pure Helfer fs-frei testen (wie tests/auto-rename.test.ts): `pickGermanAudioTrack`, `stripDualLangMarker`.
- Negativ-Test: Toggle aus → keine Verarbeitung. Edge: 1-Audio-`.DL.` → nur Rename, kein Remux. Kein-Deutsch → unangetastet.
- **Gate:** tsc-Baseline = 6 vorbestehende Fehler (NICHT clean) → „keine NEUEN tsc-Fehler" + vitest 728→728+N grün + `npm run self-check` grün.
## 9. OFFENE ENTSCHEIDUNGEN (vor Bau — per AskUserQuestion)
- **A. Spurauswahl:** Script-Parität (immer erste Audiospur, kein ffprobe, validiertes Verhalten) vs.
Smart (deutsche Spur per Sprach-Tag, Fallback erste Spur, skip wenn kein Deutsch).
- **B. Untertitel:** weglassen (wie Script) vs. deutsche Untertitel behalten.
- **C. ffmpeg-Quelle:** nur System-PATH + `RD_FFMPEG_BIN` env vs. zusätzlich Settings-Pfad-Feld im UI.
## 10. Umsetzungsreihenfolge (nach Entscheidungen)
1. `video-processor.ts` + pure Helfer + deren Unit-Tests (TDD).
2. ffmpeg/ffprobe-Discovery (probe+cache).
3. Settings-Wiring (5 Stellen) + UI-Toggle.
4. Einhängen in Deferred + Hybrid (in chainPackageFileOp), Gate OR-en.
5. collect deriveCleanCollectFileName: `.DL.`-Strip-Safety-Net.
6. Logging (logRenameProcess, neuer Stage 'audio-strip').
7. Tests (download-manager mock + video-processor args + negativ/edge). Gate prüfen.

View File

@ -1,42 +0,0 @@
# MCP-Ferndiagnose (Goal, ultracode) — v1.7.223
## Ziel (Nutzer)
"Baue massive Diagnose-Funktionen ein (MCP), so dass ich MCP auf nem Windows-Server aktivieren kann und du
auf den Server zugreifst und WIRKLICH ALLES siehst (State, Fehler, Logs, Probleme). 5-6 Server, ueber
Verbindungscode. Du verbindest dich → liest alles → behebst Probleme direkt."
## Architektur (Advisor-bestaetigt v1)
- Standalone **stdio MCP-Bridge** auf MEINER (Claude-Code-)Maschine, proxyt zur bestehenden HTTP `debug-server.ts`
jedes Servers via **Verbindungscode**. Eine Bridge bedient alle 5-6 Server.
- KEIN eingebettetes MCP-over-HTTP (verworfen: hand-rolled Protokoll auf Internet-Oberflaeche = mehr Risiko).
- Bridge-Code identisch fuer Direkt-IP vs spaeterer Tunnel (proxyt host:port).
- Verbindungscode: `rddiag:v1:<base64url(JSON {v,h:host,p:port,t:token,n?:name,fp?:certFp})>`. Nutzer liefert
oeffentlichen Host (nicht auto-detecten).
## Sicherheitsmodell (Advisor, first-class)
Plain HTTP + Bearer ueber Internet = sniffbares Token mit Lesezugriff auf sensible Logs. Mitigations:
- App-seitige IP-Allowlist (extractDebugClientIp existiert schon).
- `/trace/config` MUTIERT → "read-only"-Claim auditieren: Writes von Remote-Oberflaeche gaten oder umlabeln.
- Opt-in + sofort widerrufbar (Token-Rotation killt Zugang).
- debug_token.txt in userData bestaetigen (ueberlebt Auto-Update).
- Optional self-signed Cert + Fingerprint im Code gepinnt (NICHT v1-blockierend).
## Phasen
- [x] **P0 Vertical Slice (Diskriminator) — ERLEDIGT, harness ALL PASS:** Bridge → debug-server via stdio JSON-RPC, echte Daten zurueck.
- [x] MCP SDK API holen (context7) → @modelcontextprotocol/sdk 1.29.0, registerTool(name,{inputSchema:zodShape},cb)
- [x] Bridge in `tools/rd-diagnostics-mcp/` (eigenes package.json, NICHT in App-Bundle): code.mjs/http.mjs/bridge.mjs/gen-code.mjs
- [x] 14 Tools: rd_servers/rd_ping/rd_diagnostics/rd_status/rd_items/rd_packages/rd_errors/rd_logs/rd_history/rd_accounts/rd_host/rd_self_check/rd_get + Multi-Server (code|server|RDDIAG_CODE|RDDIAG_SERVERS)
- [x] Verbindungscode-Codec rddiag:v1:base64url({v,h,p,t,n?,fp?,s?})
- [x] Test-Harness (test/harness.mjs): fake debug-server (auth+routes+query-echo) + Bridge als stdio-Child → 19 Checks gruen (handshake, tools/list, ping, diagnostics+query-passthrough, logs-mapping, errors, escape-hatch, 401, missing-code, unreachable+hint)
- [x] **P1 Security-Hardening — ERLEDIGT:** IP-Allowlist (exakt+CIDR), erzwungen VOR Auth am ECHTEN Socket-Peer (req.socket.remoteAddress), NICHT X-Forwarded-For (Advisor: XFF faelschbar → Bypass; gefixt+Threat-Test). Fail-closed. /trace/config belassen (zeitbegrenzt). userData/runtime verifiziert. Log-Audit: keine Secrets in /logs/*.
- [x] **P2 One-Click-Enable + Code — ERLEDIGT:** restartDebugServer ('close'+closeAllConnections, EADDRINUSE). IPC get/enable/disable/rotate + Controller + Typen. Flache Modal-UI (Hilfe→Remote-Support→"Ferndiagnose (MCP)"): Status, lokal/netzwerk, Public-Host+Chips, Allowlist, Code+Copy+Token-Rotation+Deaktivieren.
- [x] **P3 — durch bestehende Endpunkte abgedeckt:** /accounts (Cooldown/Rotation), /errors, /status, /diagnostics via Bridge. Kein neuer Endpunkt noetig.
- [x] **P4 Verify — ERLEDIGT:** Suite 906 gruen, tsc=6, Harness gruen, Advisor (fing XFF-Bypass). Release v1.7.223 Gitea (6b52678, 4 Assets) + Mirror (24485be, 4 Assets, Claude-frei). Bridge `claude mcp add` (user, ✔ Connected).
- [ ] **P5 Reachability (NUTZER):** Ferndiagnose auf 1 Server an → Code an mich → ich verbinde. Entscheid: Tunnel (sicherste, "Nur lokal") vs Direkt-Bind 0.0.0.0+Allowlist (nur vertrauenswuerdiges Netz/VPN; Token reist plain HTTP).
## Review
Vertikaler Slice zuerst (Bridge gegen Fake-Debug-Server, JSON-RPC stdio), dann App-Seite load-bearing-first
(Backend+Tests vor UI). Advisor fing einen releaseblockierenden Bug: Allowlist nutzte extractDebugClientIp
(X-Forwarded-For zuerst = angreiferkontrolliert) → Bypass per `X-Forwarded-For: 127.0.0.1`; Tests maskierten es
(injizierten die IP per genau dem Header). Fix: Enforcement am Socket-Peer, XFF nur fuers Log; Threat-Test
(socket 8.8.8.8 + XFF 127.0.0.1 → denied). Empfohlener Transport: Loopback+Tunnel; Direkt-Bind nur mit Allowlist.

View File

@ -5,7 +5,7 @@ import path from "node:path";
import { once } from "node:events"; import { once } from "node:events";
import { afterEach, describe, expect, it } from "vitest"; import { afterEach, describe, expect, it } from "vitest";
import { buildBackupPayload, resolveMcpRemoteRestore, BackupMcpRemote } from "../src/main/backup-payload"; import { buildBackupPayload, resolveRemoteDiagnosticsRestore, BackupRemoteDiagnostics } from "../src/main/backup-payload";
import { defaultSettings } from "../src/main/constants"; import { defaultSettings } from "../src/main/constants";
import { normalizeSettings } from "../src/main/storage"; import { normalizeSettings } from "../src/main/storage";
import { import {
@ -21,14 +21,14 @@ import type { AppSettings, SessionState } from "../src/shared/types";
const tempDirs: string[] = []; const tempDirs: string[] = [];
function input(settingsOverride: Partial<AppSettings>, mcpRemote?: BackupMcpRemote) { function input(settingsOverride: Partial<AppSettings>, remoteDiagnostics?: BackupRemoteDiagnostics) {
return { return {
settings: { ...defaultSettings(), ...settingsOverride } as AppSettings, settings: { ...defaultSettings(), ...settingsOverride } as AppSettings,
appVersion: "1.7.224", appVersion: "1.7.233",
exportedAt: "2026-06-19T00:00:00.000Z", exportedAt: "2026-08-01T00:00:00.000Z",
session: {} as unknown as SessionState, session: {} as unknown as SessionState,
history: [], history: [],
mcpRemote remoteDiagnostics
}; };
} }
@ -74,77 +74,96 @@ afterEach(() => {
} }
}); });
describe("backup mcpRemote export gating", () => { describe("backup remoteDiagnostics export gating", () => {
it("includes mcpRemote when backupIncludeMcp is on", () => { it("includes remoteDiagnostics when backupIncludeRemoteDiagnostics is on", () => {
const section: BackupMcpRemote = { allowlist: ["10.0.0.5", "192.168.1.0/24"], port: 9999, hostMode: "network" }; const settings = {
const payload = buildBackupPayload(input({ backupIncludeMcp: true }, section)); ...defaultSettings(),
expect(payload.mcpRemote).toEqual(section); backupIncludeRemoteDiagnostics: true
};
const payload = buildBackupPayload({
settings,
appVersion: "1.7.233",
exportedAt: "2026-08-01T00:00:00.000Z",
session: {} as unknown as SessionState,
history: [],
remoteDiagnostics: {
allowlist: ["192.0.2.0/24"],
port: 8976,
hostMode: "network"
}
}); });
it("omits mcpRemote when the toggle is off even if a section is provided", () => { expect(payload.remoteDiagnostics).toEqual({
const payload = buildBackupPayload(input({ backupIncludeMcp: false }, { allowlist: ["10.0.0.5"], port: 9868, hostMode: "network" })); allowlist: ["192.0.2.0/24"],
expect(payload.mcpRemote).toBeUndefined(); port: 8976,
hostMode: "network"
});
}); });
it("omits mcpRemote when toggle on but no section gathered", () => { it("omits remoteDiagnostics when the toggle is off even if a section is provided", () => {
const payload = buildBackupPayload(input({ backupIncludeMcp: true }, undefined)); const payload = buildBackupPayload(input({ backupIncludeRemoteDiagnostics: false }, { allowlist: ["10.0.0.5"], port: 9868, hostMode: "network" }));
expect(payload.mcpRemote).toBeUndefined(); expect(payload.remoteDiagnostics).toBeUndefined();
}); });
it("the mcpRemote section carries ONLY allowlist/port/hostMode (no token, publicHost, name)", () => { it("omits remoteDiagnostics when toggle on but no section gathered", () => {
const payload = buildBackupPayload(input({ backupIncludeMcp: true }, { allowlist: ["10.0.0.5"], port: 9868, hostMode: "network" })); const payload = buildBackupPayload(input({ backupIncludeRemoteDiagnostics: true }, undefined));
expect(payload.mcpRemote && Object.keys(payload.mcpRemote).sort()).toEqual(["allowlist", "hostMode", "port"]); expect(payload.remoteDiagnostics).toBeUndefined();
const sectionJson = JSON.stringify(payload.mcpRemote); });
it("the remoteDiagnostics section carries ONLY allowlist/port/hostMode (no token, publicHost, name)", () => {
const payload = buildBackupPayload(input({ backupIncludeRemoteDiagnostics: true }, { allowlist: ["10.0.0.5"], port: 9868, hostMode: "network" }));
expect(payload.remoteDiagnostics && Object.keys(payload.remoteDiagnostics).sort()).toEqual(["allowlist", "hostMode", "port"]);
const sectionJson = JSON.stringify(payload.remoteDiagnostics);
expect(sectionJson.toLowerCase()).not.toContain("token"); expect(sectionJson.toLowerCase()).not.toContain("token");
expect(sectionJson).not.toContain("publicHost"); expect(sectionJson).not.toContain("publicHost");
expect(sectionJson.toLowerCase()).not.toContain("\"name\""); expect(sectionJson.toLowerCase()).not.toContain("\"name\"");
}); });
}); });
describe("backupIncludeMcp settings persistence", () => { describe("backupIncludeRemoteDiagnostics settings persistence", () => {
it("normalizeSettings preserves backupIncludeMcp (the toggle survives save/load)", () => { it("normalizeSettings preserves backupIncludeRemoteDiagnostics (the toggle survives save/load)", () => {
expect(normalizeSettings({ backupIncludeMcp: true } as unknown as AppSettings).backupIncludeMcp).toBe(true); expect(normalizeSettings({ backupIncludeRemoteDiagnostics: true } as unknown as AppSettings).backupIncludeRemoteDiagnostics).toBe(true);
expect(normalizeSettings({ backupIncludeMcp: false } as unknown as AppSettings).backupIncludeMcp).toBe(false); expect(normalizeSettings({ backupIncludeRemoteDiagnostics: false } as unknown as AppSettings).backupIncludeRemoteDiagnostics).toBe(false);
expect(normalizeSettings({} as unknown as AppSettings).backupIncludeMcp).toBe(false); expect(normalizeSettings({} as unknown as AppSettings).backupIncludeRemoteDiagnostics).toBe(false);
}); });
}); });
describe("resolveMcpRemoteRestore", () => { describe("resolveRemoteDiagnosticsRestore", () => {
it("maps network + non-empty allowlist to 0.0.0.0", () => { it("maps network + non-empty allowlist to 0.0.0.0", () => {
expect(resolveMcpRemoteRestore({ allowlist: ["10.0.0.5"], port: 9868, hostMode: "network" })) expect(resolveRemoteDiagnosticsRestore({ allowlist: ["10.0.0.5"], port: 9868, hostMode: "network" }))
.toEqual({ host: "0.0.0.0", port: 9868, allowlist: ["10.0.0.5"] }); .toEqual({ host: "0.0.0.0", port: 9868, allowlist: ["10.0.0.5"] });
}); });
it("SAFETY: network with EMPTY allowlist binds local, never 0.0.0.0", () => { it("SAFETY: network with EMPTY allowlist binds local, never 0.0.0.0", () => {
expect(resolveMcpRemoteRestore({ allowlist: [], port: 9868, hostMode: "network" })?.host).toBe("127.0.0.1"); expect(resolveRemoteDiagnosticsRestore({ allowlist: [], port: 9868, hostMode: "network" })?.host).toBe("127.0.0.1");
}); });
it("maps local to 127.0.0.1", () => { it("maps local to 127.0.0.1", () => {
expect(resolveMcpRemoteRestore({ allowlist: ["10.0.0.5"], port: 9868, hostMode: "local" })?.host).toBe("127.0.0.1"); expect(resolveRemoteDiagnosticsRestore({ allowlist: ["10.0.0.5"], port: 9868, hostMode: "local" })?.host).toBe("127.0.0.1");
}); });
it("rejects an out-of-range or non-integer port", () => { it("rejects an out-of-range or non-integer port", () => {
expect(resolveMcpRemoteRestore({ allowlist: ["10.0.0.5"], port: 80, hostMode: "network" })?.port).toBeUndefined(); expect(resolveRemoteDiagnosticsRestore({ allowlist: ["10.0.0.5"], port: 80, hostMode: "network" })?.port).toBeUndefined();
expect(resolveMcpRemoteRestore({ allowlist: ["10.0.0.5"], port: 70000, hostMode: "network" })?.port).toBeUndefined(); expect(resolveRemoteDiagnosticsRestore({ allowlist: ["10.0.0.5"], port: 70000, hostMode: "network" })?.port).toBeUndefined();
expect(resolveMcpRemoteRestore({ allowlist: ["10.0.0.5"], port: 9868.5, hostMode: "network" })?.port).toBeUndefined(); expect(resolveRemoteDiagnosticsRestore({ allowlist: ["10.0.0.5"], port: 9868.5, hostMode: "network" })?.port).toBeUndefined();
}); });
it("filters non-string and blank allowlist entries and trims", () => { it("filters non-string and blank allowlist entries and trims", () => {
const r = resolveMcpRemoteRestore({ allowlist: ["10.0.0.5", "", " ", 5, null, " 8.8.8.8 "], port: 9868, hostMode: "network" }); const r = resolveRemoteDiagnosticsRestore({ allowlist: ["10.0.0.5", "", " ", 5, null, " 8.8.8.8 "], port: 9868, hostMode: "network" });
expect(r?.allowlist).toEqual(["10.0.0.5", "8.8.8.8"]); expect(r?.allowlist).toEqual(["10.0.0.5", "8.8.8.8"]);
}); });
it("returns null for missing or empty/invalid sections", () => { it("returns null for missing or empty/invalid sections", () => {
expect(resolveMcpRemoteRestore(undefined)).toBeNull(); expect(resolveRemoteDiagnosticsRestore(undefined)).toBeNull();
expect(resolveMcpRemoteRestore(null)).toBeNull(); expect(resolveRemoteDiagnosticsRestore(null)).toBeNull();
expect(resolveMcpRemoteRestore("x")).toBeNull(); expect(resolveRemoteDiagnosticsRestore("x")).toBeNull();
expect(resolveMcpRemoteRestore({})).toBeNull(); expect(resolveRemoteDiagnosticsRestore({})).toBeNull();
}); });
}); });
describe("backup mcpRemote live restore round-trip", () => { describe("backup remoteDiagnostics live restore round-trip", () => {
it("export -> resolve -> apply is reflected in the running debug-server (proves restart fired)", async () => { it("export -> resolve -> apply is reflected in the running debug-server (proves restart fired)", async () => {
const baseDir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-bkmcp-")); const baseDir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-backup-remote-"));
tempDirs.push(baseDir); tempDirs.push(baseDir);
const startPort = await getFreePort(); const startPort = await getFreePort();
const restorePort = await getFreePort(); const restorePort = await getFreePort();
@ -157,11 +176,11 @@ describe("backup mcpRemote live restore round-trip", () => {
expect(getDebugAllowlist()).toEqual([]); expect(getDebugAllowlist()).toEqual([]);
const payload = buildBackupPayload(input( const payload = buildBackupPayload(input(
{ backupIncludeMcp: true }, { backupIncludeRemoteDiagnostics: true },
{ allowlist: ["203.0.113.4", "10.0.0.0/24"], port: restorePort, hostMode: "network" } { allowlist: ["203.0.113.4", "10.0.0.0/24"], port: restorePort, hostMode: "network" }
)); ));
const restore = resolveMcpRemoteRestore(payload.mcpRemote); const restore = resolveRemoteDiagnosticsRestore(payload.remoteDiagnostics);
expect(restore).not.toBeNull(); expect(restore).not.toBeNull();
writeDebugServerConfig({ host: restore!.host, port: restore!.port, allowlist: restore!.allowlist }); writeDebugServerConfig({ host: restore!.host, port: restore!.port, allowlist: restore!.allowlist });
const status = await restartDebugServer(); const status = await restartDebugServer();
@ -178,7 +197,7 @@ describe("backup mcpRemote live restore round-trip", () => {
}); });
it("full-backup path writes the debug_* files to disk without a restart (boot picks them up)", async () => { it("full-backup path writes the debug_* files to disk without a restart (boot picks them up)", async () => {
const baseDir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-bkmcp2-")); const baseDir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-backup-remote2-"));
tempDirs.push(baseDir); tempDirs.push(baseDir);
const startPort = await getFreePort(); const startPort = await getFreePort();
fs.writeFileSync(path.join(baseDir, "debug_token.txt"), "rt2", "utf8"); fs.writeFileSync(path.join(baseDir, "debug_token.txt"), "rt2", "utf8");
@ -188,7 +207,7 @@ describe("backup mcpRemote live restore round-trip", () => {
startDebugServer({} as unknown as DownloadManager, baseDir); startDebugServer({} as unknown as DownloadManager, baseDir);
await waitForReady(`http://127.0.0.1:${startPort}/health?token=rt2`); await waitForReady(`http://127.0.0.1:${startPort}/health?token=rt2`);
const restore = resolveMcpRemoteRestore({ allowlist: ["198.51.100.9"], port: 9100, hostMode: "network" }); const restore = resolveRemoteDiagnosticsRestore({ allowlist: ["198.51.100.9"], port: 9100, hostMode: "network" });
writeDebugServerConfig({ host: restore!.host, port: restore!.port, allowlist: restore!.allowlist }); writeDebugServerConfig({ host: restore!.host, port: restore!.port, allowlist: restore!.allowlist });
expect(fs.readFileSync(path.join(baseDir, "debug_host.txt"), "utf8").trim()).toBe("0.0.0.0"); expect(fs.readFileSync(path.join(baseDir, "debug_host.txt"), "utf8").trim()).toBe("0.0.0.0");

View File

@ -1,9 +1,21 @@
import { describe, it, expect } from "vitest"; import { describe, it, expect } from "vitest";
import { encodeConnectionCode } from "../src/main/connection-code"; import { encodeConnectionCode } from "../src/main/connection-code";
import { decodeConnectionCode } from "../tools/rd-diagnostics-mcp/src/code.mjs";
function decodeConnectionCode(code: string) {
const encoded = code.slice("rddiag:v1:".length).replace(/-/g, "+").replace(/_/g, "/");
const payload = JSON.parse(Buffer.from(encoded, "base64").toString("utf8")) as Record<string, unknown>;
return {
host: payload.h,
port: payload.p,
token: payload.t,
name: payload.n,
scheme: payload.s ?? "http",
fingerprint: payload.fp
};
}
describe("connection-code", () => { describe("connection-code", () => {
it("round-trips through the bridge decoder", () => { it("encodes a directly decodable payload", () => {
const code = encodeConnectionCode({ host: "203.0.113.5", port: 9868, token: "deadbeef", name: "server-1" }); const code = encodeConnectionCode({ host: "203.0.113.5", port: 9868, token: "deadbeef", name: "server-1" });
expect(code.startsWith("rddiag:v1:")).toBe(true); expect(code.startsWith("rddiag:v1:")).toBe(true);
const decoded = decodeConnectionCode(code); const decoded = decodeConnectionCode(code);

View File

@ -55,6 +55,21 @@ import type { DownloadManager } from "../src/main/download-manager";
import type { UiSnapshot } from "../src/shared/types"; import type { UiSnapshot } from "../src/shared/types";
const tempDirs: string[] = []; const tempDirs: string[] = [];
const legacyManifestFile = ["debug_", "a", "i", "_manifest.json"].join("");
const legacyManifestField = ["a", "i", "Manifest"].join("");
const forbiddenSupportMarkers = [
["A", "I"].join(""),
["assist", "ant"].join(""),
["assist", "ants"].join(""),
["ag", "ent"].join(""),
["ag", "ents"].join(""),
["Clau", "de"].join(""),
["Anthro", "pic"].join(""),
["Co", "dex"].join(""),
["Open", "AI"].join(""),
["M", "C", "P"].join(""),
["K", "I"].join("")
];
async function getFreePort(): Promise<number> { async function getFreePort(): Promise<number> {
const probe = http.createServer(); const probe = http.createServer();
@ -340,26 +355,30 @@ describe("debug-server", () => {
expect(payload.history?.total).toBe(1); expect(payload.history?.total).toBe(1);
}); });
it("writes a machine-readable AI support manifest into the runtime folder", async () => { it("writes a machine-readable support manifest into the runtime folder", async () => {
const fixture = await createFixture(); const fixture = await createFixture();
const manifestPath = path.join(fixture.baseDir, "debug_ai_manifest.json"); const manifestPath = path.join(fixture.baseDir, "debug_support_manifest.json");
expect(fs.existsSync(manifestPath)).toBe(true); expect(fs.existsSync(manifestPath)).toBe(true);
expect(fs.existsSync(path.join(fixture.baseDir, legacyManifestFile))).toBe(false);
const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8")) as Record<string, any>; const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8")) as Record<string, any>;
expect(manifest.appVersion).toBeTruthy(); expect(manifest.purpose).toBe("Machine-readable manifest for support tooling and remote troubleshooting.");
expect(JSON.stringify(manifest)).not.toMatch(new RegExp(`\\b(?:${forbiddenSupportMarkers.join("|")})\\b`, "i"));
expect(JSON.stringify(manifest)).not.toContain(fixture.token);
expect(manifest.debugServer?.port).toBeGreaterThan(0); expect(manifest.debugServer?.port).toBeGreaterThan(0);
expect(manifest.debugServer?.remoteBaseUrlTemplate).toContain("<SERVER_IP_OR_DNS>"); expect(manifest.debugServer?.remoteBaseUrlTemplate).toContain("<SERVER_IP_OR_DNS>");
expect(manifest.quickstart?.[1]).toContain("server IP"); expect(manifest.remoteAccessRequirements).toContain("A reachable server IP or DNS name.");
expect(manifest.setupCheckEndpoint).toBe("/debug/setup"); expect(manifest.setupCheckEndpoint).toBe("/debug/setup");
expect(manifest.selfCheckEndpoint).toBe("/self-check"); expect(manifest.selfCheckEndpoint).toBe("/self-check");
expect(manifest.runtimeFiles?.tokenFile).toContain("debug_token.txt"); expect(manifest.runtimeFiles?.tokenFile).toContain("debug_token.txt");
expect(manifest.endpoints?.some((entry: Record<string, any>) => entry.path === "/diagnostics")).toBe(true); expect(manifest.endpoints?.some((entry: Record<string, any>) => entry.path === "/diagnostics")).toBe(true);
expect(JSON.stringify(manifest)).not.toContain(fixture.token); expect(manifest.endpoints?.some((entry: Record<string, any>) => entry.path === "/logs/main")).toBe(true);
const metaResponse = await fetch(`${fixture.baseUrl}/meta?token=${fixture.token}`); const metaResponse = await fetch(`${fixture.baseUrl}/meta?token=${fixture.token}`);
expect(metaResponse.ok).toBe(true); expect(metaResponse.ok).toBe(true);
const metaPayload = await metaResponse.json() as Record<string, any>; const metaPayload = await metaResponse.json() as Record<string, any>;
expect(metaPayload.supportFiles?.aiManifest).toBe(manifestPath); expect(metaPayload.supportFiles?.supportManifest).toBe(manifestPath);
expect(metaPayload.supportFiles?.[legacyManifestField]).toBeUndefined();
expect(metaPayload.supportFiles?.traceConfig).toBe(getTraceConfigPath()); expect(metaPayload.supportFiles?.traceConfig).toBe(getTraceConfigPath());
expect(metaPayload.supportFiles?.traceLog).toBe(getTraceLogPath()); expect(metaPayload.supportFiles?.traceLog).toBe(getTraceLogPath());
expect(metaPayload.logPaths?.rename).toBe(getRenameLogPath()); expect(metaPayload.logPaths?.rename).toBe(getRenameLogPath());
@ -379,7 +398,9 @@ describe("debug-server", () => {
expect(payload.host).toBe("0.0.0.0"); expect(payload.host).toBe("0.0.0.0");
expect(payload.localOnly).toBe(false); expect(payload.localOnly).toBe(false);
expect(payload.tokenConfigured).toBe(true); expect(payload.tokenConfigured).toBe(true);
expect(payload.aiManifestPresent).toBe(true); expect(payload.supportManifestPresent).toBe(true);
expect(payload[`${legacyManifestField}Present`]).toBeUndefined();
expect(payload.supportManifestPath).toBe(path.join(fixture.baseDir, "debug_support_manifest.json"));
expect(payload.traceEnabled).toBe(true); expect(payload.traceEnabled).toBe(true);
expect(payload.traceAutoDisableAt).toBeTruthy(); expect(payload.traceAutoDisableAt).toBeTruthy();
expect(payload.diskSpace?.runtime?.freeBytes).toBeGreaterThan(0); expect(payload.diskSpace?.runtime?.freeBytes).toBeGreaterThan(0);
@ -511,6 +532,7 @@ describe("debug-server", () => {
it("downloads a support bundle zip", async () => { it("downloads a support bundle zip", async () => {
const fixture = await createFixture(); const fixture = await createFixture();
fs.writeFileSync(path.join(fixture.baseDir, legacyManifestFile), JSON.stringify({ purpose: "legacy" }), "utf8");
const response = await fetch(`${fixture.baseUrl}/support/bundle?token=${fixture.token}`); const response = await fetch(`${fixture.baseUrl}/support/bundle?token=${fixture.token}`);
expect(response.ok).toBe(true); expect(response.ok).toBe(true);
expect(response.headers.get("content-type")).toContain("application/zip"); expect(response.headers.get("content-type")).toContain("application/zip");
@ -526,7 +548,10 @@ describe("debug-server", () => {
expect(entries).toContain("logs/audit.log"); expect(entries).toContain("logs/audit.log");
expect(entries).toContain("logs/rename.log"); expect(entries).toContain("logs/rename.log");
expect(entries).toContain("logs/trace.log"); expect(entries).toContain("logs/trace.log");
expect(entries).toContain("runtime/debug_ai_manifest.json"); expect(entries).toContain("runtime/debug_support_manifest.json");
expect(entries).toContain("overview/support-manifest.json");
expect(entries).not.toContain(`runtime/${legacyManifestFile}`);
expect(entries).not.toContain(["overview/", "a", "i-manifest.json"].join(""));
expect(entries).not.toContain("runtime/debug_token.txt"); expect(entries).not.toContain("runtime/debug_token.txt");
}); });

View File

@ -166,6 +166,25 @@ afterEach(async () => {
}); });
describe("download manager", () => { describe("download manager", () => {
it("clears the BestDebrid circuit breaker when its token changes", () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-best-token-"));
tempDirs.push(root);
const stateDir = path.join(root, "state");
fs.mkdirSync(stateDir, { recursive: true });
const settings = { ...defaultSettings(), bestToken: "old-token" };
const manager = new DownloadManager(settings, emptySession(), createStoragePaths(stateDir));
const failures = (manager as any).providerFailures as Map<string, unknown>;
failures.set("bestdebrid", { count: 3, lastFailAt: 1, cooldownUntil: Date.now() + 60_000 });
failures.set("bestdebrid:rapidgator.net", { count: 3, lastFailAt: 1, cooldownUntil: Date.now() + 60_000 });
failures.set("realdebrid", { count: 3, lastFailAt: 1, cooldownUntil: Date.now() + 60_000 });
manager.setSettings({ ...settings, bestToken: "new-token" });
expect(failures.has("bestdebrid")).toBe(false);
expect(failures.has("bestdebrid:rapidgator.net")).toBe(false);
expect(failures.has("realdebrid")).toBe(true);
});
it("records history duration from the first actual package start", () => { it("records history duration from the first actual package start", () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-history-")); const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-history-"));
tempDirs.push(root); tempDirs.push(root);
@ -4859,6 +4878,76 @@ describe("download manager", () => {
expect(fs.existsSync(targetPath)).toBe(false); expect(fs.existsSync(targetPath)).toBe(false);
}); });
it("requeues near-total non-binary pre-allocated leftovers during post-processing", async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-"));
tempDirs.push(root);
const session = emptySession();
const packageId = "postproc-near-total-prealloc-pkg";
const itemId = "postproc-near-total-prealloc-item";
const createdAt = Date.now() - 20_000;
const outputDir = path.join(root, "downloads", "postproc-near-total-prealloc");
const targetPath = path.join(outputDir, "postproc-near-total-prealloc.data");
const totalBytes = 2 * 1024 * 1024;
fs.mkdirSync(outputDir, { recursive: true });
fs.writeFileSync(targetPath, Buffer.alloc(totalBytes - 1, 0));
session.packageOrder = [packageId];
session.packages[packageId] = {
id: packageId,
name: "postproc-near-total-prealloc",
outputDir,
extractDir: path.join(root, "extract", "postproc-near-total-prealloc"),
status: "queued",
itemIds: [itemId],
cancelled: false,
enabled: true,
createdAt,
updatedAt: createdAt
};
session.items[itemId] = {
id: itemId,
packageId,
url: "https://dummy/postproc-near-total-prealloc",
provider: "realdebrid",
status: "queued",
retries: 0,
speedBps: 0,
downloadedBytes: 0,
totalBytes,
progressPercent: 0,
fileName: "postproc-near-total-prealloc.data",
targetPath,
resumable: true,
attempts: 1,
lastError: "",
fullStatus: "Wartet",
createdAt,
updatedAt: createdAt
};
const manager = new DownloadManager(
{
...defaultSettings(),
token: "rd-token",
outputDir: path.join(root, "downloads"),
extractDir: path.join(root, "extract"),
autoExtract: false
},
session,
createStoragePaths(path.join(root, "state"))
);
await (manager as any).handlePackagePostProcessing(packageId);
const snapshot = manager.getSnapshot();
const item = snapshot.session.items[itemId];
expect(item?.status).toBe("queued");
expect(item?.fullStatus).toContain("pre-alloc");
expect(item?.downloadedBytes).toBe(0);
expect(item?.progressPercent).toBe(0);
expect(fs.existsSync(targetPath)).toBe(false);
});
it("requeues completed archive parts after auto-recovery extraction failures", () => { it("requeues completed archive parts after auto-recovery extraction failures", () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-")); const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-"));
tempDirs.push(root); tempDirs.push(root);

View File

@ -12,6 +12,8 @@ import {
archiveFilenamePasswords, archiveFilenamePasswords,
detectArchiveSignature, detectArchiveSignature,
classifyExtractionError, classifyExtractionError,
ExtractionError,
selectZipFallbackError,
shouldSerialRetryParallelFailures, shouldSerialRetryParallelFailures,
findArchiveCandidates, findArchiveCandidates,
orderExtractorCandidatesForArchive, orderExtractorCandidatesForArchive,
@ -23,7 +25,6 @@ import {
const tempDirs: string[] = []; const tempDirs: string[] = [];
const originalExtractBackend = process.env.RD_EXTRACT_BACKEND; const originalExtractBackend = process.env.RD_EXTRACT_BACKEND;
const originalStatfs = fs.promises.statfs; const originalStatfs = fs.promises.statfs;
const originalZipEntryMemoryLimit = process.env.RD_ZIP_ENTRY_MEMORY_LIMIT_MB;
beforeEach(() => { beforeEach(() => {
process.env.RD_EXTRACT_BACKEND = "legacy"; process.env.RD_EXTRACT_BACKEND = "legacy";
@ -39,11 +40,6 @@ afterEach(() => {
process.env.RD_EXTRACT_BACKEND = originalExtractBackend; process.env.RD_EXTRACT_BACKEND = originalExtractBackend;
} }
(fs.promises as any).statfs = originalStatfs; (fs.promises as any).statfs = originalStatfs;
if (originalZipEntryMemoryLimit === undefined) {
delete process.env.RD_ZIP_ENTRY_MEMORY_LIMIT_MB;
} else {
process.env.RD_ZIP_ENTRY_MEMORY_LIMIT_MB = originalZipEntryMemoryLimit;
}
}); });
describe("extractor", () => { describe("extractor", () => {
@ -593,31 +589,25 @@ describe("extractor", () => {
expect(targets.has(r02)).toBe(true); expect(targets.has(r02)).toBe(true);
}); });
it("keeps original ZIP size guard error when external fallback is unavailable", async () => { it("preserves the original ZIP size guard error when no external extractor is available", () => {
process.env.RD_ZIP_ENTRY_MEMORY_LIMIT_MB = "8"; const internalError = new Error("ZIP-Eintrag zu groß für sichere Speicher-Extraktion");
const externalError = new ExtractionError("Kein nativer Entpacker gefunden", "no_extractor");
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-extract-")); expect(selectZipFallbackError(internalError, externalError)).toBe(internalError);
tempDirs.push(root);
const packageDir = path.join(root, "pkg");
const targetDir = path.join(root, "out");
fs.mkdirSync(packageDir, { recursive: true });
const zipPath = path.join(packageDir, "too-large.zip");
const zip = new AdmZip();
zip.addFile("large.bin", Buffer.alloc(9 * 1024 * 1024, 7));
zip.writeZip(zipPath);
const result = await extractPackageArchives({
packageDir,
targetDir,
cleanupMode: "none",
conflictMode: "overwrite",
removeLinks: false,
removeSamples: false
}); });
expect(result.extracted).toBe(0);
expect(result.failed).toBe(1); it("preserves the original ZIP size guard error for an unsupported external archive format", () => {
expect(String(result.lastError)).toMatch(/ZIP-Eintrag.*groß/i); const internalError = new Error("ZIP-Eintrag zu groß für sichere Speicher-Extraktion");
const externalError = new ExtractionError("Is not archive", "unsupported_format");
expect(selectZipFallbackError(internalError, externalError)).toBe(internalError);
});
it("returns other external ZIP fallback errors unchanged", () => {
const internalError = new Error("ZIP-Eintrag zu groß für sichere Speicher-Extraktion");
const externalError = new ExtractionError("CRC failed", "crc_error");
expect(selectZipFallbackError(internalError, externalError)).toBe(externalError);
}); });
it.skipIf(process.platform !== "win32")("matches resume-state archive names case-insensitively on Windows", async () => { it.skipIf(process.platform !== "win32")("matches resume-state archive names case-insensitively on Windows", async () => {
@ -1047,6 +1037,12 @@ describe("extractor", () => {
expect(classifyExtractionError("UNSUPPORTEDMETHOD")).toBe("unsupported_format"); expect(classifyExtractionError("UNSUPPORTEDMETHOD")).toBe("unsupported_format");
}); });
it("classifies native 7-Zip Cannot open the file as archive errors as unsupported format", () => {
expect(classifyExtractionError(
new Error("Open ERROR: Cannot open the file as [zip] archive ERRORS: Is not archive")
)).toBe("unsupported_format");
});
it("classifies disk full", () => { it("classifies disk full", () => {
expect(classifyExtractionError("Nicht genug Speicherplatz")).toBe("disk_full"); expect(classifyExtractionError("Nicht genug Speicherplatz")).toBe("disk_full");
expect(classifyExtractionError("No space left on device")).toBe("disk_full"); expect(classifyExtractionError("No space left on device")).toBe("disk_full");

View File

@ -0,0 +1,391 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import crypto from "node:crypto";
import { spawnSync } from "node:child_process";
import { afterEach, describe, expect, it } from "vitest";
type ReleaseVerification = {
publish: {
provider: string;
owner: string;
repo: string;
};
latestArtifact: string;
missingArtifacts: string[];
};
type CommandResult = {
status: number | null;
stdout?: string;
stderr?: string;
error?: Error;
};
type ArchiveVerification = {
verifiedArchives: string[];
};
const verifierPath = path.resolve("scripts", "verify_public_release.mjs");
const verifierUrl = "../scripts/verify_public_release.mjs";
const { verifyPublicRelease, verifyReleaseArchives } = await import(verifierUrl) as {
verifyPublicRelease: (rootDir: string) => ReleaseVerification;
verifyReleaseArchives: (
rootDir: string,
options: {
sevenZipPath: string;
runCommand: (command: string, args: string[]) => CommandResult;
}
) => ArchiveVerification;
};
const fixtureRoots: string[] = [];
const redistributionFiles = [
"LICENSE",
"resources/extractor-jvm/licenses/LGPL-2.1.txt",
"resources/extractor-jvm/licenses/7-Zip-license.txt",
"resources/extractor-jvm/licenses/Apache-2.0.txt",
"resources/extractor-jvm/THIRD_PARTY_NOTICES.txt"
] as const;
function writeFile(rootDir: string, relativePath: string, content: string | Buffer): void {
const filePath = path.join(rootDir, ...relativePath.split("/"));
fs.mkdirSync(path.dirname(filePath), { recursive: true });
fs.writeFileSync(filePath, content);
}
function writeRedistributionFiles(rootDir: string, packaged = false): void {
for (const relativePath of redistributionFiles) {
const content = fs.readFileSync(path.resolve(...relativePath.split("/")));
let targetPath: string = relativePath;
if (packaged && relativePath === "LICENSE") {
targetPath = "win-unpacked/resources/LICENSE";
} else if (packaged) {
targetPath = `win-unpacked/resources/app.asar.unpacked/${relativePath}`;
}
writeFile(rootDir, targetPath, content);
}
}
function writeArchivePayload(outputDir: string, omittedName = ""): void {
for (const relativePath of redistributionFiles) {
if (path.basename(relativePath) === omittedName) {
continue;
}
const content = fs.readFileSync(path.resolve(...relativePath.split("/")));
const targetPath = relativePath === "LICENSE"
? "resources/LICENSE"
: `resources/app.asar.unpacked/${relativePath}`;
writeFile(outputDir, targetPath, content);
}
}
function createArchiveCommandRunner(omittedName = "") {
return (command: string, args: string[]): CommandResult => {
const archivePath = args[1] || "";
const outputArg = args.find((arg) => arg.startsWith("-o"));
if (!outputArg) {
return { status: 2, stderr: "missing output directory" };
}
const outputDir = outputArg.slice(2);
if (archivePath.toLowerCase().endsWith(".exe")) {
writeFile(outputDir, "payload/app-64.7z", "nested archive");
} else if (archivePath.toLowerCase().endsWith(".7z")) {
writeArchivePayload(outputDir, omittedName);
}
return { status: command ? 0 : 2, stdout: "ok", stderr: "" };
};
}
function createReleaseFixture(): string {
const rootDir = fs.mkdtempSync(path.join(os.tmpdir(), "public-release-metadata-"));
fixtureRoots.push(rootDir);
const setupPayload = Buffer.from("setup");
const setupSha512 = crypto.createHash("sha512").update(setupPayload).digest("base64");
writeFile(rootDir, "package.json", `${JSON.stringify({
name: "real-debrid-downloader",
version: "1.7.233",
build: {
productName: "Real-Debrid-Downloader",
publish: {
provider: "github",
owner: "Sucukdeluxe",
repo: "multi-debrid-downloader"
},
files: [
"build/main/**/*",
"build/renderer/**/*",
"resources/extractor-jvm/**/*",
"LICENSE",
"package.json"
],
extraResources: [
{
from: "LICENSE",
to: "LICENSE"
}
],
nsis: {
artifactName: "${productName}-Setup-${version}.${ext}",
oneClick: false,
perMachine: false,
allowToChangeInstallationDirectory: true,
createDesktopShortcut: true
},
portable: {
artifactName: "${productName}-${version}-portable.${ext}"
}
}
}, null, 2)}\n`);
writeFile(
rootDir,
"latest.yml",
`version: 1.7.233\nfiles:\n - url: Real-Debrid-Downloader-Setup-1.7.233.exe\n sha512: ${setupSha512}\n size: ${setupPayload.length}\npath: Real-Debrid-Downloader-Setup-1.7.233.exe\nsha512: ${setupSha512}\n`
);
writeFile(
rootDir,
"win-unpacked/resources/app-update.yml",
"provider: github\nowner: Sucukdeluxe\nrepo: multi-debrid-downloader\n"
);
writeFile(rootDir, "Real-Debrid-Downloader-Setup-1.7.233.exe", setupPayload);
writeFile(rootDir, "Real-Debrid-Downloader-Setup-1.7.233.exe.blockmap", "blockmap");
writeFile(rootDir, "Real-Debrid-Downloader-1.7.233-portable.exe", "portable");
writeRedistributionFiles(rootDir);
writeRedistributionFiles(rootDir, true);
return rootDir;
}
afterEach(() => {
for (const rootDir of fixtureRoots.splice(0)) {
fs.rmSync(rootDir, { recursive: true, force: true });
}
});
describe("public release metadata", () => {
it("accepts the canonical GitHub release metadata and artifacts", () => {
const rootDir = createReleaseFixture();
const result = verifyPublicRelease(rootDir);
expect(result.publish).toEqual({
provider: "github",
owner: "Sucukdeluxe",
repo: "multi-debrid-downloader"
});
expect(result.latestArtifact).toBe("Real-Debrid-Downloader-Setup-1.7.233.exe");
expect(result.missingArtifacts).toEqual([]);
});
it("rejects a package configured for a different GitHub owner", () => {
const rootDir = createReleaseFixture();
const packagePath = path.join(rootDir, "package.json");
const packageJson = JSON.parse(fs.readFileSync(packagePath, "utf8"));
packageJson.build.publish.owner = "DifferentOwner";
fs.writeFileSync(packagePath, `${JSON.stringify(packageJson, null, 2)}\n`);
expect(() => verifyPublicRelease(rootDir)).toThrow(/owner/i);
});
it("rejects a latest.yml path whose artifact does not exist", () => {
const rootDir = createReleaseFixture();
fs.rmSync(path.join(rootDir, "Real-Debrid-Downloader-Setup-1.7.233.exe"));
expect(() => verifyPublicRelease(rootDir)).toThrow(/Real-Debrid-Downloader-Setup-1\.7\.233\.exe/);
});
it("rejects syntactically invalid latest.yml", () => {
const rootDir = createReleaseFixture();
fs.writeFileSync(
path.join(rootDir, "latest.yml"),
"version: 1.7.233\nfiles: [\npath: Real-Debrid-Downloader-Setup-1.7.233.exe\n"
);
expect(() => verifyPublicRelease(rootDir)).toThrow(/latest\.yml|yaml/i);
});
it("rejects a noncanonical files entry in latest.yml", () => {
const rootDir = createReleaseFixture();
const latestPath = path.join(rootDir, "latest.yml");
const latest = fs.readFileSync(latestPath, "utf8").replace(
"url: Real-Debrid-Downloader-Setup-1.7.233.exe",
"url: Different-Setup-1.7.233.exe"
);
fs.writeFileSync(latestPath, latest);
expect(() => verifyPublicRelease(rootDir)).toThrow(/files|url|canonical/i);
});
it("rejects a latest.yml SHA512 digest that does not match the installer", () => {
const rootDir = createReleaseFixture();
const latestPath = path.join(rootDir, "latest.yml");
const latest = fs.readFileSync(latestPath, "utf8");
const wrongDigest = Buffer.alloc(64, 0x23).toString("base64");
fs.writeFileSync(latestPath, latest.replace(/sha512: [^\n]+/g, `sha512: ${wrongDigest}`));
expect(() => verifyPublicRelease(rootDir)).toThrow(/sha512|digest|integrity/i);
});
it("rejects a directory in place of an artifact file", () => {
const rootDir = createReleaseFixture();
const setupPath = path.join(rootDir, "Real-Debrid-Downloader-Setup-1.7.233.exe");
fs.rmSync(setupPath);
fs.mkdirSync(setupPath);
expect(() => verifyPublicRelease(rootDir)).toThrow(/artifact|file/i);
});
it("rejects an empty artifact file", () => {
const rootDir = createReleaseFixture();
fs.writeFileSync(path.join(rootDir, "Real-Debrid-Downloader-1.7.233-portable.exe"), "");
expect(() => verifyPublicRelease(rootDir)).toThrow(/artifact|empty|file/i);
});
it("rejects a release missing a declared redistribution license", () => {
const rootDir = createReleaseFixture();
fs.rmSync(path.join(rootDir, "resources", "extractor-jvm", "licenses", "Apache-2.0.txt"));
expect(() => verifyPublicRelease(rootDir)).toThrow(/Apache-2\.0\.txt/);
});
it("rejects a modified official license text", () => {
const rootDir = createReleaseFixture();
fs.appendFileSync(
path.join(rootDir, "resources", "extractor-jvm", "licenses", "LGPL-2.1.txt"),
"modified"
);
expect(() => verifyPublicRelease(rootDir)).toThrow(/LGPL-2\.1\.txt|digest|content/i);
});
it("rejects swapped third-party license assignments", () => {
const rootDir = createReleaseFixture();
const noticePath = path.join(rootDir, "resources", "extractor-jvm", "THIRD_PARTY_NOTICES.txt");
const notice = fs.readFileSync(noticePath, "utf8")
.replace("GNU Lesser General Public License 2.1 or later", "Apache License 2.0")
.replace("licenses/LGPL-2.1.txt; licenses/7-Zip-license.txt", "licenses/Apache-2.0.txt");
fs.writeFileSync(noticePath, notice);
expect(() => verifyPublicRelease(rootDir)).toThrow(/THIRD_PARTY_NOTICES|notice|digest|mapping/i);
});
it("rejects a release whose unpacked application omits a license", () => {
const rootDir = createReleaseFixture();
fs.rmSync(path.join(
rootDir,
"win-unpacked",
"resources",
"app.asar.unpacked",
"resources",
"extractor-jvm",
"licenses",
"Apache-2.0.txt"
));
expect(() => verifyPublicRelease(rootDir)).toThrow(/win-unpacked|Apache-2\.0\.txt|packaged/i);
});
it("rejects a symlink in place of a release artifact", () => {
const rootDir = createReleaseFixture();
const setupPath = path.join(rootDir, "Real-Debrid-Downloader-Setup-1.7.233.exe");
const targetPath = path.join(rootDir, "setup-target.exe");
fs.renameSync(setupPath, targetPath);
fs.symlinkSync(targetPath, setupPath, "file");
expect(() => verifyPublicRelease(rootDir)).toThrow(/symbolic|symlink|regular file/i);
});
it("rejects a symlink in place of an official license", () => {
const rootDir = createReleaseFixture();
const licensePath = path.join(rootDir, "resources", "extractor-jvm", "licenses", "Apache-2.0.txt");
const targetPath = path.join(rootDir, "Apache-target.txt");
fs.renameSync(licensePath, targetPath);
fs.symlinkSync(targetPath, licensePath, "file");
expect(() => verifyPublicRelease(rootDir)).toThrow(/symbolic|symlink|regular file/i);
});
it("rejects build metadata that omits the project license", () => {
const rootDir = createReleaseFixture();
const packagePath = path.join(rootDir, "package.json");
const packageJson = JSON.parse(fs.readFileSync(packagePath, "utf8"));
packageJson.build.files = packageJson.build.files.filter((entry: string) => entry !== "LICENSE");
fs.writeFileSync(packagePath, `${JSON.stringify(packageJson, null, 2)}\n`);
expect(() => verifyPublicRelease(rootDir)).toThrow(/LICENSE/);
});
it("rejects build metadata that does not copy the project license into resources", () => {
const rootDir = createReleaseFixture();
const packagePath = path.join(rootDir, "package.json");
const packageJson = JSON.parse(fs.readFileSync(packagePath, "utf8"));
delete packageJson.build.extraResources;
fs.writeFileSync(packagePath, `${JSON.stringify(packageJson, null, 2)}\n`);
expect(() => verifyPublicRelease(rootDir)).toThrow(/extraResources|LICENSE/);
});
it("rejects incomplete third-party redistribution notices", () => {
const rootDir = createReleaseFixture();
fs.writeFileSync(
path.join(rootDir, "resources", "extractor-jvm", "THIRD_PARTY_NOTICES.txt"),
"net.sf.sevenzipjbinding:sevenzipjbinding:16.02-2.01 LGPL-2.1.txt\n"
);
expect(() => verifyPublicRelease(rootDir)).toThrow(/THIRD_PARTY_NOTICES|notice|content/i);
});
it("returns a nonzero CLI status for invalid release metadata", () => {
const rootDir = createReleaseFixture();
const packagePath = path.join(rootDir, "package.json");
const packageJson = JSON.parse(fs.readFileSync(packagePath, "utf8"));
packageJson.build.publish.owner = "DifferentOwner";
fs.writeFileSync(packagePath, `${JSON.stringify(packageJson, null, 2)}\n`);
const result = spawnSync(process.execPath, [verifierPath, rootDir], {
encoding: "utf8"
});
expect(result.status).not.toBe(0);
expect(result.stderr).toMatch(/owner/i);
});
it("recursively verifies redistribution files inside setup and portable archives", () => {
const rootDir = createReleaseFixture();
const result = verifyReleaseArchives(rootDir, {
sevenZipPath: "C:\\Tools\\7-Zip\\7z.exe",
runCommand: createArchiveCommandRunner()
});
expect(result.verifiedArchives).toEqual([
"Real-Debrid-Downloader-Setup-1.7.233.exe",
"Real-Debrid-Downloader-1.7.233-portable.exe"
]);
});
it("rejects an archive whose nested application payload omits a license", () => {
const rootDir = createReleaseFixture();
expect(() => verifyReleaseArchives(rootDir, {
sevenZipPath: "C:\\Tools\\7-Zip\\7z.exe",
runCommand: createArchiveCommandRunner("Apache-2.0.txt")
})).toThrow(/Apache-2\.0\.txt|missing redistribution file/i);
});
it("exposes archive verification as a nonzero CLI gate", () => {
const rootDir = createReleaseFixture();
const result = spawnSync(process.execPath, [
verifierPath,
rootDir,
"--verify-archives",
"--seven-zip",
path.join(rootDir, "missing-7z.exe")
], {
encoding: "utf8"
});
expect(result.status).not.toBe(0);
expect(result.stderr).toMatch(/7-Zip|command|spawn/i);
});
});

View File

@ -7,6 +7,7 @@ import { buildSupportBundle } from "../src/main/support-bundle";
import type { DownloadManager } from "../src/main/download-manager"; import type { DownloadManager } from "../src/main/download-manager";
const tempDirs: string[] = []; const tempDirs: string[] = [];
const legacyManifestFile = ["debug_", "a", "i", "_manifest.json"].join("");
afterEach(() => { afterEach(() => {
for (const dir of tempDirs.splice(0)) { for (const dir of tempDirs.splice(0)) {
@ -36,6 +37,8 @@ describe("buildSupportBundle (async, non-blocking)", () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-bundle-")); const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-bundle-"));
tempDirs.push(root); tempDirs.push(root);
fs.writeFileSync(path.join(root, "debug_host.txt"), "host-info-test", "utf8"); fs.writeFileSync(path.join(root, "debug_host.txt"), "host-info-test", "utf8");
fs.writeFileSync(path.join(root, "debug_support_manifest.json"), JSON.stringify({ purpose: "support" }), "utf8");
fs.writeFileSync(path.join(root, legacyManifestFile), JSON.stringify({ purpose: "legacy" }), "utf8");
const promise = buildSupportBundle(fakeManager(), root, { hostDiagnosticsMode: "none" }); const promise = buildSupportBundle(fakeManager(), root, { hostDiagnosticsMode: "none" });
expect(promise).toBeInstanceOf(Promise); expect(promise).toBeInstanceOf(Promise);
@ -48,6 +51,10 @@ describe("buildSupportBundle (async, non-blocking)", () => {
expect(entries).toContain("overview/meta.json"); expect(entries).toContain("overview/meta.json");
expect(entries).toContain("overview/settings.json"); expect(entries).toContain("overview/settings.json");
expect(entries).toContain("runtime/debug_host.txt"); expect(entries).toContain("runtime/debug_host.txt");
expect(entries).toContain("runtime/debug_support_manifest.json");
expect(entries).toContain("overview/support-manifest.json");
expect(entries).not.toContain(`runtime/${legacyManifestFile}`);
expect(entries).not.toContain(["overview/", "a", "i-manifest.json"].join(""));
const hostEntry = new AdmZip(buffer).getEntry("runtime/debug_host.txt"); const hostEntry = new AdmZip(buffer).getEntry("runtime/debug_host.txt");
expect(hostEntry?.getData().toString("utf8")).toBe("host-info-test"); expect(hostEntry?.getData().toString("utf8")).toBe("host-info-test");

View File

@ -46,13 +46,13 @@ afterEach(() => {
describe("update", () => { describe("update", () => {
it("normalizes update repo input", () => { it("normalizes update repo input", () => {
expect(normalizeUpdateRepo("")).toBe("Administrator/real-debrid-downloader"); expect(normalizeUpdateRepo("")).toBe("Sucukdeluxe/multi-debrid-downloader");
expect(normalizeUpdateRepo("owner/repo")).toBe("owner/repo"); expect(normalizeUpdateRepo("owner/repo")).toBe("owner/repo");
expect(normalizeUpdateRepo("https://codeberg.org/owner/repo")).toBe("owner/repo"); expect(normalizeUpdateRepo("https://github.com/owner/repo")).toBe("owner/repo");
expect(normalizeUpdateRepo("https://www.codeberg.org/owner/repo")).toBe("owner/repo"); expect(normalizeUpdateRepo("https://www.github.com/owner/repo")).toBe("owner/repo");
expect(normalizeUpdateRepo("https://codeberg.org/owner/repo/releases/tag/v1.2.3")).toBe("owner/repo"); expect(normalizeUpdateRepo("https://github.com/owner/repo/releases/tag/v1.2.3")).toBe("owner/repo");
expect(normalizeUpdateRepo("codeberg.org/owner/repo.git")).toBe("owner/repo"); expect(normalizeUpdateRepo("github.com/owner/repo.git")).toBe("owner/repo");
expect(normalizeUpdateRepo("git@codeberg.org:owner/repo.git")).toBe("owner/repo"); expect(normalizeUpdateRepo("git@github.com:owner/repo.git")).toBe("owner/repo");
}); });
it("uses normalized repo slug for API requests", async () => { it("uses normalized repo slug for API requests", async () => {
@ -62,7 +62,7 @@ describe("update", () => {
return new Response( return new Response(
JSON.stringify({ JSON.stringify({
tag_name: `v${APP_VERSION}`, tag_name: `v${APP_VERSION}`,
html_url: "https://git.24-music.de/owner/repo/releases/tag/v1.0.0", html_url: "https://github.com/owner/repo/releases/tag/v1.0.0",
assets: [] assets: []
}), }),
{ {
@ -72,8 +72,8 @@ describe("update", () => {
); );
}) as typeof fetch; }) as typeof fetch;
const result = await checkGitHubUpdate("https://git.24-music.de/owner/repo/releases"); const result = await checkGitHubUpdate("https://github.com/owner/repo/releases");
expect(requestedUrl).toBe("https://git.24-music.de/api/v1/repos/owner/repo/releases/latest"); expect(requestedUrl).toBe("https://api.github.com/repos/owner/repo/releases/latest");
expect(result.currentVersion).toBe(APP_VERSION); expect(result.currentVersion).toBe(APP_VERSION);
expect(result.latestVersion).toBe(APP_VERSION); expect(result.latestVersion).toBe(APP_VERSION);
expect(result.updateAvailable).toBe(false); expect(result.updateAvailable).toBe(false);
@ -83,14 +83,14 @@ describe("update", () => {
globalThis.fetch = (async (): Promise<Response> => new Response( globalThis.fetch = (async (): Promise<Response> => new Response(
JSON.stringify({ JSON.stringify({
tag_name: "v9.9.9", tag_name: "v9.9.9",
html_url: "https://codeberg.org/owner/repo/releases/tag/v9.9.9", html_url: "https://github.com/owner/repo/releases/tag/v9.9.9",
assets: [ assets: [
{ {
name: "Real-Debrid-Downloader 9.9.9.exe", name: "Real-Debrid-Downloader-9.9.9-portable.exe",
browser_download_url: "https://example.invalid/portable.exe" browser_download_url: "https://example.invalid/portable.exe"
}, },
{ {
name: "Real-Debrid-Downloader Setup 9.9.9.exe", name: "Real-Debrid-Downloader-Setup-9.9.9.exe",
browser_download_url: "https://example.invalid/setup.exe", browser_download_url: "https://example.invalid/setup.exe",
digest: "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" digest: "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
} }
@ -105,7 +105,7 @@ describe("update", () => {
const result = await checkGitHubUpdate("owner/repo"); const result = await checkGitHubUpdate("owner/repo");
expect(result.updateAvailable).toBe(true); expect(result.updateAvailable).toBe(true);
expect(result.setupAssetUrl).toBe("https://example.invalid/setup.exe"); expect(result.setupAssetUrl).toBe("https://example.invalid/setup.exe");
expect(result.setupAssetName).toBe("Real-Debrid-Downloader Setup 9.9.9.exe"); expect(result.setupAssetName).toBe("Real-Debrid-Downloader-Setup-9.9.9.exe");
}); });
it("uses silent NSIS install flags with auto-run after update", () => { it("uses silent NSIS install flags with auto-run after update", () => {
@ -137,9 +137,9 @@ describe("update", () => {
currentVersion: APP_VERSION, currentVersion: APP_VERSION,
latestVersion: "9.9.9", latestVersion: "9.9.9",
latestTag: "v9.9.9", latestTag: "v9.9.9",
releaseUrl: "https://codeberg.org/owner/repo/releases/tag/v9.9.9", releaseUrl: "https://github.com/owner/repo/releases/tag/v9.9.9",
setupAssetUrl: "https://example.invalid/stale-setup.exe", setupAssetUrl: "https://example.invalid/stale-setup.exe",
setupAssetName: "Real-Debrid-Downloader Setup 9.9.9.exe", setupAssetName: "Real-Debrid-Downloader-Setup-9.9.9.exe",
setupAssetDigest: `sha256:${executableDigest}` setupAssetDigest: `sha256:${executableDigest}`
}; };
@ -208,7 +208,7 @@ describe("update", () => {
currentVersion: APP_VERSION, currentVersion: APP_VERSION,
latestVersion: "9.9.9", latestVersion: "9.9.9",
latestTag: "v9.9.9", latestTag: "v9.9.9",
releaseUrl: "https://codeberg.org/owner/repo/releases/tag/v9.9.9", releaseUrl: "https://github.com/owner/repo/releases/tag/v9.9.9",
setupAssetUrl: "", setupAssetUrl: "",
setupAssetName: "" setupAssetName: ""
}; };
@ -272,7 +272,7 @@ describe("update", () => {
currentVersion: APP_VERSION, currentVersion: APP_VERSION,
latestVersion: "9.9.9", latestVersion: "9.9.9",
latestTag: "v9.9.9", latestTag: "v9.9.9",
releaseUrl: "https://codeberg.org/owner/repo/releases/tag/v9.9.9", releaseUrl: "https://github.com/owner/repo/releases/tag/v9.9.9",
setupAssetUrl: "https://example.invalid/hang-setup.exe", setupAssetUrl: "https://example.invalid/hang-setup.exe",
setupAssetName: "", setupAssetName: "",
setupAssetDigest: "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" setupAssetDigest: "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
@ -308,7 +308,7 @@ describe("update", () => {
currentVersion: APP_VERSION, currentVersion: APP_VERSION,
latestVersion: "9.9.9", latestVersion: "9.9.9",
latestTag: "v9.9.9", latestTag: "v9.9.9",
releaseUrl: "https://codeberg.org/owner/repo/releases/tag/v9.9.9", releaseUrl: "https://github.com/owner/repo/releases/tag/v9.9.9",
setupAssetUrl: "https://example.invalid/mismatch-setup.exe", setupAssetUrl: "https://example.invalid/mismatch-setup.exe",
setupAssetName: "setup.exe", setupAssetName: "setup.exe",
setupAssetDigest: "sha256:1111111111111111111111111111111111111111111111111111111111111111" setupAssetDigest: "sha256:1111111111111111111111111111111111111111111111111111111111111111"
@ -337,7 +337,7 @@ describe("update", () => {
currentVersion: APP_VERSION, currentVersion: APP_VERSION,
latestVersion: "9.9.9", latestVersion: "9.9.9",
latestTag: "", latestTag: "",
releaseUrl: "https://codeberg.org/owner/repo/releases/tag/v9.9.9", releaseUrl: "https://github.com/owner/repo/releases/tag/v9.9.9",
setupAssetUrl: "https://example.invalid/unsigned-setup.exe", setupAssetUrl: "https://example.invalid/unsigned-setup.exe",
setupAssetName: "setup.exe", setupAssetName: "setup.exe",
setupAssetDigest: "" setupAssetDigest: ""
@ -365,7 +365,7 @@ describe("update", () => {
prerelease: false, prerelease: false,
assets: [ assets: [
{ {
name: "Real-Debrid-Downloader Setup 9.9.9.exe", name: "Real-Debrid-Downloader-Setup-9.9.9.exe",
browser_download_url: "https://example.invalid/setup-no-digest.exe" browser_download_url: "https://example.invalid/setup-no-digest.exe"
}, },
{ {
@ -407,9 +407,9 @@ describe("update", () => {
currentVersion: APP_VERSION, currentVersion: APP_VERSION,
latestVersion: "9.9.9", latestVersion: "9.9.9",
latestTag: "v9.9.9", latestTag: "v9.9.9",
releaseUrl: "https://codeberg.org/owner/repo/releases/tag/v9.9.9", releaseUrl: "https://github.com/owner/repo/releases/tag/v9.9.9",
setupAssetUrl: "https://example.invalid/setup-no-digest.exe", setupAssetUrl: "https://example.invalid/setup-no-digest.exe",
setupAssetName: "Real-Debrid-Downloader Setup 9.9.9.exe", setupAssetName: "Real-Debrid-Downloader-Setup-9.9.9.exe",
setupAssetDigest: "" setupAssetDigest: ""
}; };
@ -433,7 +433,7 @@ describe("update", () => {
prerelease: false, prerelease: false,
assets: [ assets: [
{ {
name: "Real-Debrid-Downloader Setup 9.9.9.exe", name: "Real-Debrid-Downloader-Setup-9.9.9.exe",
browser_download_url: "https://example.invalid/setup-no-digest.exe" browser_download_url: "https://example.invalid/setup-no-digest.exe"
}, },
{ {
@ -449,7 +449,7 @@ describe("update", () => {
if (url.includes("latest.yml")) { if (url.includes("latest.yml")) {
return new Response( return new Response(
`version: 9.9.9\npath: Real-Debrid-Downloader Setup 9.9.9.exe\nsha512: ${wrongDigestBase64}\n`, `version: 9.9.9\npath: Real-Debrid-Downloader-Setup-9.9.9.exe\nsha512: ${wrongDigestBase64}\n`,
{ {
status: 200, status: 200,
headers: { "Content-Type": "text/yaml" } headers: { "Content-Type": "text/yaml" }
@ -475,9 +475,9 @@ describe("update", () => {
currentVersion: APP_VERSION, currentVersion: APP_VERSION,
latestVersion: "9.9.9", latestVersion: "9.9.9",
latestTag: "v9.9.9", latestTag: "v9.9.9",
releaseUrl: "https://codeberg.org/owner/repo/releases/tag/v9.9.9", releaseUrl: "https://github.com/owner/repo/releases/tag/v9.9.9",
setupAssetUrl: "https://example.invalid/setup-no-digest.exe", setupAssetUrl: "https://example.invalid/setup-no-digest.exe",
setupAssetName: "Real-Debrid-Downloader Setup 9.9.9.exe", setupAssetName: "Real-Debrid-Downloader-Setup-9.9.9.exe",
setupAssetDigest: "" setupAssetDigest: ""
}; };
@ -509,7 +509,7 @@ describe("update", () => {
currentVersion: APP_VERSION, currentVersion: APP_VERSION,
latestVersion: "9.9.9", latestVersion: "9.9.9",
latestTag: "v9.9.9", latestTag: "v9.9.9",
releaseUrl: "https://codeberg.org/owner/repo/releases/tag/v9.9.9", releaseUrl: "https://github.com/owner/repo/releases/tag/v9.9.9",
setupAssetUrl: "https://example.invalid/progress-setup.exe", setupAssetUrl: "https://example.invalid/progress-setup.exe",
setupAssetName: "setup.exe", setupAssetName: "setup.exe",
setupAssetDigest: `sha256:${digest}` setupAssetDigest: `sha256:${digest}`
@ -539,27 +539,27 @@ describe("normalizeUpdateRepo extended", () => {
it("handles trailing slashes and extra path segments", () => { it("handles trailing slashes and extra path segments", () => {
expect(normalizeUpdateRepo("owner/repo/")).toBe("owner/repo"); expect(normalizeUpdateRepo("owner/repo/")).toBe("owner/repo");
expect(normalizeUpdateRepo("/owner/repo/")).toBe("owner/repo"); expect(normalizeUpdateRepo("/owner/repo/")).toBe("owner/repo");
expect(normalizeUpdateRepo("https://codeberg.org/owner/repo/tree/main/src")).toBe("owner/repo"); expect(normalizeUpdateRepo("https://github.com/owner/repo/tree/main/src")).toBe("owner/repo");
}); });
it("handles ssh-style git URLs", () => { it("handles ssh-style git URLs", () => {
expect(normalizeUpdateRepo("git@codeberg.org:user/project.git")).toBe("user/project"); expect(normalizeUpdateRepo("git@github.com:user/project.git")).toBe("user/project");
}); });
it("returns default for malformed inputs", () => { it("returns default for malformed inputs", () => {
expect(normalizeUpdateRepo("just-one-part")).toBe("Administrator/real-debrid-downloader"); expect(normalizeUpdateRepo("just-one-part")).toBe("Sucukdeluxe/multi-debrid-downloader");
expect(normalizeUpdateRepo(" ")).toBe("Administrator/real-debrid-downloader"); expect(normalizeUpdateRepo(" ")).toBe("Sucukdeluxe/multi-debrid-downloader");
}); });
it("rejects traversal-like owner or repo segments", () => { it("rejects traversal-like owner or repo segments", () => {
expect(normalizeUpdateRepo("../owner/repo")).toBe("Administrator/real-debrid-downloader"); expect(normalizeUpdateRepo("../owner/repo")).toBe("Sucukdeluxe/multi-debrid-downloader");
expect(normalizeUpdateRepo("owner/../repo")).toBe("Administrator/real-debrid-downloader"); expect(normalizeUpdateRepo("owner/../repo")).toBe("Sucukdeluxe/multi-debrid-downloader");
expect(normalizeUpdateRepo("https://codeberg.org/owner/../../repo")).toBe("Administrator/real-debrid-downloader"); expect(normalizeUpdateRepo("https://github.com/owner/../../repo")).toBe("Sucukdeluxe/multi-debrid-downloader");
}); });
it("handles www prefix", () => { it("handles www prefix", () => {
expect(normalizeUpdateRepo("https://www.codeberg.org/owner/repo")).toBe("owner/repo"); expect(normalizeUpdateRepo("https://www.github.com/owner/repo")).toBe("owner/repo");
expect(normalizeUpdateRepo("www.codeberg.org/owner/repo")).toBe("owner/repo"); expect(normalizeUpdateRepo("www.github.com/owner/repo")).toBe("owner/repo");
}); });
}); });

View File

@ -1,62 +0,0 @@
# rd-diagnostics-mcp
Standalone **stdio MCP bridge** to the Real-Debrid-Downloader debug-server. It runs on the machine where the
MCP client runs, takes a **connection code** for a downloader server, and exposes that server's
read-only HTTP diagnostics API (`/diagnostics`, `/status`, `/errors`, `/logs/*`, `/accounts`, …) as MCP tools.
One bridge serves all 56 servers; you pass a `code` (or a configured `server` name) per call.
This bridge is **not** bundled into the Electron app and adds **no** dependencies to it.
## Setup
```bash
cd tools/rd-diagnostics-mcp
npm install
```
Register it with your MCP client as a stdio server that launches the bridge:
```bash
node "<repo>/tools/rd-diagnostics-mcp/src/bridge.mjs"
```
Provide servers via environment variables (codes contain a token — treat like passwords):
- `RDDIAG_CODE` — a single default connection code (`rddiag:v1:...`)
- `RDDIAG_SERVERS` — JSON map of name → code, e.g. `{"berlin":"rddiag:v1:...","fra":"rddiag:v1:..."}`
Without env config, every tool simply takes a `code` argument.
## Tools
`rd_servers`, `rd_ping`, `rd_diagnostics`, `rd_status`, `rd_items`, `rd_packages`, `rd_errors`, `rd_logs`
(`main|audit|rename|trace|session|conversion|package|item`), `rd_history`, `rd_accounts`, `rd_providers`
(live per-account/key cooldown + in-flight + rotation state), `rd_host`, `rd_self_check`,
`rd_get` (raw escape-hatch, any read-only path).
Each tool accepts `code` or `server` to pick the target.
## Connection code
Format: `rddiag:v1:<base64url(JSON)>` with `{ v:1, h:host, p:port, t:token, n?:name, fp?:certFingerprint, s?:scheme }`.
Generated by the app (Hilfe → Remote-Support → Ferndiagnose (MCP)) or via `node src/gen-code.mjs --host H --port P --token T`.
## Security model (read before exposing a server)
- The debug surface is **read-only** for state/logs; the one control endpoint is `/trace/config` (toggles the
optional, time-bounded support trace). No persistent secrets are written into the logs it serves: passwords are
redacted, debrid API keys/tokens and resolved download URLs are never logged; `/settings` and `/accounts` are redacted.
- Auth is a bearer token (24 random bytes). Over plain HTTP on a public network the token is sniffable, so:
- **Preferred:** keep the server bound to `127.0.0.1` ("Nur lokal") and reach it through a private tunnel
(Tailscale / SSH / Cloudflare Tunnel). The tunnel encrypts and authenticates; no public exposure.
- **Direct network bind (`0.0.0.0`)** requires a non-empty **IP allowlist** (enforced fail-closed: with an empty
allowlist only loopback is accepted). Use only inside a trusted LAN/VPN.
- Revoke instantly from the app ("Token neu" or "Deaktivieren") — the old code stops working immediately.
- `fp` pins a self-signed cert fingerprint and is verified on `secureConnect` (before the token is sent). HTTPS is
not the v1 default; plain HTTP behind a tunnel is the recommended transport.
## Test
```bash
npm test # spins a fake debug-server, runs the bridge as a stdio child, asserts the full protocol path
```

File diff suppressed because it is too large Load Diff

View File

@ -1,18 +0,0 @@
{
"name": "rd-diagnostics-mcp",
"version": "1.0.0",
"private": true,
"description": "Standalone stdio MCP bridge to the Real-Debrid-Downloader debug-server. Connects via connection code, proxies the read-only HTTP diagnostics API as MCP tools.",
"type": "module",
"bin": {
"rd-diagnostics-mcp": "src/bridge.mjs"
},
"scripts": {
"start": "node src/bridge.mjs",
"test": "node test/harness.mjs"
},
"dependencies": {
"@modelcontextprotocol/sdk": "^1.12.0",
"zod": "^3.23.8"
}
}

View File

@ -1,332 +0,0 @@
#!/usr/bin/env node
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { z } from "zod";
import { decodeConnectionCode } from "./code.mjs";
import { debugGet } from "./http.mjs";
function loadServerMap() {
const map = new Map();
const raw = process.env.RDDIAG_SERVERS;
if (raw) {
try {
const parsed = JSON.parse(raw);
for (const [name, code] of Object.entries(parsed || {})) {
map.set(String(name), String(code));
}
} catch {
process.stderr.write("rd-diagnostics-mcp: RDDIAG_SERVERS ist kein gueltiges JSON, wird ignoriert\n");
}
}
return map;
}
const SERVER_MAP = loadServerMap();
const DEFAULT_CODE = process.env.RDDIAG_CODE ? String(process.env.RDDIAG_CODE) : "";
function listAvailableServers() {
const names = [...SERVER_MAP.keys()];
if (DEFAULT_CODE) names.push("(RDDIAG_CODE-Default)");
return names;
}
function resolveTarget(args) {
let code = "";
if (args && args.code) {
code = String(args.code);
} else if (args && args.server) {
const found = SERVER_MAP.get(String(args.server));
if (!found) {
throw new Error(
`Server "${args.server}" nicht konfiguriert. Bekannt: ${listAvailableServers().join(", ") || "(keine)"}`
);
}
code = found;
} else if (DEFAULT_CODE) {
code = DEFAULT_CODE;
} else if (SERVER_MAP.size === 1) {
code = [...SERVER_MAP.values()][0];
} else {
throw new Error(
`Kein Verbindungscode. Uebergib "code" oder "server", oder setze RDDIAG_CODE/RDDIAG_SERVERS. Bekannt: ${listAvailableServers().join(", ") || "(keine)"}`
);
}
return decodeConnectionCode(code);
}
function targetLabel(target) {
return target.name ? `${target.name} (${target.host}:${target.port})` : `${target.host}:${target.port}`;
}
function buildQuery(params) {
const usable = Object.entries(params || {}).filter(
([, v]) => v !== undefined && v !== null && String(v).length > 0
);
if (usable.length === 0) return "";
const sp = new URLSearchParams();
for (const [k, v] of usable) sp.set(k, String(v));
return "?" + sp.toString();
}
function prettyBody(body) {
try {
return JSON.stringify(JSON.parse(body), null, 2);
} catch {
return body;
}
}
function connectionHint(err) {
const m = String((err && err.code) || err && err.message || "");
if (/ECONNREFUSED/.test(m)) return "Debug-Server nicht erreichbar — auf dem Server aktiviert? Port/Firewall offen?";
if (/ENOTFOUND|EAI_AGAIN/.test(m)) return "Host nicht aufloesbar — stimmt die Adresse im Verbindungscode?";
if (/ETIMEDOUT|Zeitueberschreitung/.test(m)) return "Zeitueberschreitung — Server/Netz langsam oder Port geblockt.";
if (/ECONNRESET|EPIPE/.test(m)) return "Verbindung abgebrochen — falscher Port/Scheme (http vs https)?";
if (/Fingerprint/.test(m)) return "TLS-Fingerprint passt nicht — Code stammt evtl. von einem anderen Server.";
return "";
}
async function requestTool(args, path, params, opts = {}) {
let target;
try {
target = resolveTarget(args);
} catch (err) {
return { content: [{ type: "text", text: `# Verbindungsfehler\n${err.message}` }], isError: true };
}
const fullPath = path + buildQuery(params);
const label = targetLabel(target);
try {
const res = await debugGet(target, fullPath, { timeoutMs: opts.timeoutMs || 20000 });
const isError = res.status < 200 || res.status >= 300;
let extra = "";
if (res.status === 401) extra = "\n(401 = Token im Verbindungscode ist abgelaufen/rotiert. Neuen Code anfordern.)";
if (res.status === 503) extra = "\n(503 = Download-Manager nicht bereit. App laeuft, aber noch nicht initialisiert?)";
const head = `# ${label} ${fullPath} → HTTP ${res.status}${extra}`;
return { content: [{ type: "text", text: head + "\n" + prettyBody(res.body) }], isError };
} catch (err) {
const hint = connectionHint(err);
const text = `# ${label} ${fullPath} → FEHLER\n${err.message}${hint ? "\n→ " + hint : ""}`;
return { content: [{ type: "text", text }], isError: true };
}
}
const CODE_FIELD = {
code: z.string().optional().describe("Verbindungscode (rddiag:v1:...). Optional, wenn server/RDDIAG_CODE gesetzt ist."),
server: z.string().optional().describe("Name eines via RDDIAG_SERVERS konfigurierten Servers statt eines vollen Codes.")
};
const server = new McpServer({ name: "rd-diagnostics-mcp", version: "1.0.0" });
server.registerTool(
"rd_servers",
{
title: "Konfigurierte Server",
description: "Listet die in dieser Bridge konfigurierten Server (RDDIAG_SERVERS / RDDIAG_CODE). Verbindet sich nicht.",
inputSchema: {}
},
async () => {
const names = [...SERVER_MAP.keys()];
const lines = [];
lines.push(`Konfigurierte Server: ${names.length}`);
for (const n of names) lines.push(`- ${n}`);
lines.push(`Default (RDDIAG_CODE): ${DEFAULT_CODE ? "gesetzt" : "nicht gesetzt"}`);
lines.push("");
lines.push("Tools akzeptieren entweder code:<rddiag:v1:...> oder server:<name>.");
return { content: [{ type: "text", text: lines.join("\n") }] };
}
);
server.registerTool(
"rd_ping",
{
title: "Erreichbarkeit pruefen",
description: "Schneller Health-Check (GET /health): App-Version, Uptime, Speicher. Zuerst aufrufen, um Erreichbarkeit + Token zu pruefen.",
inputSchema: { ...CODE_FIELD }
},
async (args) => requestTool(args, "/health", {}, { timeoutMs: 10000 })
);
server.registerTool(
"rd_diagnostics",
{
title: "Gesamtdiagnose",
description: "Aggregierter Zustand (GET /diagnostics): Meta, Status, Settings, Stats, Accounts, History, Host + die wichtigsten Logs. Der 'alles auf einen Blick'-Endpunkt.",
inputSchema: {
...CODE_FIELD,
lines: z.number().int().positive().optional().describe("Anzahl Log-Zeilen pro Log (Default 150)."),
grep: z.string().optional().describe("Filter fuer Log-Zeilen."),
package: z.string().optional().describe("Optional auf ein Paket fokussieren.")
}
},
async (args) => requestTool(args, "/diagnostics", { lines: args.lines, grep: args.grep, package: args.package }, { timeoutMs: 30000 })
);
server.registerTool(
"rd_status",
{
title: "Live-Status",
description: "Laufzeit-Status (GET /status): aktive Downloads, Queue, Provider-Zustand.",
inputSchema: { ...CODE_FIELD }
},
async (args) => requestTool(args, "/status", {})
);
server.registerTool(
"rd_items",
{
title: "Download-Items",
description: "Einzelne Download-Items (GET /items), optional gefiltert nach Status/Paket.",
inputSchema: {
...CODE_FIELD,
status: z.string().optional().describe("Status-Filter (z.B. downloading, error, done)."),
package: z.string().optional().describe("Paket-Filter.")
}
},
async (args) => requestTool(args, "/items", { status: args.status, package: args.package })
);
server.registerTool(
"rd_packages",
{
title: "Pakete",
description: "Pakete (GET /packages), optional mit enthaltenen Items.",
inputSchema: {
...CODE_FIELD,
package: z.string().optional().describe("Bestimmtes Paket."),
includeItems: z.boolean().optional().describe("Items mitliefern.")
}
},
async (args) => requestTool(args, "/packages", { package: args.package, includeItems: args.includeItems ? "1" : "" })
);
server.registerTool(
"rd_errors",
{
title: "Letzte Fehler",
description: "Fehler-Ring (GET /errors): die letzten Fehler mit Level/Quelle. 'Was ist schiefgelaufen'.",
inputSchema: {
...CODE_FIELD,
level: z.string().optional().describe("Level-Filter (ERROR, WARN, ...)."),
limit: z.number().int().positive().optional().describe("Anzahl (Default 100).")
}
},
async (args) => requestTool(args, "/errors", { level: args.level, limit: args.limit })
);
const LOG_PATHS = {
main: "/logs/main",
audit: "/logs/audit",
rename: "/logs/rename",
trace: "/logs/trace",
session: "/logs/session",
conversion: "/logs/conversion",
package: "/logs/package",
item: "/logs/item"
};
server.registerTool(
"rd_logs",
{
title: "Log lesen",
description: "Liest das Ende eines Logs (GET /logs/<name>). name: main|audit|rename|trace|session|conversion|package|item. conversion = Pro-Item Link-Aufloesungs-Lebenszyklus (Token, API, Web, Rotation, Abbrueche mit Zeiten). Fuer package/item zusaetzlich package/item angeben.",
inputSchema: {
...CODE_FIELD,
name: z.enum(["main", "audit", "rename", "trace", "session", "conversion", "package", "item"]).describe("Welches Log."),
lines: z.number().int().positive().optional().describe("Anzahl Zeilen vom Ende (Default 100)."),
grep: z.string().optional().describe("Filter."),
package: z.string().optional().describe("Nur fuer name=package."),
item: z.string().optional().describe("Nur fuer name=item.")
}
},
async (args) => {
const path = LOG_PATHS[args.name];
return requestTool(args, path, { lines: args.lines, grep: args.grep, package: args.package, item: args.item });
}
);
server.registerTool(
"rd_history",
{
title: "Verlauf",
description: "Abgeschlossener Verlauf (GET /history), optional nach Status/Suchbegriff.",
inputSchema: {
...CODE_FIELD,
limit: z.number().int().positive().optional().describe("Anzahl (Default 50)."),
status: z.string().optional().describe("Status-Filter."),
grep: z.string().optional().describe("Suchbegriff.")
}
},
async (args) => requestTool(args, "/history", { limit: args.limit, status: args.status, grep: args.grep })
);
server.registerTool(
"rd_accounts",
{
title: "Accounts",
description: "Debrid-Accounts (GET /accounts, Token redigiert): Gueltigkeit, Premium, Cooldown/Rotation.",
inputSchema: { ...CODE_FIELD }
},
async (args) => requestTool(args, "/accounts", {})
);
server.registerTool(
"rd_providers",
{
title: "Provider-Laufzeitzustand",
description: "Live Provider-Runtime (GET /providers): pro Mega-Account/Debrid-Link-Key der AKTIVE Cooldown (until/remainingMs/Grund/Kategorie), in-flight-Tiefe, Mega-Rotationscursor, Empty-Response-Streaks. Die 'warum kuehlt es JETZT ab'-Ansicht — beantwortet Cooldown-Fragen direkt statt aus Log-Arithmetik.",
inputSchema: { ...CODE_FIELD }
},
async (args) => requestTool(args, "/providers", {})
);
server.registerTool(
"rd_host",
{
title: "Host-Diagnose",
description: "Windows-Host-Diagnose (GET /host/diagnostics): Laufwerke, Speicher, Pfade.",
inputSchema: { ...CODE_FIELD }
},
async (args) => requestTool(args, "/host/diagnostics", {})
);
server.registerTool(
"rd_self_check",
{
title: "Self-Check",
description: "Setup/Self-Check (GET /self-check): erkennt Konfigurations-/Pfadprobleme.",
inputSchema: { ...CODE_FIELD }
},
async (args) => requestTool(args, "/self-check", {})
);
server.registerTool(
"rd_get",
{
title: "Roh-Endpunkt (Escape-Hatch)",
description: "Beliebigen Debug-Server-Pfad lesen (GET <path>), wenn kein spezialisiertes Tool passt. Pfad inkl. fuehrendem / und optionalem Query-String, z.B. /meta oder /stats.",
inputSchema: {
...CODE_FIELD,
path: z.string().describe("Pfad mit fuehrendem /, optional ?query. Nur GET, read-only.")
}
},
async (args) => {
const p = String(args.path || "");
if (!p.startsWith("/")) {
return { content: [{ type: "text", text: "# Fehler\npath muss mit / beginnen" }], isError: true };
}
return requestTool(args, p, {}, { timeoutMs: 30000 });
}
);
async function main() {
const transport = new StdioServerTransport();
await server.connect(transport);
process.stderr.write(
`rd-diagnostics-mcp bereit. Server: ${listAvailableServers().join(", ") || "(keine vorkonfiguriert; code pro Aufruf uebergeben)"}\n`
);
}
main().catch((err) => {
process.stderr.write(`rd-diagnostics-mcp Startfehler: ${err && err.stack ? err.stack : err}\n`);
process.exit(1);
});

View File

@ -1,20 +0,0 @@
export interface DecodedConnectionCode {
host: string;
port: number;
token: string;
scheme: string;
name: string;
fingerprint: string;
}
export interface EncodeConnectionCodeInput {
host: string;
port: number;
token: string;
name?: string;
fingerprint?: string;
scheme?: string;
}
export function encodeConnectionCode(input: EncodeConnectionCodeInput): string;
export function decodeConnectionCode(code: string): DecodedConnectionCode;

View File

@ -1,56 +0,0 @@
const PREFIX = "rddiag:v1:";
function base64urlEncode(str) {
return Buffer.from(str, "utf8")
.toString("base64")
.replace(/\+/g, "-")
.replace(/\//g, "_")
.replace(/=+$/, "");
}
function base64urlDecode(str) {
const pad = str.length % 4 === 0 ? "" : "=".repeat(4 - (str.length % 4));
const b64 = str.replace(/-/g, "+").replace(/_/g, "/") + pad;
return Buffer.from(b64, "base64").toString("utf8");
}
export function encodeConnectionCode({ host, port, token, name, fingerprint, scheme }) {
if (!host || typeof host !== "string") throw new Error("host fehlt");
const p = Number(port);
if (!Number.isInteger(p) || p < 1 || p > 65535) throw new Error("port ungueltig");
if (!token || typeof token !== "string") throw new Error("token fehlt");
const payload = { v: 1, h: host, p, t: token };
if (name) payload.n = String(name);
if (fingerprint) payload.fp = String(fingerprint);
if (scheme && scheme !== "http") payload.s = String(scheme);
return PREFIX + base64urlEncode(JSON.stringify(payload));
}
export function decodeConnectionCode(code) {
const raw = String(code || "").trim();
if (!raw.startsWith(PREFIX)) {
throw new Error(`Verbindungscode muss mit "${PREFIX}" beginnen`);
}
let json;
try {
json = JSON.parse(base64urlDecode(raw.slice(PREFIX.length)));
} catch {
throw new Error("Verbindungscode ist beschaedigt (kein gueltiges base64url/JSON)");
}
if (!json || typeof json !== "object") throw new Error("Verbindungscode-Inhalt ungueltig");
const host = String(json.h || "").trim();
const port = Number(json.p);
const token = String(json.t || "");
if (!host) throw new Error("Verbindungscode ohne Host");
if (!Number.isInteger(port) || port < 1 || port > 65535) throw new Error("Verbindungscode mit ungueltigem Port");
if (!token) throw new Error("Verbindungscode ohne Token");
const scheme = json.s === "https" ? "https" : "http";
return {
host,
port,
token,
scheme,
name: json.n ? String(json.n) : "",
fingerprint: json.fp ? String(json.fp) : ""
};
}

View File

@ -1,22 +0,0 @@
#!/usr/bin/env node
import { encodeConnectionCode } from "./code.mjs";
function arg(name, fallback) {
const i = process.argv.indexOf("--" + name);
if (i >= 0 && i + 1 < process.argv.length) return process.argv[i + 1];
return fallback;
}
const host = arg("host");
const port = arg("port");
const token = arg("token");
const name = arg("name");
const scheme = arg("scheme");
const fingerprint = arg("fp");
if (!host || !port || !token) {
process.stderr.write("Usage: node src/gen-code.mjs --host <h> --port <p> --token <t> [--name <n>] [--scheme https] [--fp <sha256>]\n");
process.exit(2);
}
process.stdout.write(encodeConnectionCode({ host, port, token, name, scheme, fingerprint }) + "\n");

View File

@ -1,63 +0,0 @@
import http from "node:http";
import https from "node:https";
function normalizeFp(fp) {
return String(fp || "").replace(/:/g, "").toLowerCase();
}
export function debugGet(target, path, { timeoutMs = 20000 } = {}) {
const scheme = target.scheme === "https" ? "https" : "http";
const lib = scheme === "https" ? https : http;
const rel = path.startsWith("/") ? path : "/" + path;
const url = new URL(rel, `${scheme}://${target.host}:${target.port}`);
const pinning = scheme === "https" && !!target.fingerprint;
return new Promise((resolve, reject) => {
const options = {
method: "GET",
headers: {
Authorization: `Bearer ${target.token}`,
Accept: "application/json"
},
timeout: timeoutMs
};
if (scheme === "https") {
options.rejectUnauthorized = !target.fingerprint;
}
const req = lib.request(url, options, (res) => {
let data = "";
res.setEncoding("utf8");
res.on("data", (chunk) => {
data += chunk;
});
res.on("end", () => {
resolve({ status: res.statusCode || 0, body: data, headers: res.headers });
});
});
req.on("timeout", () => {
req.destroy(new Error(`Zeitueberschreitung nach ${timeoutMs}ms`));
});
req.on("error", (err) => {
reject(err);
});
if (pinning) {
req.on("socket", (socket) => {
socket.on("secureConnect", () => {
const cert = typeof socket.getPeerCertificate === "function" ? socket.getPeerCertificate() : null;
const got = normalizeFp(cert && cert.fingerprint256);
const want = normalizeFp(target.fingerprint);
if (!got || got !== want) {
req.destroy(new Error(`TLS-Fingerprint stimmt nicht (erwartet ${want || "?"}, erhalten ${got || "?"})`));
return;
}
req.end();
});
});
} else {
req.end();
}
});
}

View File

@ -1,175 +0,0 @@
import http from "node:http";
import { spawn } from "node:child_process";
import { fileURLToPath } from "node:url";
import { dirname, join } from "node:path";
import { encodeConnectionCode } from "../src/code.mjs";
const __dirname = dirname(fileURLToPath(import.meta.url));
const BRIDGE = join(__dirname, "..", "src", "bridge.mjs");
const TOKEN = "test-token-abc123";
const failures = [];
function check(name, cond, detail) {
if (cond) {
process.stdout.write(` PASS ${name}\n`);
} else {
failures.push(name);
process.stdout.write(` FAIL ${name}${detail ? " — " + detail : ""}\n`);
}
}
function startFakeServer() {
return new Promise((resolve) => {
const server = http.createServer((req, res) => {
const url = new URL(req.url, "http://localhost");
const auth = req.headers.authorization || "";
const tokenOk = auth === `Bearer ${TOKEN}` || url.searchParams.get("token") === TOKEN;
if (!tokenOk) {
res.writeHead(401, { "content-type": "application/json" });
res.end(JSON.stringify({ error: "Unauthorized" }));
return;
}
const p = url.pathname;
const q = Object.fromEntries(url.searchParams.entries());
const send = (obj) => {
res.writeHead(200, { "content-type": "application/json" });
res.end(JSON.stringify(obj));
};
if (p === "/health") return send({ status: "ok", appVersion: "1.7.222", uptime: 42 });
if (p === "/diagnostics") return send({ meta: { appVersion: "1.7.222" }, status: { active: 1 }, query: q });
if (p === "/errors") return send({ errors: [{ level: "ERROR", message: "boom" }], query: q });
if (p === "/logs/main") return send({ lines: ["line1", "line2"], count: 2, query: q });
if (p === "/status") return send({ active: 1, queued: 3 });
if (p === "/items") return send({ items: [], query: q });
if (p === "/accounts") return send({ accounts: [{ name: "acc1", premium: true }] });
if (p === "/meta") return send({ appVersion: "1.7.222", endpoints: ["/health", "/diagnostics"] });
res.writeHead(404, { "content-type": "application/json" });
res.end(JSON.stringify({ error: "not found", path: p }));
});
server.listen(0, "127.0.0.1", () => resolve(server));
});
}
function startBridge() {
const child = spawn(process.execPath, [BRIDGE], { stdio: ["pipe", "pipe", "pipe"] });
child.stderr.on("data", (d) => process.stderr.write(`[bridge] ${d}`));
const pending = new Map();
let buf = "";
child.stdout.on("data", (chunk) => {
buf += chunk.toString("utf8");
let idx;
while ((idx = buf.indexOf("\n")) >= 0) {
const line = buf.slice(0, idx).trim();
buf = buf.slice(idx + 1);
if (!line) continue;
let msg;
try {
msg = JSON.parse(line);
} catch {
continue;
}
if (msg.id !== undefined && pending.has(msg.id)) {
pending.get(msg.id)(msg);
pending.delete(msg.id);
}
}
});
let nextId = 1;
function rpc(method, params) {
const id = nextId++;
return new Promise((resolve, reject) => {
pending.set(id, resolve);
child.stdin.write(JSON.stringify({ jsonrpc: "2.0", id, method, params }) + "\n");
setTimeout(() => {
if (pending.has(id)) {
pending.delete(id);
reject(new Error(`RPC timeout: ${method}`));
}
}, 15000);
});
}
function notify(method, params) {
child.stdin.write(JSON.stringify({ jsonrpc: "2.0", method, params }) + "\n");
}
return { child, rpc, notify };
}
function textOf(callResult) {
const c = callResult && callResult.result && callResult.result.content;
if (!Array.isArray(c)) return "";
return c.map((x) => x.text || "").join("\n");
}
async function run() {
const fake = await startFakeServer();
const port = fake.address().port;
const code = encodeConnectionCode({ host: "127.0.0.1", port, token: TOKEN, name: "testserver" });
const badCode = encodeConnectionCode({ host: "127.0.0.1", port, token: "WRONG", name: "testserver" });
const bridge = startBridge();
try {
const init = await bridge.rpc("initialize", {
protocolVersion: "2024-11-05",
capabilities: {},
clientInfo: { name: "harness", version: "1.0.0" }
});
check("initialize handshake", !!(init.result && init.result.serverInfo), JSON.stringify(init.error || {}));
check("server name reported", init.result && init.result.serverInfo && init.result.serverInfo.name === "rd-diagnostics-mcp");
bridge.notify("notifications/initialized", {});
const tools = await bridge.rpc("tools/list", {});
const names = (tools.result && tools.result.tools || []).map((t) => t.name);
check("tools/list returns tools", names.length >= 10, `got ${names.length}`);
for (const expected of ["rd_ping", "rd_diagnostics", "rd_errors", "rd_logs", "rd_get", "rd_servers"]) {
check(`tool present: ${expected}`, names.includes(expected));
}
const ping = await bridge.rpc("tools/call", { name: "rd_ping", arguments: { code } });
const pingText = textOf(ping);
check("rd_ping reaches server", /HTTP 200/.test(pingText) && /"status": "ok"/.test(pingText), pingText.slice(0, 200));
check("rd_ping shows server label", /testserver \(127\.0\.0\.1:/.test(pingText));
const diag = await bridge.rpc("tools/call", { name: "rd_diagnostics", arguments: { code, lines: 50, grep: "err" } });
const diagText = textOf(diag);
check("rd_diagnostics returns aggregate", /"appVersion": "1\.7\.222"/.test(diagText));
check("rd_diagnostics passes query params", /"lines": "50"/.test(diagText) && /"grep": "err"/.test(diagText), diagText.slice(0, 300));
const logs = await bridge.rpc("tools/call", { name: "rd_logs", arguments: { code, name: "main", lines: 5 } });
const logsText = textOf(logs);
check("rd_logs maps name→path + lines", /logs\/main\?lines=5/.test(logsText) && /"count": 2/.test(logsText), logsText.slice(0, 200));
const errs = await bridge.rpc("tools/call", { name: "rd_errors", arguments: { code, level: "ERROR" } });
check("rd_errors returns ring", /"message": "boom"/.test(textOf(errs)));
const raw = await bridge.rpc("tools/call", { name: "rd_get", arguments: { code, path: "/meta" } });
check("rd_get escape hatch hits arbitrary path", /"endpoints"/.test(textOf(raw)));
const unauthorized = await bridge.rpc("tools/call", { name: "rd_ping", arguments: { code: badCode } });
check("bad token → HTTP 401 + isError", /HTTP 401/.test(textOf(unauthorized)) && unauthorized.result.isError === true);
const noCode = await bridge.rpc("tools/call", { name: "rd_ping", arguments: {} });
check("missing code → graceful isError", noCode.result && noCode.result.isError === true && /Kein Verbindungscode/.test(textOf(noCode)));
const unreachable = await bridge.rpc("tools/call", {
name: "rd_ping",
arguments: { code: encodeConnectionCode({ host: "127.0.0.1", port: 1, token: TOKEN }) }
});
check("unreachable → isError + hint", unreachable.result.isError === true && /nicht erreichbar|abgebrochen|FEHLER/.test(textOf(unreachable)), textOf(unreachable).slice(0, 160));
} finally {
bridge.child.kill();
fake.close();
}
process.stdout.write("\n");
if (failures.length) {
process.stdout.write(`RESULT: ${failures.length} FAIL\n`);
process.exit(1);
}
process.stdout.write("RESULT: ALL PASS\n");
process.exit(0);
}
run().catch((err) => {
process.stderr.write(`harness error: ${err && err.stack ? err.stack : err}\n`);
process.exit(1);
});

View File

@ -3,7 +3,7 @@ import { defineConfig } from "vitest/config";
export default defineConfig({ export default defineConfig({
test: { test: {
environment: "node", environment: "node",
include: ["tests/**/*.test.ts"], include: ["tests/**/*.test.ts", "scripts/tests/**/*.test.ts"],
globals: true globals: true
} }
}); });