Compare commits
No commits in common. "v2.0.0" and "main" have entirely different histories.
53
.gitignore
vendored
53
.gitignore
vendored
@ -1,18 +1,49 @@
|
|||||||
node_modules/
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
*.pyo
|
||||||
|
*.pyd
|
||||||
|
|
||||||
|
.venv/
|
||||||
|
venv/
|
||||||
|
|
||||||
build/
|
build/
|
||||||
dist/
|
dist/
|
||||||
release/
|
release/
|
||||||
.vite/
|
*.spec
|
||||||
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
|
|
||||||
.env.*
|
.claude/
|
||||||
!.env.example
|
.github/
|
||||||
.DS_Store
|
CHANGELOG.md
|
||||||
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
|
||||||
|
|||||||
28
CLAUDE.md
Normal file
28
CLAUDE.md
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
## 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
21
LICENSE
@ -1,21 +0,0 @@
|
|||||||
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.
|
|
||||||
19
README.md
19
README.md
@ -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 GitHub.
|
- Includes an in-app updater for releases published on `git.24-music.de`.
|
||||||
|
|
||||||
## 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: [GitHub Releases](https://github.com/Sucukdeluxe/multi-debrid-downloader/releases)
|
Releases: [git.24-music.de Releases](https://git.24-music.de/Administrator/real-debrid-downloader/releases)
|
||||||
|
|
||||||
### Build from source
|
### Build from source
|
||||||
|
|
||||||
@ -152,8 +152,9 @@ 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
|
||||||
|
|
||||||
@ -207,7 +208,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_support_manifest.json`
|
- `debug_ai_manifest.json`
|
||||||
- `trace.log`
|
- `trace.log`
|
||||||
- `trace_config.json`
|
- `trace_config.json`
|
||||||
- `session-logs/session_*.txt`
|
- `session-logs/session_*.txt`
|
||||||
@ -229,11 +230,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_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.
|
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.
|
||||||
|
|
||||||
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/support-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/AI-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:
|
||||||
|
|
||||||
@ -299,10 +300,8 @@ This makes it easy to share one URL plus token during support, so current packag
|
|||||||
|
|
||||||
## Changelog
|
## Changelog
|
||||||
|
|
||||||
Detailed release history is published on [GitHub Releases](https://github.com/Sucukdeluxe/multi-debrid-downloader/releases).
|
Detailed release history is published on [git.24-music.de Releases](https://git.24-music.de/Administrator/real-debrid-downloader/releases).
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
The project is licensed under the MIT License. See `LICENSE`.
|
MIT - see `LICENSE`.
|
||||||
|
|
||||||
Bundled JVM extractor licenses and redistribution notices are available in `resources/extractor-jvm`.
|
|
||||||
|
|||||||
50
installer/RealDebridDownloader.iss
Normal file
50
installer/RealDebridDownloader.iss
Normal file
@ -0,0 +1,50 @@
|
|||||||
|
#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
41
package-lock.json
generated
@ -1,18 +1,18 @@
|
|||||||
{
|
{
|
||||||
"name": "real-debrid-downloader",
|
"name": "real-debrid-downloader",
|
||||||
"version": "2.0.0",
|
"version": "1.7.45",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "real-debrid-downloader",
|
"name": "real-debrid-downloader",
|
||||||
"version": "2.0.0",
|
"version": "1.7.45",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"adm-zip": "0.6.0",
|
"adm-zip": "^0.5.16",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-dom": "^18.3.1",
|
"react-dom": "^18.3.1",
|
||||||
"uuid": "11.1.1"
|
"uuid": "^11.1.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/adm-zip": "^0.5.7",
|
"@types/adm-zip": "^0.5.7",
|
||||||
@ -31,8 +31,7 @@
|
|||||||
"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": {
|
||||||
@ -2254,12 +2253,12 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/adm-zip": {
|
"node_modules/adm-zip": {
|
||||||
"version": "0.6.0",
|
"version": "0.5.16",
|
||||||
"resolved": "https://registry.npmjs.org/adm-zip/-/adm-zip-0.6.0.tgz",
|
"resolved": "https://registry.npmjs.org/adm-zip/-/adm-zip-0.5.16.tgz",
|
||||||
"integrity": "sha512-XleryMhbuksdKtofnWZ9Sk+4CUTbms4Mb/EU32SZwToAyZ5RgVos/ki8n+yr0LWHOGKuakbXTuuYNHLQjhddgg==",
|
"integrity": "sha512-TGw5yVi4saajsSEgz25grObGHEUaDrniwvA2qwSC060KfqGPdglhvPMA2lPIoxs3PQIItj2iag35fONcQqgUaQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=14.0"
|
"node": ">=12.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/agent-base": {
|
"node_modules/agent-base": {
|
||||||
@ -7829,9 +7828,9 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/uuid": {
|
"node_modules/uuid": {
|
||||||
"version": "11.1.1",
|
"version": "11.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.0.tgz",
|
||||||
"integrity": "sha512-vIYxrBCC/N/K+Js3qSN88go7kIfNPssr/hHCesKCQNAjmgvYS2oqr69kIufEG+O4+PfezOH4EbIeHCfFov8ZgQ==",
|
"integrity": "sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==",
|
||||||
"funding": [
|
"funding": [
|
||||||
"https://github.com/sponsors/broofa",
|
"https://github.com/sponsors/broofa",
|
||||||
"https://github.com/sponsors/ctavan"
|
"https://github.com/sponsors/ctavan"
|
||||||
@ -9670,22 +9669,6 @@
|
|||||||
"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",
|
||||||
|
|||||||
28
package.json
28
package.json
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "real-debrid-downloader",
|
"name": "real-debrid-downloader",
|
||||||
"version": "2.0.0",
|
"version": "1.7.232",
|
||||||
"description": "Desktop downloader",
|
"description": "Desktop downloader",
|
||||||
"main": "build/main/main/main.js",
|
"main": "build/main/main/main.js",
|
||||||
"author": "Sucukdeluxe",
|
"author": "Sucukdeluxe",
|
||||||
@ -17,13 +17,14 @@
|
|||||||
"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",
|
||||||
"verify:release": "node scripts/verify_public_release.mjs"
|
"release:gitea": "node scripts/release_gitea.mjs",
|
||||||
|
"release:forgejo": "node scripts/release_gitea.mjs"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"adm-zip": "0.6.0",
|
"adm-zip": "^0.5.16",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-dom": "^18.3.1",
|
"react-dom": "^18.3.1",
|
||||||
"uuid": "11.1.1"
|
"uuid": "^11.1.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/adm-zip": "^0.5.7",
|
"@types/adm-zip": "^0.5.7",
|
||||||
@ -42,8 +43,7 @@
|
|||||||
"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,24 +52,12 @@
|
|||||||
"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/**/*"
|
||||||
],
|
],
|
||||||
@ -82,15 +70,11 @@
|
|||||||
"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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,7 +1,12 @@
|
|||||||
Bundled JVM extractor dependencies
|
Bundled JVM extractor dependencies:
|
||||||
|
|
||||||
| Maven artifact | Version | License and restrictions | Included text | Upstream |
|
1) sevenzipjbinding (16.02-2.01)
|
||||||
| --- | --- | --- | --- | --- |
|
- Maven artifact: net.sf.sevenzipjbinding:sevenzipjbinding
|
||||||
| 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/ |
|
- Maven artifact: net.sf.sevenzipjbinding:sevenzipjbinding-all-platforms
|
||||||
| 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/ |
|
- Upstream: 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.
|
||||||
|
|||||||
@ -1,146 +0,0 @@
|
|||||||
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.
|
|
||||||
|
|
||||||
--
|
|
||||||
@ -1,202 +0,0 @@
|
|||||||
|
|
||||||
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.
|
|
||||||
@ -1,501 +0,0 @@
|
|||||||
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!
|
|
||||||
51
scripts/debrid_service_smoke.ts
Normal file
51
scripts/debrid_service_smoke.ts
Normal file
@ -0,0 +1,51 @@
|
|||||||
|
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); });
|
||||||
148
scripts/mega_web_generate_download_test.mjs
Normal file
148
scripts/mega_web_generate_download_test.mjs
Normal file
@ -0,0 +1,148 @@
|
|||||||
|
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); });
|
||||||
295
scripts/provider_smoke_check.mjs
Normal file
295
scripts/provider_smoke_check.mjs
Normal file
@ -0,0 +1,295 @@
|
|||||||
|
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); });
|
||||||
392
scripts/release_gitea.mjs
Normal file
392
scripts/release_gitea.mjs
Normal file
@ -0,0 +1,392 @@
|
|||||||
|
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);
|
||||||
|
});
|
||||||
@ -1,392 +0,0 @@
|
|||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -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, resolveRemoteDiagnosticsRestore, BackupRemoteDiagnostics } from "./backup-payload";
|
import { buildBackupPayload, planBackupImport, resolveMcpRemoteRestore, BackupMcpRemote } 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 restoreRemoteDiagnosticsFromBackup(section: unknown, restartNow: boolean): void {
|
private restoreMcpRemoteFromBackup(section: unknown, restartNow: boolean): void {
|
||||||
const restore = resolveRemoteDiagnosticsRestore(section);
|
const restore = resolveMcpRemoteRestore(section);
|
||||||
if (!restore) {
|
if (!restore) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@ -748,10 +748,10 @@ public async checkDebridAccounts(): Promise<DebridAccountStatus[]> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public exportBackup(): Buffer {
|
public exportBackup(): Buffer {
|
||||||
let remoteDiagnostics: BackupRemoteDiagnostics | undefined;
|
let mcpRemote: BackupMcpRemote | undefined;
|
||||||
if (Boolean(this.settings.backupIncludeRemoteDiagnostics)) {
|
if (Boolean(this.settings.backupIncludeMcp)) {
|
||||||
const status = getDebugServerRuntimeStatus();
|
const status = getDebugServerRuntimeStatus();
|
||||||
remoteDiagnostics = {
|
mcpRemote = {
|
||||||
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()),
|
||||||
remoteDiagnostics
|
mcpRemote
|
||||||
});
|
});
|
||||||
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.restoreRemoteDiagnosticsFromBackup(parsed.remoteDiagnostics, true);
|
this.restoreMcpRemoteFromBackup(parsed.mcpRemote, 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.restoreRemoteDiagnosticsFromBackup(parsed.remoteDiagnostics, false);
|
this.restoreMcpRemoteFromBackup(parsed.mcpRemote, false);
|
||||||
|
|
||||||
this.manager.skipShutdownPersist = true;
|
this.manager.skipShutdownPersist = true;
|
||||||
this.manager.blockAllPersistence = true;
|
this.manager.blockAllPersistence = true;
|
||||||
|
|||||||
@ -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 BackupRemoteDiagnostics {
|
export interface BackupMcpRemote {
|
||||||
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[];
|
||||||
remoteDiagnostics?: BackupRemoteDiagnostics;
|
mcpRemote?: BackupMcpRemote;
|
||||||
}
|
}
|
||||||
|
|
||||||
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[];
|
||||||
remoteDiagnostics?: BackupRemoteDiagnostics;
|
mcpRemote?: BackupMcpRemote;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -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.backupIncludeRemoteDiagnostics) && input.remoteDiagnostics) {
|
if (Boolean(input.settings.backupIncludeMcp) && input.mcpRemote) {
|
||||||
base.remoteDiagnostics = input.remoteDiagnostics;
|
base.mcpRemote = input.mcpRemote;
|
||||||
}
|
}
|
||||||
return base;
|
return base;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface RemoteDiagnosticsRestore {
|
export interface McpRemoteRestore {
|
||||||
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 resolveRemoteDiagnosticsRestore(section: unknown): RemoteDiagnosticsRestore | null {
|
export function resolveMcpRemoteRestore(section: unknown): McpRemoteRestore | null {
|
||||||
if (!section || typeof section !== "object") {
|
if (!section || typeof section !== "object") {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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 = "Sucukdeluxe/multi-debrid-downloader";
|
export const DEFAULT_UPDATE_REPO = "Administrator/real-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,
|
||||||
backupIncludeRemoteDiagnostics: false,
|
backupIncludeMcp: false,
|
||||||
notifyUrl: "",
|
notifyUrl: "",
|
||||||
notifyMention: "",
|
notifyMention: "",
|
||||||
notifyOnPackageCompleted: false,
|
notifyOnPackageCompleted: false,
|
||||||
|
|||||||
@ -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 SUPPORT_MANIFEST_FILE = "debug_support_manifest.json";
|
const AI_MANIFEST_FILE = "debug_ai_manifest.json";
|
||||||
|
|
||||||
type DebugEndpointDescriptor = {
|
type DebugEndpointDescriptor = {
|
||||||
method: "GET";
|
method: "GET";
|
||||||
@ -103,11 +103,12 @@ function extractDebugClientIp(req: http.IncomingMessage): string {
|
|||||||
if (realIp) {
|
if (realIp) {
|
||||||
return realIp;
|
return realIp;
|
||||||
}
|
}
|
||||||
return getPeerIp(req);
|
const remote = String(req.socket.remoteAddress || req.socket.address()?.address || "").trim();
|
||||||
|
return remote.replace(/^::ffff:/i, "");
|
||||||
}
|
}
|
||||||
|
|
||||||
function getSupportManifestPath(baseDir: string = runtimeBaseDir): string {
|
function getAiManifestPath(baseDir: string = runtimeBaseDir): string {
|
||||||
return path.join(baseDir, SUPPORT_MANIFEST_FILE);
|
return path.join(baseDir, AI_MANIFEST_FILE);
|
||||||
}
|
}
|
||||||
|
|
||||||
function getDebugTokenPath(baseDir: string = runtimeBaseDir): string {
|
function getDebugTokenPath(baseDir: string = runtimeBaseDir): string {
|
||||||
@ -342,13 +343,26 @@ function getEndpointSummaries(): string[] {
|
|||||||
return DEBUG_ENDPOINTS.map((endpoint) => formatEndpointSummary(endpoint));
|
return DEBUG_ENDPOINTS.map((endpoint) => formatEndpointSummary(endpoint));
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildSupportManifest(baseDir: string): Record<string, unknown> {
|
function buildAiManifest(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 manifest for support tooling and remote troubleshooting.",
|
purpose: "Machine-readable support manifest for AI tools 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: [
|
||||||
@ -378,15 +392,13 @@ function buildSupportManifest(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",
|
||||||
remoteAccessRequirements: [
|
askUserFor: [
|
||||||
"A reachable server IP or DNS name.",
|
"Server IP or DNS name, if remote access is required and not already known."
|
||||||
"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,
|
||||||
@ -395,11 +407,11 @@ function buildSupportManifest(baseDir: string): Record<string, unknown> {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function writeSupportManifest(baseDir: string): void {
|
function writeAiManifest(baseDir: string): void {
|
||||||
try {
|
try {
|
||||||
fs.writeFileSync(getSupportManifestPath(baseDir), JSON.stringify(buildSupportManifest(baseDir), null, 2), "utf8");
|
fs.writeFileSync(getAiManifestPath(baseDir), JSON.stringify(buildAiManifest(baseDir), null, 2), "utf8");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.warn(`Debug-Server: Support-Manifest konnte nicht geschrieben werden: ${String(error)}`);
|
logger.warn(`Debug-Server: KI-Support-Datei konnte nicht geschrieben werden: ${String(error)}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -409,7 +421,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;
|
||||||
writeSupportManifest(baseDir);
|
writeAiManifest(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 });
|
||||||
@ -596,7 +608,7 @@ function handleRequest(req: http.IncomingMessage, res: http.ServerResponse): voi
|
|||||||
port: bindPort
|
port: bindPort
|
||||||
},
|
},
|
||||||
supportFiles: {
|
supportFiles: {
|
||||||
supportManifest: getSupportManifestPath(),
|
aiManifest: getAiManifestPath(),
|
||||||
traceConfig: getTraceConfigPath(),
|
traceConfig: getTraceConfigPath(),
|
||||||
traceLog: getTraceLogPath()
|
traceLog: getTraceLogPath()
|
||||||
},
|
},
|
||||||
@ -1061,7 +1073,7 @@ function openServerSocket(): Promise<void> {
|
|||||||
bindPort = getPort(runtimeBaseDir);
|
bindPort = getPort(runtimeBaseDir);
|
||||||
bindHost = getHost(runtimeBaseDir);
|
bindHost = getHost(runtimeBaseDir);
|
||||||
allowlist = loadAllowlist(runtimeBaseDir);
|
allowlist = loadAllowlist(runtimeBaseDir);
|
||||||
writeSupportManifest(runtimeBaseDir);
|
writeAiManifest(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();
|
||||||
@ -1165,7 +1177,7 @@ export function clearDebugToken(): void {
|
|||||||
} catch {
|
} catch {
|
||||||
}
|
}
|
||||||
authToken = "";
|
authToken = "";
|
||||||
writeSupportManifest(runtimeBaseDir);
|
writeAiManifest(runtimeBaseDir);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function stopDebugServer(): void {
|
export function stopDebugServer(): void {
|
||||||
|
|||||||
@ -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 SUPPORT_MANIFEST_FILE = "debug_support_manifest.json";
|
const AI_MANIFEST_FILE = "debug_ai_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, SUPPORT_MANIFEST_FILE),
|
path.join(baseDir, AI_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 supportManifestPath = path.join(baseDir, SUPPORT_MANIFEST_FILE);
|
const aiManifestPath = path.join(baseDir, AI_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(supportManifestPath)) {
|
if (!fs.existsSync(aiManifestPath)) {
|
||||||
warnings.push("debug_support_manifest.json fehlt. App einmal neu starten, damit das Support-Manifest neu geschrieben wird.");
|
warnings.push("debug_ai_manifest.json fehlt. App einmal neu starten, damit die KI-Support-Datei 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,
|
||||||
supportManifestPath,
|
aiManifestPath,
|
||||||
supportManifestPresent: fs.existsSync(supportManifestPath),
|
aiManifestPresent: fs.existsSync(aiManifestPath),
|
||||||
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,
|
||||||
|
|||||||
@ -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.bestToken || "", next: next.bestToken || "", providers: ["bestdebrid"] },
|
{ prev: previous.bestDebridApiKey || "", next: next.bestDebridApiKey || "", 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,12 +11761,11 @@ export class DownloadManager extends EventEmitter {
|
|||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const stat = await fs.promises.stat(item.targetPath);
|
const stat = await fs.promises.stat(item.targetPath);
|
||||||
const totalBytes = item.totalBytes;
|
const minSize = expectedMinBytes(item.totalBytes, isLargeBinaryLikePath(item.fileName || item.targetPath));
|
||||||
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 = totalBytes != null
|
const suspiciousPreallocFootprint = item.totalBytes != null
|
||||||
&& totalBytes > 0
|
&& item.totalBytes > 0
|
||||||
&& stat.size >= minSize
|
&& stat.size >= minSize
|
||||||
&& stat.size > persistedBytes + preallocMismatchThreshold;
|
&& stat.size > persistedBytes + preallocMismatchThreshold;
|
||||||
if (stat.size >= minSize) {
|
if (stat.size >= minSize) {
|
||||||
@ -11778,7 +11777,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(totalBytes)})`
|
`(stat=${humanSize(stat.size)}, bytes=${humanSize(persistedBytes)}, total=${humanSize(item.totalBytes)})`
|
||||||
);
|
);
|
||||||
try {
|
try {
|
||||||
if (persistedBytes > 0) {
|
if (persistedBytes > 0) {
|
||||||
@ -11791,8 +11790,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 = totalBytes > 0
|
item.progressPercent = item.totalBytes > 0
|
||||||
? Math.max(0, Math.min(99, Math.floor((persistedBytes / totalBytes) * 100)))
|
? Math.max(0, Math.min(99, Math.floor((persistedBytes / item.totalBytes) * 100)))
|
||||||
: 0;
|
: 0;
|
||||||
item.speedBps = 0;
|
item.speedBps = 0;
|
||||||
item.fullStatus = "Wartet (Auto-Recovery: pre-alloc)";
|
item.fullStatus = "Wartet (Auto-Recovery: pre-alloc)";
|
||||||
|
|||||||
@ -108,13 +108,6 @@ 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;
|
||||||
@ -818,8 +811,7 @@ function withExtractionErrorHints(
|
|||||||
return enhanced;
|
return enhanced;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function classifyExtractionError(errorText: unknown): ExtractErrorCategory {
|
export function classifyExtractionError(errorText: string): 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";
|
||||||
@ -827,7 +819,7 @@ export function classifyExtractionError(errorText: unknown): ExtractErrorCategor
|
|||||||
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 (isUnsupportedArchiveFormatError(text)) return "unsupported_format";
|
if (text.includes("kein rar-archiv") || text.includes("not a rar archive") || text.includes("unsupported") || text.includes("unsupportedmethod")) 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";
|
||||||
}
|
}
|
||||||
@ -893,9 +885,7 @@ 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 {
|
||||||
@ -2566,14 +2556,6 @@ 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();
|
||||||
@ -3109,7 +3091,10 @@ 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) {
|
||||||
throw selectZipFallbackError(error, externalError);
|
if (isNoExtractorError(String(externalError)) || isUnsupportedArchiveFormatError(String(externalError))) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
throw externalError;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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://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://git.24-music.de https://ddownload.com https://ddl.to https://debrid-link.com"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@ -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,
|
||||||
backupIncludeRemoteDiagnostics: settings.backupIncludeRemoteDiagnostics !== undefined ? Boolean(settings.backupIncludeRemoteDiagnostics) : defaults.backupIncludeRemoteDiagnostics,
|
backupIncludeMcp: settings.backupIncludeMcp !== undefined ? Boolean(settings.backupIncludeMcp) : defaults.backupIncludeMcp,
|
||||||
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,
|
||||||
|
|||||||
@ -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 SUPPORT_MANIFEST_FILE = "debug_support_manifest.json";
|
const AI_MANIFEST_FILE = "debug_ai_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, SUPPORT_MANIFEST_FILE), `runtime/${SUPPORT_MANIFEST_FILE}`);
|
await addFileIfExists(zip, path.join(baseDir, AI_MANIFEST_FILE), `runtime/${AI_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 supportManifest = await safeReadJson(path.join(baseDir, SUPPORT_MANIFEST_FILE));
|
const aiManifest = await safeReadJson(path.join(baseDir, AI_MANIFEST_FILE));
|
||||||
if (supportManifest) {
|
if (aiManifest) {
|
||||||
addJson(zip, "overview/support-manifest.json", supportManifest);
|
addJson(zip, "overview/ai-manifest.json", aiManifest);
|
||||||
}
|
}
|
||||||
|
|
||||||
return zip.toBuffer();
|
return zip.toBuffer();
|
||||||
|
|||||||
@ -37,7 +37,9 @@ type ExpectedDigest = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const UPDATE_SOURCES: UpdateSource[] = [
|
const UPDATE_SOURCES: UpdateSource[] = [
|
||||||
{ name: "github", webBase: "https://github.com", apiBase: "https://api.github.com" }
|
{ name: "git24", webBase: "https://git.24-music.de", apiBase: "https://git.24-music.de/api/v1" },
|
||||||
|
{ 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];
|
||||||
@ -80,9 +82,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)\//i, "")
|
.replace(/^https?:\/\/(?:www\.)?(?:codeberg\.org|github\.com|git\.24-music\.de)\//i, "")
|
||||||
.replace(/^(?:www\.)?(?:codeberg\.org|github\.com)\//i, "")
|
.replace(/^(?:www\.)?(?:codeberg\.org|github\.com|git\.24-music\.de)\//i, "")
|
||||||
.replace(/^git@(?:codeberg\.org|github\.com):/i, "")
|
.replace(/^git@(?:codeberg\.org|github\.com|git\.24-music\.de):/i, "")
|
||||||
.replace(/\.git$/i, "")
|
.replace(/\.git$/i, "")
|
||||||
.replace(/^\/+|\/+$/g, "");
|
.replace(/^\/+|\/+$/g, "");
|
||||||
const parts = cleaned.split("/").filter(Boolean);
|
const parts = cleaned.split("/").filter(Boolean);
|
||||||
@ -104,6 +106,7 @@ 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;
|
||||||
|
|||||||
@ -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}`,
|
||||||
`Support-Manifest: ${setup.supportManifestPresent ? "vorhanden" : "fehlt"} (${setup.supportManifestPath})`,
|
`KI-Manifest: ${setup.aiManifestPresent ? "vorhanden" : "fehlt"} (${setup.aiManifestPath})`,
|
||||||
`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, backupIncludeRemoteDiagnostics: false,
|
theme: "dark", collapseNewPackages: true, historyRetentionMode: "permanent", historyMaxEntries: 500, historyMaxAgeDays: 0, autoSortPackagesByProgress: true, autoSkipExtracted: false, hideExtractedItems: true, confirmDeleteSelection: true, backupIncludeDownloads: false, backupIncludeMcp: 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 …</span></button>
|
<button className="menu-dropdown-item" onClick={() => { void onOpenRemoteDiagnostics(); }}><span>Ferndiagnose (MCP) …</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.backupIncludeRemoteDiagnostics} onChange={(e) => setBool("backupIncludeRemoteDiagnostics", e.target.checked)} /> Ferndiagnose-Einstellungen mitsichern</label>
|
<label className="toggle-line"><input type="checkbox" checked={settingsDraft.backupIncludeMcp} onChange={(e) => setBool("backupIncludeMcp", 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</h3>
|
<h3>Ferndiagnose (MCP)</h3>
|
||||||
<p>Ermöglicht einer vertrauenswürdigen Support-Stelle den geschützten Lesezugriff auf Status, Logs und Fehler. Der Verbindungscode enthält das Zugriffstoken und ist wie ein Passwort zu behandeln.</p>
|
<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>
|
||||||
<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>
|
||||||
|
|||||||
@ -134,7 +134,7 @@ export interface AppSettings {
|
|||||||
hideExtractedItems: boolean;
|
hideExtractedItems: boolean;
|
||||||
confirmDeleteSelection: boolean;
|
confirmDeleteSelection: boolean;
|
||||||
backupIncludeDownloads: boolean;
|
backupIncludeDownloads: boolean;
|
||||||
backupIncludeRemoteDiagnostics: boolean;
|
backupIncludeMcp: 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;
|
||||||
supportManifestPath: string;
|
aiManifestPath: string;
|
||||||
supportManifestPresent: boolean;
|
aiManifestPresent: boolean;
|
||||||
traceConfigPath: string | null;
|
traceConfigPath: string | null;
|
||||||
traceLogPath: string | null;
|
traceLogPath: string | null;
|
||||||
traceEnabled: boolean;
|
traceEnabled: boolean;
|
||||||
|
|||||||
479
tasks/audit-loop.md
Normal file
479
tasks/audit-loop.md
Normal file
@ -0,0 +1,479 @@
|
|||||||
|
# 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.
|
||||||
164
tasks/entscheidungen-offen.md
Normal file
164
tasks/entscheidungen-offen.md
Normal file
@ -0,0 +1,164 @@
|
|||||||
|
# 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.)
|
||||||
361
tasks/lessons.md
Normal file
361
tasks/lessons.md
Normal file
@ -0,0 +1,361 @@
|
|||||||
|
# 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 7–66s). 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.
|
||||||
104
tasks/plan-german-audio-track.md
Normal file
104
tasks/plan-german-audio-track.md
Normal file
@ -0,0 +1,104 @@
|
|||||||
|
# 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 **togglebarer 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.
|
||||||
42
tasks/todo.md
Normal file
42
tasks/todo.md
Normal file
@ -0,0 +1,42 @@
|
|||||||
|
# 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.
|
||||||
@ -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, resolveRemoteDiagnosticsRestore, BackupRemoteDiagnostics } from "../src/main/backup-payload";
|
import { buildBackupPayload, resolveMcpRemoteRestore, BackupMcpRemote } 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>, remoteDiagnostics?: BackupRemoteDiagnostics) {
|
function input(settingsOverride: Partial<AppSettings>, mcpRemote?: BackupMcpRemote) {
|
||||||
return {
|
return {
|
||||||
settings: { ...defaultSettings(), ...settingsOverride } as AppSettings,
|
settings: { ...defaultSettings(), ...settingsOverride } as AppSettings,
|
||||||
appVersion: "1.7.233",
|
appVersion: "1.7.224",
|
||||||
exportedAt: "2026-08-01T00:00:00.000Z",
|
exportedAt: "2026-06-19T00:00:00.000Z",
|
||||||
session: {} as unknown as SessionState,
|
session: {} as unknown as SessionState,
|
||||||
history: [],
|
history: [],
|
||||||
remoteDiagnostics
|
mcpRemote
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -74,96 +74,77 @@ afterEach(() => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("backup remoteDiagnostics export gating", () => {
|
describe("backup mcpRemote export gating", () => {
|
||||||
it("includes remoteDiagnostics when backupIncludeRemoteDiagnostics is on", () => {
|
it("includes mcpRemote when backupIncludeMcp is on", () => {
|
||||||
const settings = {
|
const section: BackupMcpRemote = { allowlist: ["10.0.0.5", "192.168.1.0/24"], port: 9999, hostMode: "network" };
|
||||||
...defaultSettings(),
|
const payload = buildBackupPayload(input({ backupIncludeMcp: true }, section));
|
||||||
backupIncludeRemoteDiagnostics: true
|
expect(payload.mcpRemote).toEqual(section);
|
||||||
};
|
|
||||||
const payload = buildBackupPayload({
|
|
||||||
settings,
|
|
||||||
appVersion: "1.7.233",
|
|
||||||
exportedAt: "2026-08-01T00:00:00.000Z",
|
|
||||||
session: {} as unknown as SessionState,
|
|
||||||
history: [],
|
|
||||||
remoteDiagnostics: {
|
|
||||||
allowlist: ["192.0.2.0/24"],
|
|
||||||
port: 8976,
|
|
||||||
hostMode: "network"
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(payload.remoteDiagnostics).toEqual({
|
it("omits mcpRemote when the toggle is off even if a section is provided", () => {
|
||||||
allowlist: ["192.0.2.0/24"],
|
const payload = buildBackupPayload(input({ backupIncludeMcp: false }, { allowlist: ["10.0.0.5"], port: 9868, hostMode: "network" }));
|
||||||
port: 8976,
|
expect(payload.mcpRemote).toBeUndefined();
|
||||||
hostMode: "network"
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("omits remoteDiagnostics when the toggle is off even if a section is provided", () => {
|
it("omits mcpRemote when toggle on but no section gathered", () => {
|
||||||
const payload = buildBackupPayload(input({ backupIncludeRemoteDiagnostics: false }, { allowlist: ["10.0.0.5"], port: 9868, hostMode: "network" }));
|
const payload = buildBackupPayload(input({ backupIncludeMcp: true }, undefined));
|
||||||
expect(payload.remoteDiagnostics).toBeUndefined();
|
expect(payload.mcpRemote).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("omits remoteDiagnostics when toggle on but no section gathered", () => {
|
it("the mcpRemote section carries ONLY allowlist/port/hostMode (no token, publicHost, name)", () => {
|
||||||
const payload = buildBackupPayload(input({ backupIncludeRemoteDiagnostics: true }, undefined));
|
const payload = buildBackupPayload(input({ backupIncludeMcp: true }, { allowlist: ["10.0.0.5"], port: 9868, hostMode: "network" }));
|
||||||
expect(payload.remoteDiagnostics).toBeUndefined();
|
expect(payload.mcpRemote && Object.keys(payload.mcpRemote).sort()).toEqual(["allowlist", "hostMode", "port"]);
|
||||||
});
|
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("backupIncludeRemoteDiagnostics settings persistence", () => {
|
describe("backupIncludeMcp settings persistence", () => {
|
||||||
it("normalizeSettings preserves backupIncludeRemoteDiagnostics (the toggle survives save/load)", () => {
|
it("normalizeSettings preserves backupIncludeMcp (the toggle survives save/load)", () => {
|
||||||
expect(normalizeSettings({ backupIncludeRemoteDiagnostics: true } as unknown as AppSettings).backupIncludeRemoteDiagnostics).toBe(true);
|
expect(normalizeSettings({ backupIncludeMcp: true } as unknown as AppSettings).backupIncludeMcp).toBe(true);
|
||||||
expect(normalizeSettings({ backupIncludeRemoteDiagnostics: false } as unknown as AppSettings).backupIncludeRemoteDiagnostics).toBe(false);
|
expect(normalizeSettings({ backupIncludeMcp: false } as unknown as AppSettings).backupIncludeMcp).toBe(false);
|
||||||
expect(normalizeSettings({} as unknown as AppSettings).backupIncludeRemoteDiagnostics).toBe(false);
|
expect(normalizeSettings({} as unknown as AppSettings).backupIncludeMcp).toBe(false);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("resolveRemoteDiagnosticsRestore", () => {
|
describe("resolveMcpRemoteRestore", () => {
|
||||||
it("maps network + non-empty allowlist to 0.0.0.0", () => {
|
it("maps network + non-empty allowlist to 0.0.0.0", () => {
|
||||||
expect(resolveRemoteDiagnosticsRestore({ allowlist: ["10.0.0.5"], port: 9868, hostMode: "network" }))
|
expect(resolveMcpRemoteRestore({ 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(resolveRemoteDiagnosticsRestore({ allowlist: [], port: 9868, hostMode: "network" })?.host).toBe("127.0.0.1");
|
expect(resolveMcpRemoteRestore({ 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(resolveRemoteDiagnosticsRestore({ allowlist: ["10.0.0.5"], port: 9868, hostMode: "local" })?.host).toBe("127.0.0.1");
|
expect(resolveMcpRemoteRestore({ 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(resolveRemoteDiagnosticsRestore({ allowlist: ["10.0.0.5"], port: 80, hostMode: "network" })?.port).toBeUndefined();
|
expect(resolveMcpRemoteRestore({ allowlist: ["10.0.0.5"], port: 80, hostMode: "network" })?.port).toBeUndefined();
|
||||||
expect(resolveRemoteDiagnosticsRestore({ allowlist: ["10.0.0.5"], port: 70000, hostMode: "network" })?.port).toBeUndefined();
|
expect(resolveMcpRemoteRestore({ allowlist: ["10.0.0.5"], port: 70000, hostMode: "network" })?.port).toBeUndefined();
|
||||||
expect(resolveRemoteDiagnosticsRestore({ allowlist: ["10.0.0.5"], port: 9868.5, hostMode: "network" })?.port).toBeUndefined();
|
expect(resolveMcpRemoteRestore({ 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 = resolveRemoteDiagnosticsRestore({ allowlist: ["10.0.0.5", "", " ", 5, null, " 8.8.8.8 "], port: 9868, hostMode: "network" });
|
const r = resolveMcpRemoteRestore({ 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(resolveRemoteDiagnosticsRestore(undefined)).toBeNull();
|
expect(resolveMcpRemoteRestore(undefined)).toBeNull();
|
||||||
expect(resolveRemoteDiagnosticsRestore(null)).toBeNull();
|
expect(resolveMcpRemoteRestore(null)).toBeNull();
|
||||||
expect(resolveRemoteDiagnosticsRestore("x")).toBeNull();
|
expect(resolveMcpRemoteRestore("x")).toBeNull();
|
||||||
expect(resolveRemoteDiagnosticsRestore({})).toBeNull();
|
expect(resolveMcpRemoteRestore({})).toBeNull();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("backup remoteDiagnostics live restore round-trip", () => {
|
describe("backup mcpRemote 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-backup-remote-"));
|
const baseDir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-bkmcp-"));
|
||||||
tempDirs.push(baseDir);
|
tempDirs.push(baseDir);
|
||||||
const startPort = await getFreePort();
|
const startPort = await getFreePort();
|
||||||
const restorePort = await getFreePort();
|
const restorePort = await getFreePort();
|
||||||
@ -176,11 +157,11 @@ describe("backup remoteDiagnostics live restore round-trip", () => {
|
|||||||
expect(getDebugAllowlist()).toEqual([]);
|
expect(getDebugAllowlist()).toEqual([]);
|
||||||
|
|
||||||
const payload = buildBackupPayload(input(
|
const payload = buildBackupPayload(input(
|
||||||
{ backupIncludeRemoteDiagnostics: true },
|
{ backupIncludeMcp: 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 = resolveRemoteDiagnosticsRestore(payload.remoteDiagnostics);
|
const restore = resolveMcpRemoteRestore(payload.mcpRemote);
|
||||||
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();
|
||||||
@ -197,7 +178,7 @@ describe("backup remoteDiagnostics 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-backup-remote2-"));
|
const baseDir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-bkmcp2-"));
|
||||||
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");
|
||||||
@ -207,7 +188,7 @@ describe("backup remoteDiagnostics 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 = resolveRemoteDiagnosticsRestore({ allowlist: ["198.51.100.9"], port: 9100, hostMode: "network" });
|
const restore = resolveMcpRemoteRestore({ 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");
|
||||||
@ -1,21 +1,9 @@
|
|||||||
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("encodes a directly decodable payload", () => {
|
it("round-trips through the bridge decoder", () => {
|
||||||
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);
|
||||||
|
|||||||
@ -55,21 +55,6 @@ 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();
|
||||||
@ -355,30 +340,26 @@ describe("debug-server", () => {
|
|||||||
expect(payload.history?.total).toBe(1);
|
expect(payload.history?.total).toBe(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("writes a machine-readable support manifest into the runtime folder", async () => {
|
it("writes a machine-readable AI support manifest into the runtime folder", async () => {
|
||||||
const fixture = await createFixture();
|
const fixture = await createFixture();
|
||||||
const manifestPath = path.join(fixture.baseDir, "debug_support_manifest.json");
|
const manifestPath = path.join(fixture.baseDir, "debug_ai_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.purpose).toBe("Machine-readable manifest for support tooling and remote troubleshooting.");
|
expect(manifest.appVersion).toBeTruthy();
|
||||||
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.remoteAccessRequirements).toContain("A reachable server IP or DNS name.");
|
expect(manifest.quickstart?.[1]).toContain("server IP");
|
||||||
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(manifest.endpoints?.some((entry: Record<string, any>) => entry.path === "/logs/main")).toBe(true);
|
expect(JSON.stringify(manifest)).not.toContain(fixture.token);
|
||||||
|
|
||||||
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?.supportManifest).toBe(manifestPath);
|
expect(metaPayload.supportFiles?.aiManifest).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());
|
||||||
@ -398,9 +379,7 @@ 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.supportManifestPresent).toBe(true);
|
expect(payload.aiManifestPresent).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);
|
||||||
@ -532,7 +511,6 @@ 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");
|
||||||
@ -548,10 +526,7 @@ 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_support_manifest.json");
|
expect(entries).toContain("runtime/debug_ai_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");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@ -166,25 +166,6 @@ 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);
|
||||||
@ -4878,76 +4859,6 @@ 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);
|
||||||
|
|||||||
@ -12,8 +12,6 @@ import {
|
|||||||
archiveFilenamePasswords,
|
archiveFilenamePasswords,
|
||||||
detectArchiveSignature,
|
detectArchiveSignature,
|
||||||
classifyExtractionError,
|
classifyExtractionError,
|
||||||
ExtractionError,
|
|
||||||
selectZipFallbackError,
|
|
||||||
shouldSerialRetryParallelFailures,
|
shouldSerialRetryParallelFailures,
|
||||||
findArchiveCandidates,
|
findArchiveCandidates,
|
||||||
orderExtractorCandidatesForArchive,
|
orderExtractorCandidatesForArchive,
|
||||||
@ -25,6 +23,7 @@ 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";
|
||||||
@ -40,6 +39,11 @@ 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", () => {
|
||||||
@ -589,25 +593,31 @@ describe("extractor", () => {
|
|||||||
expect(targets.has(r02)).toBe(true);
|
expect(targets.has(r02)).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("preserves the original ZIP size guard error when no external extractor is available", () => {
|
it("keeps original ZIP size guard error when external fallback is unavailable", async () => {
|
||||||
const internalError = new Error("ZIP-Eintrag zu groß für sichere Speicher-Extraktion");
|
process.env.RD_ZIP_ENTRY_MEMORY_LIMIT_MB = "8";
|
||||||
const externalError = new ExtractionError("Kein nativer Entpacker gefunden", "no_extractor");
|
|
||||||
|
|
||||||
expect(selectZipFallbackError(internalError, externalError)).toBe(internalError);
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-extract-"));
|
||||||
|
tempDirs.push(root);
|
||||||
|
const packageDir = path.join(root, "pkg");
|
||||||
|
const targetDir = path.join(root, "out");
|
||||||
|
fs.mkdirSync(packageDir, { recursive: true });
|
||||||
|
|
||||||
|
const zipPath = path.join(packageDir, "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);
|
||||||
it("preserves the original ZIP size guard error for an unsupported external archive format", () => {
|
expect(result.failed).toBe(1);
|
||||||
const internalError = new Error("ZIP-Eintrag zu groß für sichere Speicher-Extraktion");
|
expect(String(result.lastError)).toMatch(/ZIP-Eintrag.*groß/i);
|
||||||
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 () => {
|
||||||
@ -1037,12 +1047,6 @@ 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");
|
||||||
|
|||||||
@ -1,391 +0,0 @@
|
|||||||
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);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@ -7,7 +7,6 @@ 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)) {
|
||||||
@ -37,8 +36,6 @@ 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);
|
||||||
@ -51,10 +48,6 @@ 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");
|
||||||
|
|||||||
@ -46,13 +46,13 @@ afterEach(() => {
|
|||||||
|
|
||||||
describe("update", () => {
|
describe("update", () => {
|
||||||
it("normalizes update repo input", () => {
|
it("normalizes update repo input", () => {
|
||||||
expect(normalizeUpdateRepo("")).toBe("Sucukdeluxe/multi-debrid-downloader");
|
expect(normalizeUpdateRepo("")).toBe("Administrator/real-debrid-downloader");
|
||||||
expect(normalizeUpdateRepo("owner/repo")).toBe("owner/repo");
|
expect(normalizeUpdateRepo("owner/repo")).toBe("owner/repo");
|
||||||
expect(normalizeUpdateRepo("https://github.com/owner/repo")).toBe("owner/repo");
|
expect(normalizeUpdateRepo("https://codeberg.org/owner/repo")).toBe("owner/repo");
|
||||||
expect(normalizeUpdateRepo("https://www.github.com/owner/repo")).toBe("owner/repo");
|
expect(normalizeUpdateRepo("https://www.codeberg.org/owner/repo")).toBe("owner/repo");
|
||||||
expect(normalizeUpdateRepo("https://github.com/owner/repo/releases/tag/v1.2.3")).toBe("owner/repo");
|
expect(normalizeUpdateRepo("https://codeberg.org/owner/repo/releases/tag/v1.2.3")).toBe("owner/repo");
|
||||||
expect(normalizeUpdateRepo("github.com/owner/repo.git")).toBe("owner/repo");
|
expect(normalizeUpdateRepo("codeberg.org/owner/repo.git")).toBe("owner/repo");
|
||||||
expect(normalizeUpdateRepo("git@github.com:owner/repo.git")).toBe("owner/repo");
|
expect(normalizeUpdateRepo("git@codeberg.org: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://github.com/owner/repo/releases/tag/v1.0.0",
|
html_url: "https://git.24-music.de/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://github.com/owner/repo/releases");
|
const result = await checkGitHubUpdate("https://git.24-music.de/owner/repo/releases");
|
||||||
expect(requestedUrl).toBe("https://api.github.com/repos/owner/repo/releases/latest");
|
expect(requestedUrl).toBe("https://git.24-music.de/api/v1/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://github.com/owner/repo/releases/tag/v9.9.9",
|
html_url: "https://codeberg.org/owner/repo/releases/tag/v9.9.9",
|
||||||
assets: [
|
assets: [
|
||||||
{
|
{
|
||||||
name: "Real-Debrid-Downloader-9.9.9-portable.exe",
|
name: "Real-Debrid-Downloader 9.9.9.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://github.com/owner/repo/releases/tag/v9.9.9",
|
releaseUrl: "https://codeberg.org/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://github.com/owner/repo/releases/tag/v9.9.9",
|
releaseUrl: "https://codeberg.org/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://github.com/owner/repo/releases/tag/v9.9.9",
|
releaseUrl: "https://codeberg.org/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://github.com/owner/repo/releases/tag/v9.9.9",
|
releaseUrl: "https://codeberg.org/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://github.com/owner/repo/releases/tag/v9.9.9",
|
releaseUrl: "https://codeberg.org/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://github.com/owner/repo/releases/tag/v9.9.9",
|
releaseUrl: "https://codeberg.org/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://github.com/owner/repo/releases/tag/v9.9.9",
|
releaseUrl: "https://codeberg.org/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://github.com/owner/repo/releases/tag/v9.9.9",
|
releaseUrl: "https://codeberg.org/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://github.com/owner/repo/tree/main/src")).toBe("owner/repo");
|
expect(normalizeUpdateRepo("https://codeberg.org/owner/repo/tree/main/src")).toBe("owner/repo");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("handles ssh-style git URLs", () => {
|
it("handles ssh-style git URLs", () => {
|
||||||
expect(normalizeUpdateRepo("git@github.com:user/project.git")).toBe("user/project");
|
expect(normalizeUpdateRepo("git@codeberg.org:user/project.git")).toBe("user/project");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns default for malformed inputs", () => {
|
it("returns default for malformed inputs", () => {
|
||||||
expect(normalizeUpdateRepo("just-one-part")).toBe("Sucukdeluxe/multi-debrid-downloader");
|
expect(normalizeUpdateRepo("just-one-part")).toBe("Administrator/real-debrid-downloader");
|
||||||
expect(normalizeUpdateRepo(" ")).toBe("Sucukdeluxe/multi-debrid-downloader");
|
expect(normalizeUpdateRepo(" ")).toBe("Administrator/real-debrid-downloader");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("rejects traversal-like owner or repo segments", () => {
|
it("rejects traversal-like owner or repo segments", () => {
|
||||||
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("owner/../repo")).toBe("Administrator/real-debrid-downloader");
|
||||||
expect(normalizeUpdateRepo("https://github.com/owner/../../repo")).toBe("Sucukdeluxe/multi-debrid-downloader");
|
expect(normalizeUpdateRepo("https://codeberg.org/owner/../../repo")).toBe("Administrator/real-debrid-downloader");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("handles www prefix", () => {
|
it("handles www prefix", () => {
|
||||||
expect(normalizeUpdateRepo("https://www.github.com/owner/repo")).toBe("owner/repo");
|
expect(normalizeUpdateRepo("https://www.codeberg.org/owner/repo")).toBe("owner/repo");
|
||||||
expect(normalizeUpdateRepo("www.github.com/owner/repo")).toBe("owner/repo");
|
expect(normalizeUpdateRepo("www.codeberg.org/owner/repo")).toBe("owner/repo");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
62
tools/rd-diagnostics-mcp/README.md
Normal file
62
tools/rd-diagnostics-mcp/README.md
Normal file
@ -0,0 +1,62 @@
|
|||||||
|
# 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 5–6 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
|
||||||
|
```
|
||||||
1172
tools/rd-diagnostics-mcp/package-lock.json
generated
Normal file
1172
tools/rd-diagnostics-mcp/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
18
tools/rd-diagnostics-mcp/package.json
Normal file
18
tools/rd-diagnostics-mcp/package.json
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
{
|
||||||
|
"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"
|
||||||
|
}
|
||||||
|
}
|
||||||
332
tools/rd-diagnostics-mcp/src/bridge.mjs
Normal file
332
tools/rd-diagnostics-mcp/src/bridge.mjs
Normal file
@ -0,0 +1,332 @@
|
|||||||
|
#!/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);
|
||||||
|
});
|
||||||
20
tools/rd-diagnostics-mcp/src/code.d.mts
Normal file
20
tools/rd-diagnostics-mcp/src/code.d.mts
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
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;
|
||||||
56
tools/rd-diagnostics-mcp/src/code.mjs
Normal file
56
tools/rd-diagnostics-mcp/src/code.mjs
Normal file
@ -0,0 +1,56 @@
|
|||||||
|
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) : ""
|
||||||
|
};
|
||||||
|
}
|
||||||
22
tools/rd-diagnostics-mcp/src/gen-code.mjs
Normal file
22
tools/rd-diagnostics-mcp/src/gen-code.mjs
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
#!/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");
|
||||||
63
tools/rd-diagnostics-mcp/src/http.mjs
Normal file
63
tools/rd-diagnostics-mcp/src/http.mjs
Normal file
@ -0,0 +1,63 @@
|
|||||||
|
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();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
175
tools/rd-diagnostics-mcp/test/harness.mjs
Normal file
175
tools/rd-diagnostics-mcp/test/harness.mjs
Normal file
@ -0,0 +1,175 @@
|
|||||||
|
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);
|
||||||
|
});
|
||||||
@ -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", "scripts/tests/**/*.test.ts"],
|
include: ["tests/**/*.test.ts"],
|
||||||
globals: true
|
globals: true
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user