Compare commits
No commits in common. "v2.0.1" 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/
|
||||
dist/
|
||||
release/
|
||||
.vite/
|
||||
coverage/
|
||||
*.log
|
||||
*.log.old
|
||||
*.bak
|
||||
*.spec
|
||||
|
||||
rd_downloader_config.json
|
||||
rd_downloader.log
|
||||
rd_download_manifest.json
|
||||
_update_staging/
|
||||
apply_update.cmd
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
.claude/
|
||||
.github/
|
||||
CHANGELOG.md
|
||||
|
||||
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.
|
||||
607
README.md
607
README.md
@ -1,308 +1,307 @@
|
||||
# Multi Debrid Downloader
|
||||
|
||||
Desktop downloader for Windows with package-based queue management, multi-provider fallback, automatic extraction, auto-rename, provider statistics, and built-in updates.
|
||||
|
||||

|
||||

|
||||

|
||||

|
||||

|
||||
|
||||
## Why this tool?
|
||||
|
||||
- JDownloader-style workflow with packages, progress, extraction, history, and clean post-processing.
|
||||
- Multiple debrid accounts in one app, including provider order, automatic fallback, and per-hoster routing.
|
||||
- Built for large queues with session persistence, retries, reconnect handling, resume support, and integrity checks.
|
||||
- Includes an in-app updater for releases published on GitHub.
|
||||
|
||||
## Supported providers
|
||||
|
||||
- AllDebrid API
|
||||
- AllDebrid Web via browser login
|
||||
- BestDebrid API
|
||||
- BestDebrid Web via cookie import
|
||||
- Debrid-Link with multi-key support
|
||||
- DDownload login
|
||||
- 1fichier API
|
||||
- LinkSnappy login
|
||||
- Mega-Debrid API
|
||||
- Mega-Debrid Web
|
||||
- Real-Debrid
|
||||
|
||||
## Core features
|
||||
|
||||
### Queue and package handling
|
||||
|
||||
- Package-based queue with item status, retries, ETA, speed, provider, and account label.
|
||||
- Start, pause, stop, cancel, reset, rename, and delete for packages and items.
|
||||
- Ctrl+Click multi-select and bulk actions.
|
||||
- Queue backup import/export as JSON.
|
||||
- Context-menu export for selected packages or selected items as structured TXT re-import files.
|
||||
- Duplicate handling when adding links: keep, skip, or overwrite.
|
||||
- Optional start scheduling for a specific time.
|
||||
- Session recovery after restart with optional auto-resume.
|
||||
- Optional auto-sorting by progress.
|
||||
|
||||
### Link collection
|
||||
|
||||
- Paste links directly into the collector.
|
||||
- Import `.txt` export files that preserve package names and optional per-file names.
|
||||
- Clipboard watcher with automatic link detection.
|
||||
- `.dlc` import via file picker and drag-and-drop.
|
||||
- Drag-and-drop of plain links, `.txt` export files, and supported container files.
|
||||
|
||||
### Provider routing and fallback
|
||||
|
||||
- Configurable provider order with primary, secondary, and tertiary fallback.
|
||||
- Optional automatic provider fallback on unrestrict/download failures.
|
||||
- Per-hoster routing override, so specific hosters can always use a specific provider.
|
||||
- Providers can be disabled without deleting stored account data.
|
||||
- Daily traffic limits per provider.
|
||||
- Debrid-Link per-key daily limits and per-key daily usage tracking.
|
||||
|
||||
### Accounts and provider tools
|
||||
|
||||
- Central Accounts view with account type, status, info, access data, and actions.
|
||||
- BestDebrid cookie import directly from a Netscape cookies file.
|
||||
- AllDebrid browser-login flow and in-app Rapidgator host status display.
|
||||
- Debrid-Link multi-key management with optional detailed line-by-line key display.
|
||||
- Debrid-Link API-key statistics popup with per-key Rapidgator traffic quota, link quota, reset, activate/deactivate, and click-to-copy masked keys.
|
||||
- Reset button for stored account column widths in the Accounts table.
|
||||
|
||||
### Download engine
|
||||
|
||||
- Parallel downloads with resumable transfers when supported.
|
||||
- Reconnect handling with configurable wait time.
|
||||
- Circuit-breaker style cooldown and retry handling for provider issues.
|
||||
- Global speed limit or per-download speed limit mode.
|
||||
- Bandwidth schedules with time windows and speed caps.
|
||||
- Live bandwidth chart and session statistics.
|
||||
- Persistent all-time download counter.
|
||||
|
||||
### Extraction and post-processing
|
||||
|
||||
- Automatic extraction after download.
|
||||
- Extraction can continue even when the session is stopped or after app restart.
|
||||
- Hybrid download + extract workflow.
|
||||
- Extraction backend using native tools by default, with JVM sidecar support available.
|
||||
- Supports common archive formats including RAR, ZIP, and 7z.
|
||||
- Nested extraction for archives found inside extracted output.
|
||||
- Conflict handling: overwrite, skip, rename, or ask.
|
||||
- Disk-space validation before extraction.
|
||||
- Package-scoped password reuse for multi-archive sets.
|
||||
- Optional cleanup of downloaded archives after extraction.
|
||||
- Optional cleanup of link artifacts and sample files after extraction.
|
||||
- Optional flat MKV collection folder after package completion.
|
||||
|
||||
### Auto-rename and media cleanup
|
||||
|
||||
- Auto-rename for extracted scene-style files based on folder/source naming.
|
||||
- Multi-episode token parsing.
|
||||
- Handles compact episode tokens like `s02e01` directly attached to the title.
|
||||
- Optional skip of already extracted packages on start.
|
||||
|
||||
### Integrity, history, and backup
|
||||
|
||||
- Optional integrity verification with `CRC32`, `MD5`, and `SHA1`.
|
||||
- Download history with package details, duration, size, provider, and target folder.
|
||||
- Backup export/import for restoring app state.
|
||||
- Persistent config, session, and history files in the Electron `userData` directory.
|
||||
|
||||
### UI and desktop integration
|
||||
|
||||
- Downloads, history, statistics, and settings tabs.
|
||||
- Progress bars for packages and single items.
|
||||
- Hoster/provider display showing both source and effective debrid account.
|
||||
- Minimize-to-tray support.
|
||||
- Dark/light theme setting.
|
||||
- Long path support on Windows.
|
||||
- Default startup window size of `1920x1080`.
|
||||
|
||||
## Installation
|
||||
|
||||
### Prebuilt releases
|
||||
|
||||
1. Download the latest installer or portable build from the releases page.
|
||||
2. Start the app.
|
||||
3. Add your provider credentials in `Settings > Accounts`.
|
||||
|
||||
Releases: [GitHub Releases](https://github.com/Sucukdeluxe/multi-debrid-downloader/releases)
|
||||
|
||||
### Build from source
|
||||
|
||||
Requirements:
|
||||
|
||||
- Node.js `20+`
|
||||
- npm
|
||||
- Windows `10/11`
|
||||
- Java Runtime `8+` for the optional JVM extraction backend
|
||||
- Optional native extraction tools: 7-Zip / WinRAR / UnRAR
|
||||
|
||||
```bash
|
||||
npm install
|
||||
npm run dev
|
||||
```
|
||||
|
||||
## NPM scripts
|
||||
|
||||
| Command | Description |
|
||||
| --- | --- |
|
||||
| `npm run dev` | Starts Vite, tsup watchers, and Electron in development mode |
|
||||
| `npm run build` | Builds main and renderer bundles |
|
||||
| `npm run start` | Starts the built app in production mode |
|
||||
# Multi Debrid Downloader
|
||||
|
||||
Desktop downloader for Windows with package-based queue management, multi-provider fallback, automatic extraction, auto-rename, provider statistics, and built-in updates.
|
||||
|
||||

|
||||

|
||||

|
||||

|
||||

|
||||
|
||||
## Why this tool?
|
||||
|
||||
- JDownloader-style workflow with packages, progress, extraction, history, and clean post-processing.
|
||||
- Multiple debrid accounts in one app, including provider order, automatic fallback, and per-hoster routing.
|
||||
- Built for large queues with session persistence, retries, reconnect handling, resume support, and integrity checks.
|
||||
- Includes an in-app updater for releases published on `git.24-music.de`.
|
||||
|
||||
## Supported providers
|
||||
|
||||
- AllDebrid API
|
||||
- AllDebrid Web via browser login
|
||||
- BestDebrid API
|
||||
- BestDebrid Web via cookie import
|
||||
- Debrid-Link with multi-key support
|
||||
- DDownload login
|
||||
- 1fichier API
|
||||
- LinkSnappy login
|
||||
- Mega-Debrid API
|
||||
- Mega-Debrid Web
|
||||
- Real-Debrid
|
||||
|
||||
## Core features
|
||||
|
||||
### Queue and package handling
|
||||
|
||||
- Package-based queue with item status, retries, ETA, speed, provider, and account label.
|
||||
- Start, pause, stop, cancel, reset, rename, and delete for packages and items.
|
||||
- Ctrl+Click multi-select and bulk actions.
|
||||
- Queue backup import/export as JSON.
|
||||
- Context-menu export for selected packages or selected items as structured TXT re-import files.
|
||||
- Duplicate handling when adding links: keep, skip, or overwrite.
|
||||
- Optional start scheduling for a specific time.
|
||||
- Session recovery after restart with optional auto-resume.
|
||||
- Optional auto-sorting by progress.
|
||||
|
||||
### Link collection
|
||||
|
||||
- Paste links directly into the collector.
|
||||
- Import `.txt` export files that preserve package names and optional per-file names.
|
||||
- Clipboard watcher with automatic link detection.
|
||||
- `.dlc` import via file picker and drag-and-drop.
|
||||
- Drag-and-drop of plain links, `.txt` export files, and supported container files.
|
||||
|
||||
### Provider routing and fallback
|
||||
|
||||
- Configurable provider order with primary, secondary, and tertiary fallback.
|
||||
- Optional automatic provider fallback on unrestrict/download failures.
|
||||
- Per-hoster routing override, so specific hosters can always use a specific provider.
|
||||
- Providers can be disabled without deleting stored account data.
|
||||
- Daily traffic limits per provider.
|
||||
- Debrid-Link per-key daily limits and per-key daily usage tracking.
|
||||
|
||||
### Accounts and provider tools
|
||||
|
||||
- Central Accounts view with account type, status, info, access data, and actions.
|
||||
- BestDebrid cookie import directly from a Netscape cookies file.
|
||||
- AllDebrid browser-login flow and in-app Rapidgator host status display.
|
||||
- Debrid-Link multi-key management with optional detailed line-by-line key display.
|
||||
- Debrid-Link API-key statistics popup with per-key Rapidgator traffic quota, link quota, reset, activate/deactivate, and click-to-copy masked keys.
|
||||
- Reset button for stored account column widths in the Accounts table.
|
||||
|
||||
### Download engine
|
||||
|
||||
- Parallel downloads with resumable transfers when supported.
|
||||
- Reconnect handling with configurable wait time.
|
||||
- Circuit-breaker style cooldown and retry handling for provider issues.
|
||||
- Global speed limit or per-download speed limit mode.
|
||||
- Bandwidth schedules with time windows and speed caps.
|
||||
- Live bandwidth chart and session statistics.
|
||||
- Persistent all-time download counter.
|
||||
|
||||
### Extraction and post-processing
|
||||
|
||||
- Automatic extraction after download.
|
||||
- Extraction can continue even when the session is stopped or after app restart.
|
||||
- Hybrid download + extract workflow.
|
||||
- Extraction backend using native tools by default, with JVM sidecar support available.
|
||||
- Supports common archive formats including RAR, ZIP, and 7z.
|
||||
- Nested extraction for archives found inside extracted output.
|
||||
- Conflict handling: overwrite, skip, rename, or ask.
|
||||
- Disk-space validation before extraction.
|
||||
- Package-scoped password reuse for multi-archive sets.
|
||||
- Optional cleanup of downloaded archives after extraction.
|
||||
- Optional cleanup of link artifacts and sample files after extraction.
|
||||
- Optional flat MKV collection folder after package completion.
|
||||
|
||||
### Auto-rename and media cleanup
|
||||
|
||||
- Auto-rename for extracted scene-style files based on folder/source naming.
|
||||
- Multi-episode token parsing.
|
||||
- Handles compact episode tokens like `s02e01` directly attached to the title.
|
||||
- Optional skip of already extracted packages on start.
|
||||
|
||||
### Integrity, history, and backup
|
||||
|
||||
- Optional integrity verification with `CRC32`, `MD5`, and `SHA1`.
|
||||
- Download history with package details, duration, size, provider, and target folder.
|
||||
- Backup export/import for restoring app state.
|
||||
- Persistent config, session, and history files in the Electron `userData` directory.
|
||||
|
||||
### UI and desktop integration
|
||||
|
||||
- Downloads, history, statistics, and settings tabs.
|
||||
- Progress bars for packages and single items.
|
||||
- Hoster/provider display showing both source and effective debrid account.
|
||||
- Minimize-to-tray support.
|
||||
- Dark/light theme setting.
|
||||
- Long path support on Windows.
|
||||
- Default startup window size of `1920x1080`.
|
||||
|
||||
## Installation
|
||||
|
||||
### Prebuilt releases
|
||||
|
||||
1. Download the latest installer or portable build from the releases page.
|
||||
2. Start the app.
|
||||
3. Add your provider credentials in `Settings > Accounts`.
|
||||
|
||||
Releases: [git.24-music.de Releases](https://git.24-music.de/Administrator/real-debrid-downloader/releases)
|
||||
|
||||
### Build from source
|
||||
|
||||
Requirements:
|
||||
|
||||
- Node.js `20+`
|
||||
- npm
|
||||
- Windows `10/11`
|
||||
- Java Runtime `8+` for the optional JVM extraction backend
|
||||
- Optional native extraction tools: 7-Zip / WinRAR / UnRAR
|
||||
|
||||
```bash
|
||||
npm install
|
||||
npm run dev
|
||||
```
|
||||
|
||||
## NPM scripts
|
||||
|
||||
| Command | Description |
|
||||
| --- | --- |
|
||||
| `npm run dev` | Starts Vite, tsup watchers, and Electron in development mode |
|
||||
| `npm run build` | Builds main and renderer bundles |
|
||||
| `npm run start` | Starts the built app in production mode |
|
||||
| `npm test` | Runs Vitest unit tests |
|
||||
| `npm run self-check` | Runs integrated self-checks |
|
||||
| `npm run verify:release -- --verify-archives --seven-zip <path-to-7z.exe>` | Verifies metadata, unpacked redistribution files, and the nested Setup/Portable payloads |
|
||||
| `npm run release:win` | Builds Windows installer and portable EXE |
|
||||
|
||||
## Typical workflow
|
||||
|
||||
1. Add one or more provider accounts in `Settings > Accounts`.
|
||||
2. Configure provider order, fallback, and optional hoster routing.
|
||||
3. Paste links or import `.dlc` files.
|
||||
4. Adjust package names, target folders, extraction, and cleanup settings if needed.
|
||||
5. Start the queue and monitor downloads, extraction, and provider status.
|
||||
6. Review history and statistics after completion.
|
||||
|
||||
## Link export format
|
||||
|
||||
Selected packages or items can be exported from the context menu as a structured text file. Re-importing that file restores the original package grouping, even if it only contains a subset of items from a larger package.
|
||||
|
||||
Example:
|
||||
|
||||
```txt
|
||||
# rd-link-export: 1
|
||||
# package: Dave Staffel 1
|
||||
# file: Dave.S01E01.rar
|
||||
https://example.com/e01
|
||||
# file: Dave.S01E02.rar
|
||||
https://example.com/e02
|
||||
```
|
||||
|
||||
Supported import sources:
|
||||
|
||||
- collector text input
|
||||
- `Datei importieren`
|
||||
- drag-and-drop of `.txt` and `.json`
|
||||
|
||||
The optional `# file:` marker preserves the original item name so the imported subset can be rebuilt with the same package name and per-item filename hints.
|
||||
|
||||
## Project structure
|
||||
|
||||
- `src/main` - Electron main process, download engine, provider clients, updater, storage
|
||||
- `src/preload` - secure IPC bridge
|
||||
- `src/renderer` - React UI
|
||||
- `src/shared` - shared types and IPC contracts
|
||||
- `tests` - unit and integration-style tests
|
||||
- `resources/extractor-jvm` - optional JVM extraction runtime
|
||||
- `scripts` - release and build helpers
|
||||
|
||||
## Data and logs
|
||||
|
||||
Runtime files are stored in Electron's `userData` directory, including:
|
||||
|
||||
- `rd_downloader_config.json`
|
||||
- `rd_session_state.json`
|
||||
- `rd_history.json`
|
||||
- `rd_downloader.log`
|
||||
- `audit.log`
|
||||
- `rename.log`
|
||||
- `debug_support_manifest.json`
|
||||
- `trace.log`
|
||||
- `trace_config.json`
|
||||
- `session-logs/session_*.txt`
|
||||
- `package-logs/package_*.txt`
|
||||
- `item-logs/item_*.txt`
|
||||
|
||||
`audit.log`, `rename.log`, and `trace.log` are rotated automatically. The current file is kept plus one `.old` backup, and outdated backups are purged automatically.
|
||||
|
||||
### Remote debug server
|
||||
|
||||
For headless or server-style troubleshooting, the app can expose a small authenticated HTTP debug API with live status and log tails.
|
||||
|
||||
Enable it by creating these files in the same runtime folder that contains `rd_downloader.log`:
|
||||
|
||||
- `debug_token.txt`
|
||||
Example: a long random token such as `rd-debug-please-change-me`
|
||||
- `debug_port.txt`
|
||||
Example: `9868`
|
||||
- `debug_host.txt` (optional)
|
||||
Default is `127.0.0.1`. Set `0.0.0.0` only if you really want remote access and protect it with firewall, VPN, or reverse proxy.
|
||||
|
||||
After startup, the app also writes `debug_support_manifest.json` into the same runtime folder. This file lists all available endpoints, the authentication method, related runtime files, and the requirements for remote access.
|
||||
|
||||
If you want extra support detail during a flaky or hard-to-reproduce issue, the app also maintains a `trace.log` plus `trace_config.json`. You can enable or disable the support trace from the app menu or remotely via the debug API. By default, the support trace now auto-disables again after 2 hours so it does not stay enabled forever by accident.
|
||||
|
||||
The app menu under `Hilfe` also includes a `Debug-Setup prüfen` action. It verifies the current host/port/token/support-manifest/trace setup locally and now also reports free disk space, current support-log sizes, and an estimated support-bundle size.
|
||||
|
||||
Available endpoints after restart:
|
||||
|
||||
- `GET /health`
|
||||
- `GET /meta`
|
||||
- `GET /debug/setup`
|
||||
- `GET /self-check`
|
||||
- `GET /host/diagnostics`
|
||||
- `GET /status`
|
||||
- `GET /settings`
|
||||
- `GET /accounts`
|
||||
- `GET /stats`
|
||||
- `GET /history?limit=50&status=completed`
|
||||
- `GET /packages?package=Release&includeItems=1`
|
||||
- `GET /items?status=downloading&package=Release`
|
||||
- `GET /session?package=Release`
|
||||
- `GET /log?lines=100&grep=keyword`
|
||||
- `GET /logs/main?lines=100&grep=keyword`
|
||||
- `GET /logs/audit?lines=100&grep=keyword`
|
||||
- `GET /logs/rename?lines=100&grep=keyword`
|
||||
- `GET /logs/trace?lines=100&grep=keyword`
|
||||
- `GET /logs/session?lines=100&grep=keyword`
|
||||
- `GET /logs/package?package=Release&lines=100&grep=keyword`
|
||||
- `GET /logs/item?item=episode.part2.rar&lines=100&grep=keyword`
|
||||
- `GET /trace/config?enable=1¬e=support&durationMinutes=120`
|
||||
- `GET /support/bundle`
|
||||
- `GET /diagnostics?package=Release&lines=150`
|
||||
|
||||
Authentication works with either:
|
||||
|
||||
- header: `Authorization: Bearer <token>`
|
||||
- query param: `?token=<token>`
|
||||
|
||||
Example from PowerShell:
|
||||
|
||||
```powershell
|
||||
Invoke-RestMethod "http://SERVER:9868/diagnostics?token=YOUR_TOKEN&package=Release"
|
||||
Invoke-RestMethod "http://SERVER:9868/settings?token=YOUR_TOKEN"
|
||||
Invoke-RestMethod "http://SERVER:9868/accounts?token=YOUR_TOKEN"
|
||||
Invoke-RestMethod "http://SERVER:9868/stats?token=YOUR_TOKEN"
|
||||
Invoke-RestMethod "http://SERVER:9868/history?token=YOUR_TOKEN&limit=20"
|
||||
Invoke-RestMethod "http://SERVER:9868/debug/setup?token=YOUR_TOKEN"
|
||||
Invoke-RestMethod "http://SERVER:9868/self-check?token=YOUR_TOKEN"
|
||||
Invoke-RestMethod "http://SERVER:9868/logs/audit?token=YOUR_TOKEN&lines=200"
|
||||
Invoke-RestMethod "http://SERVER:9868/logs/rename?token=YOUR_TOKEN&lines=200"
|
||||
Invoke-RestMethod "http://SERVER:9868/logs/trace?token=YOUR_TOKEN&lines=200"
|
||||
Invoke-RestMethod "http://SERVER:9868/trace/config?token=YOUR_TOKEN&enable=1¬e=support&durationMinutes=120"
|
||||
Invoke-RestMethod "http://SERVER:9868/logs/package?token=YOUR_TOKEN&package=Release&lines=200"
|
||||
Invoke-RestMethod "http://SERVER:9868/logs/item?token=YOUR_TOKEN&item=episode.part2.rar&lines=200"
|
||||
Invoke-RestMethod "http://SERVER:9868/host/diagnostics?token=YOUR_TOKEN"
|
||||
Invoke-WebRequest "http://SERVER:9868/support/bundle?token=YOUR_TOKEN" -OutFile ".\\rd-support-bundle.zip"
|
||||
```
|
||||
|
||||
This makes it easy to share one URL plus token during support, so current package status, session state, history, redacted account/settings state, audit actions, rename/MKV move traces, trace data, package/session/item logs, host-side Windows crash hints, disk space, support-log volume, support-bundle size estimates, and even a full ZIP support bundle can be inspected remotely.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
- Provider does not work: verify credentials, enabled state, provider order, and daily limits.
|
||||
- Debrid-Link quota looks wrong: open the API-key statistics popup and check the Rapidgator quota for the affected key.
|
||||
- Extraction fails: verify passwords and installed extraction tools. The native backend is the default; JVM extraction is optional.
|
||||
- Downloads stall: check active speed limits, bandwidth schedules, reconnect settings, and provider health.
|
||||
- Accounts table looks misaligned on one machine: use `Spalten zuruecksetzen` in the Accounts view to clear the locally stored column widths.
|
||||
|
||||
## Changelog
|
||||
|
||||
Detailed release history is published on [GitHub Releases](https://github.com/Sucukdeluxe/multi-debrid-downloader/releases).
|
||||
|
||||
## License
|
||||
|
||||
The project is licensed under the MIT License. See `LICENSE`.
|
||||
| `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 |
|
||||
|
||||
Bundled JVM extractor licenses and redistribution notices are available in `resources/extractor-jvm`.
|
||||
## Typical workflow
|
||||
|
||||
1. Add one or more provider accounts in `Settings > Accounts`.
|
||||
2. Configure provider order, fallback, and optional hoster routing.
|
||||
3. Paste links or import `.dlc` files.
|
||||
4. Adjust package names, target folders, extraction, and cleanup settings if needed.
|
||||
5. Start the queue and monitor downloads, extraction, and provider status.
|
||||
6. Review history and statistics after completion.
|
||||
|
||||
## Link export format
|
||||
|
||||
Selected packages or items can be exported from the context menu as a structured text file. Re-importing that file restores the original package grouping, even if it only contains a subset of items from a larger package.
|
||||
|
||||
Example:
|
||||
|
||||
```txt
|
||||
# rd-link-export: 1
|
||||
# package: Dave Staffel 1
|
||||
# file: Dave.S01E01.rar
|
||||
https://example.com/e01
|
||||
# file: Dave.S01E02.rar
|
||||
https://example.com/e02
|
||||
```
|
||||
|
||||
Supported import sources:
|
||||
|
||||
- collector text input
|
||||
- `Datei importieren`
|
||||
- drag-and-drop of `.txt` and `.json`
|
||||
|
||||
The optional `# file:` marker preserves the original item name so the imported subset can be rebuilt with the same package name and per-item filename hints.
|
||||
|
||||
## Project structure
|
||||
|
||||
- `src/main` - Electron main process, download engine, provider clients, updater, storage
|
||||
- `src/preload` - secure IPC bridge
|
||||
- `src/renderer` - React UI
|
||||
- `src/shared` - shared types and IPC contracts
|
||||
- `tests` - unit and integration-style tests
|
||||
- `resources/extractor-jvm` - optional JVM extraction runtime
|
||||
- `scripts` - release and build helpers
|
||||
|
||||
## Data and logs
|
||||
|
||||
Runtime files are stored in Electron's `userData` directory, including:
|
||||
|
||||
- `rd_downloader_config.json`
|
||||
- `rd_session_state.json`
|
||||
- `rd_history.json`
|
||||
- `rd_downloader.log`
|
||||
- `audit.log`
|
||||
- `rename.log`
|
||||
- `debug_ai_manifest.json`
|
||||
- `trace.log`
|
||||
- `trace_config.json`
|
||||
- `session-logs/session_*.txt`
|
||||
- `package-logs/package_*.txt`
|
||||
- `item-logs/item_*.txt`
|
||||
|
||||
`audit.log`, `rename.log`, and `trace.log` are rotated automatically. The current file is kept plus one `.old` backup, and outdated backups are purged automatically.
|
||||
|
||||
### Remote debug server
|
||||
|
||||
For headless or server-style troubleshooting, the app can expose a small authenticated HTTP debug API with live status and log tails.
|
||||
|
||||
Enable it by creating these files in the same runtime folder that contains `rd_downloader.log`:
|
||||
|
||||
- `debug_token.txt`
|
||||
Example: a long random token such as `rd-debug-please-change-me`
|
||||
- `debug_port.txt`
|
||||
Example: `9868`
|
||||
- `debug_host.txt` (optional)
|
||||
Default is `127.0.0.1`. Set `0.0.0.0` only if you really want remote access and protect it with firewall, VPN, or reverse proxy.
|
||||
|
||||
After startup, the app also writes `debug_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.
|
||||
|
||||
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:
|
||||
|
||||
- `GET /health`
|
||||
- `GET /meta`
|
||||
- `GET /debug/setup`
|
||||
- `GET /self-check`
|
||||
- `GET /host/diagnostics`
|
||||
- `GET /status`
|
||||
- `GET /settings`
|
||||
- `GET /accounts`
|
||||
- `GET /stats`
|
||||
- `GET /history?limit=50&status=completed`
|
||||
- `GET /packages?package=Release&includeItems=1`
|
||||
- `GET /items?status=downloading&package=Release`
|
||||
- `GET /session?package=Release`
|
||||
- `GET /log?lines=100&grep=keyword`
|
||||
- `GET /logs/main?lines=100&grep=keyword`
|
||||
- `GET /logs/audit?lines=100&grep=keyword`
|
||||
- `GET /logs/rename?lines=100&grep=keyword`
|
||||
- `GET /logs/trace?lines=100&grep=keyword`
|
||||
- `GET /logs/session?lines=100&grep=keyword`
|
||||
- `GET /logs/package?package=Release&lines=100&grep=keyword`
|
||||
- `GET /logs/item?item=episode.part2.rar&lines=100&grep=keyword`
|
||||
- `GET /trace/config?enable=1¬e=support&durationMinutes=120`
|
||||
- `GET /support/bundle`
|
||||
- `GET /diagnostics?package=Release&lines=150`
|
||||
|
||||
Authentication works with either:
|
||||
|
||||
- header: `Authorization: Bearer <token>`
|
||||
- query param: `?token=<token>`
|
||||
|
||||
Example from PowerShell:
|
||||
|
||||
```powershell
|
||||
Invoke-RestMethod "http://SERVER:9868/diagnostics?token=YOUR_TOKEN&package=Release"
|
||||
Invoke-RestMethod "http://SERVER:9868/settings?token=YOUR_TOKEN"
|
||||
Invoke-RestMethod "http://SERVER:9868/accounts?token=YOUR_TOKEN"
|
||||
Invoke-RestMethod "http://SERVER:9868/stats?token=YOUR_TOKEN"
|
||||
Invoke-RestMethod "http://SERVER:9868/history?token=YOUR_TOKEN&limit=20"
|
||||
Invoke-RestMethod "http://SERVER:9868/debug/setup?token=YOUR_TOKEN"
|
||||
Invoke-RestMethod "http://SERVER:9868/self-check?token=YOUR_TOKEN"
|
||||
Invoke-RestMethod "http://SERVER:9868/logs/audit?token=YOUR_TOKEN&lines=200"
|
||||
Invoke-RestMethod "http://SERVER:9868/logs/rename?token=YOUR_TOKEN&lines=200"
|
||||
Invoke-RestMethod "http://SERVER:9868/logs/trace?token=YOUR_TOKEN&lines=200"
|
||||
Invoke-RestMethod "http://SERVER:9868/trace/config?token=YOUR_TOKEN&enable=1¬e=support&durationMinutes=120"
|
||||
Invoke-RestMethod "http://SERVER:9868/logs/package?token=YOUR_TOKEN&package=Release&lines=200"
|
||||
Invoke-RestMethod "http://SERVER:9868/logs/item?token=YOUR_TOKEN&item=episode.part2.rar&lines=200"
|
||||
Invoke-RestMethod "http://SERVER:9868/host/diagnostics?token=YOUR_TOKEN"
|
||||
Invoke-WebRequest "http://SERVER:9868/support/bundle?token=YOUR_TOKEN" -OutFile ".\\rd-support-bundle.zip"
|
||||
```
|
||||
|
||||
This makes it easy to share one URL plus token during support, so current package status, session state, history, redacted account/settings state, audit actions, rename/MKV move traces, trace data, package/session/item logs, host-side Windows crash hints, disk space, support-log volume, support-bundle size estimates, and even a full ZIP support bundle can be inspected remotely.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
- Provider does not work: verify credentials, enabled state, provider order, and daily limits.
|
||||
- Debrid-Link quota looks wrong: open the API-key statistics popup and check the Rapidgator quota for the affected key.
|
||||
- Extraction fails: verify passwords and installed extraction tools. The native backend is the default; JVM extraction is optional.
|
||||
- Downloads stall: check active speed limits, bandwidth schedules, reconnect settings, and provider health.
|
||||
- Accounts table looks misaligned on one machine: use `Spalten zuruecksetzen` in the Accounts view to clear the locally stored column widths.
|
||||
|
||||
## Changelog
|
||||
|
||||
Detailed release history is published on [git.24-music.de Releases](https://git.24-music.de/Administrator/real-debrid-downloader/releases).
|
||||
|
||||
## License
|
||||
|
||||
MIT - see `LICENSE`.
|
||||
|
||||
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
|
||||
19545
package-lock.json
generated
19545
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
33
package.json
33
package.json
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "real-debrid-downloader",
|
||||
"version": "2.0.1",
|
||||
"version": "1.7.232",
|
||||
"description": "Desktop downloader",
|
||||
"main": "build/main/main/main.js",
|
||||
"author": "Sucukdeluxe",
|
||||
@ -14,19 +14,17 @@
|
||||
"build:main": "tsup src/main/main.ts src/preload/preload.ts --out-dir build/main --format cjs --target node20 --external electron --sourcemap",
|
||||
"build:renderer": "vite build",
|
||||
"start": "cross-env NODE_ENV=production electron .",
|
||||
"test": "npm run test:client && npm run test:backup-api",
|
||||
"test:client": "vitest run",
|
||||
"test:backup-api": "npm --prefix services/backup-api test",
|
||||
"start:backup-api": "npm --prefix services/backup-api start",
|
||||
"test": "vitest run",
|
||||
"self-check": "tsx tests/self-check.ts",
|
||||
"release:win": "npm run build && electron-builder --publish never --win nsis portable",
|
||||
"verify:release": "node scripts/verify_public_release.mjs"
|
||||
"release:gitea": "node scripts/release_gitea.mjs",
|
||||
"release:forgejo": "node scripts/release_gitea.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"adm-zip": "0.6.0",
|
||||
"adm-zip": "^0.5.16",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"uuid": "11.1.1"
|
||||
"uuid": "^11.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/adm-zip": "^0.5.7",
|
||||
@ -45,8 +43,7 @@
|
||||
"typescript": "^5.7.3",
|
||||
"vite": "^6.0.5",
|
||||
"vitest": "^2.1.8",
|
||||
"wait-on": "^8.0.1",
|
||||
"yaml": "^2.9.0"
|
||||
"wait-on": "^8.0.1"
|
||||
},
|
||||
"build": {
|
||||
"appId": "com.sucukdeluxe.realdebrid",
|
||||
@ -55,24 +52,12 @@
|
||||
"buildResources": "assets",
|
||||
"output": "release"
|
||||
},
|
||||
"publish": {
|
||||
"provider": "github",
|
||||
"owner": "Sucukdeluxe",
|
||||
"repo": "multi-debrid-downloader"
|
||||
},
|
||||
"files": [
|
||||
"build/main/**/*",
|
||||
"build/renderer/**/*",
|
||||
"resources/extractor-jvm/**/*",
|
||||
"LICENSE",
|
||||
"package.json"
|
||||
],
|
||||
"extraResources": [
|
||||
{
|
||||
"from": "LICENSE",
|
||||
"to": "LICENSE"
|
||||
}
|
||||
],
|
||||
"asarUnpack": [
|
||||
"resources/extractor-jvm/**/*"
|
||||
],
|
||||
@ -85,15 +70,11 @@
|
||||
"signAndEditExecutable": false
|
||||
},
|
||||
"nsis": {
|
||||
"artifactName": "${productName}-Setup-${version}.${ext}",
|
||||
"oneClick": false,
|
||||
"perMachine": false,
|
||||
"allowToChangeInstallationDirectory": true,
|
||||
"createDesktopShortcut": true
|
||||
},
|
||||
"portable": {
|
||||
"artifactName": "${productName}-${version}-portable.${ext}"
|
||||
},
|
||||
"afterPack": "scripts/afterPack.cjs"
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,22 +1,22 @@
|
||||
# JVM extractor runtime
|
||||
|
||||
This directory contains the Java sidecar runtime used by `src/main/extractor.ts`.
|
||||
|
||||
## Included backends
|
||||
|
||||
- `sevenzipjbinding` for the primary extraction path (RAR/7z/ZIP and others)
|
||||
- `zip4j` for ZIP multipart handling (JD-style split ZIP behavior)
|
||||
|
||||
## Layout
|
||||
|
||||
- `classes/` compiled `JBindExtractorMain` classes
|
||||
- `lib/` runtime jars required by the sidecar
|
||||
- `src/` Java source for the sidecar
|
||||
|
||||
## Rebuild notes
|
||||
|
||||
The checked-in classes are Java 8 compatible and built from:
|
||||
|
||||
`resources/extractor-jvm/src/com/sucukdeluxe/extractor/JBindExtractorMain.java`
|
||||
|
||||
If you need to rebuild, compile against the jars in `lib/` with a Java 8-compatible compiler.
|
||||
# JVM extractor runtime
|
||||
|
||||
This directory contains the Java sidecar runtime used by `src/main/extractor.ts`.
|
||||
|
||||
## Included backends
|
||||
|
||||
- `sevenzipjbinding` for the primary extraction path (RAR/7z/ZIP and others)
|
||||
- `zip4j` for ZIP multipart handling (JD-style split ZIP behavior)
|
||||
|
||||
## Layout
|
||||
|
||||
- `classes/` compiled `JBindExtractorMain` classes
|
||||
- `lib/` runtime jars required by the sidecar
|
||||
- `src/` Java source for the sidecar
|
||||
|
||||
## Rebuild notes
|
||||
|
||||
The checked-in classes are Java 8 compatible and built from:
|
||||
|
||||
`resources/extractor-jvm/src/com/sucukdeluxe/extractor/JBindExtractorMain.java`
|
||||
|
||||
If you need to rebuild, compile against the jars in `lib/` with a Java 8-compatible compiler.
|
||||
|
||||
@ -1,7 +1,12 @@
|
||||
Bundled JVM extractor dependencies
|
||||
Bundled JVM extractor dependencies:
|
||||
|
||||
| Maven artifact | Version | License and restrictions | Included text | Upstream |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| net.sf.sevenzipjbinding:sevenzipjbinding:16.02-2.01 | 16.02-2.01 | GNU Lesser General Public License 2.1 or later; bundled 7-Zip code includes the unRAR restriction | licenses/LGPL-2.1.txt; licenses/7-Zip-license.txt | https://sevenzipjbind.sourceforge.net/ |
|
||||
| net.sf.sevenzipjbinding:sevenzipjbinding-all-platforms:16.02-2.01 | 16.02-2.01 | GNU Lesser General Public License 2.1 or later; bundled 7-Zip code includes the unRAR restriction | licenses/LGPL-2.1.txt; licenses/7-Zip-license.txt | https://sevenzipjbind.sourceforge.net/ |
|
||||
| net.lingala.zip4j:zip4j:2.11.5 | 2.11.5 | Apache License 2.0 | licenses/Apache-2.0.txt | https://github.com/srikanth-lingala/zip4j |
|
||||
1) sevenzipjbinding (16.02-2.01)
|
||||
- Maven artifact: net.sf.sevenzipjbinding:sevenzipjbinding
|
||||
- Maven artifact: net.sf.sevenzipjbinding:sevenzipjbinding-all-platforms
|
||||
- Upstream: https://sevenzipjbind.sourceforge.net/
|
||||
|
||||
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!
|
||||
File diff suppressed because it is too large
Load Diff
@ -1,18 +1,18 @@
|
||||
const path = require("path");
|
||||
const { rcedit } = require("rcedit");
|
||||
|
||||
module.exports = async function afterPack(context) {
|
||||
const productFilename = context.packager?.appInfo?.productFilename;
|
||||
if (!productFilename) {
|
||||
console.warn(" • rcedit: skipped — productFilename not available");
|
||||
return;
|
||||
}
|
||||
const exePath = path.join(context.appOutDir, `${productFilename}.exe`);
|
||||
const iconPath = path.resolve(__dirname, "..", "assets", "app_icon.ico");
|
||||
console.log(` • rcedit: patching icon → ${exePath}`);
|
||||
try {
|
||||
await rcedit(exePath, { icon: iconPath });
|
||||
} catch (error) {
|
||||
console.warn(` • rcedit: failed — ${String(error)}`);
|
||||
}
|
||||
};
|
||||
const path = require("path");
|
||||
const { rcedit } = require("rcedit");
|
||||
|
||||
module.exports = async function afterPack(context) {
|
||||
const productFilename = context.packager?.appInfo?.productFilename;
|
||||
if (!productFilename) {
|
||||
console.warn(" • rcedit: skipped — productFilename not available");
|
||||
return;
|
||||
}
|
||||
const exePath = path.join(context.appOutDir, `${productFilename}.exe`);
|
||||
const iconPath = path.resolve(__dirname, "..", "assets", "app_icon.ico");
|
||||
console.log(` • rcedit: patching icon → ${exePath}`);
|
||||
try {
|
||||
await rcedit(exePath, { icon: iconPath });
|
||||
} catch (error) {
|
||||
console.warn(` • rcedit: failed — ${String(error)}`);
|
||||
}
|
||||
};
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
1
services/backup-api/.gitignore
vendored
1
services/backup-api/.gitignore
vendored
@ -1 +0,0 @@
|
||||
data/
|
||||
@ -1,36 +0,0 @@
|
||||
# Multi-Debrid Backup API
|
||||
|
||||
Die API speichert ausschließlich bereits clientseitig verschlüsselte, undurchsichtige Backups. Schlüssel und Klartext verlassen den Client nicht.
|
||||
|
||||
Jeder Export wird als eigener unveränderlicher Datensatz gespeichert. Es gibt keine automatische Ablaufzeit und ein neuer Export überschreibt oder löscht keine älteren Sicherungen.
|
||||
|
||||
## Konfiguration
|
||||
|
||||
| Variable | Standard | Bedeutung |
|
||||
|---|---:|---|
|
||||
| `HOST` | `127.0.0.1` | Bind-Adresse |
|
||||
| `PORT` | `8787` | HTTP-Port hinter einem TLS-Reverse-Proxy |
|
||||
| `BACKUP_DATA_DIR` | `./data` | Persistentes Datenverzeichnis |
|
||||
| `ALLOWED_ORIGINS` | leer | Kommagetrennte erlaubte Browser-Origins |
|
||||
| `RATE_LIMIT_MAX` | `60` | Maximalzahl pro IP und Zeitfenster |
|
||||
| `RATE_LIMIT_WINDOW_MS` | `60000` | Länge des Zeitfensters |
|
||||
| `UPLOAD_RATE_LIMIT_MAX` | `10` | Maximale neue Sicherungen pro IP und Upload-Zeitfenster |
|
||||
| `UPLOAD_RATE_LIMIT_WINDOW_MS` | `3600000` | Länge des separaten Upload-Zeitfensters |
|
||||
| `MAX_STORAGE_BYTES` | `10737418240` | Globale Obergrenze des persistenten Speichers in Bytes |
|
||||
| `TRUST_PROXY` | `false` | `true`, wenn der vertrauenswürdige Proxy `X-Forwarded-For` überschreibt |
|
||||
|
||||
## Start
|
||||
|
||||
```powershell
|
||||
$env:BACKUP_DATA_DIR = 'C:\ProgramData\MultiDebridBackup'
|
||||
$env:ALLOWED_ORIGINS = 'https://downloads.24-music.de'
|
||||
$env:MAX_STORAGE_BYTES = '10737418240'
|
||||
$env:TRUST_PROXY = 'true'
|
||||
npm start
|
||||
```
|
||||
|
||||
Der Dienst sollte nur hinter einem TLS-Reverse-Proxy öffentlich erreichbar sein. Bei `TRUST_PROXY=true` muss dieser den eingehenden `X-Forwarded-For`-Header vollständig ersetzen. Das Datenverzeichnis benötigt regelmäßige Dateisystem-Backups.
|
||||
|
||||
## HTTP-Vertrag
|
||||
|
||||
`POST /v1/backups` akzeptiert `id`, `blob` und `deleteVerifier`. `POST /v1/backups/restore` akzeptiert `id` und liefert ausschließlich `blob`. `POST /v1/backups/delete` akzeptiert `id` und `deleteSecret`. Fehlerhafte Löschgeheimnisse und unbekannte IDs sind nicht unterscheidbar. IDs erscheinen nie in URLs.
|
||||
50
services/backup-api/package-lock.json
generated
50
services/backup-api/package-lock.json
generated
@ -1,50 +0,0 @@
|
||||
{
|
||||
"name": "multi-debrid-backup-api",
|
||||
"version": "2.0.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "multi-debrid-backup-api",
|
||||
"version": "2.0.0",
|
||||
"dependencies": {
|
||||
"proper-lockfile": "4.1.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
}
|
||||
},
|
||||
"node_modules/graceful-fs": {
|
||||
"version": "4.2.11",
|
||||
"resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz",
|
||||
"integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/proper-lockfile": {
|
||||
"version": "4.1.2",
|
||||
"resolved": "https://registry.npmjs.org/proper-lockfile/-/proper-lockfile-4.1.2.tgz",
|
||||
"integrity": "sha512-TjNPblN4BwAWMXU8s9AEz4JmQxnD1NNL7bNOY/AKUzyamc379FWASUhc/K1pL2noVb+XmZKLL68cjzLsiOAMaA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"graceful-fs": "^4.2.4",
|
||||
"retry": "^0.12.0",
|
||||
"signal-exit": "^3.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/retry": {
|
||||
"version": "0.12.0",
|
||||
"resolved": "https://registry.npmjs.org/retry/-/retry-0.12.0.tgz",
|
||||
"integrity": "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 4"
|
||||
}
|
||||
},
|
||||
"node_modules/signal-exit": {
|
||||
"version": "3.0.7",
|
||||
"resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz",
|
||||
"integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==",
|
||||
"license": "ISC"
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,16 +0,0 @@
|
||||
{
|
||||
"name": "multi-debrid-backup-api",
|
||||
"version": "2.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"start": "node src/cli.mjs",
|
||||
"test": "node --test"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
},
|
||||
"dependencies": {
|
||||
"proper-lockfile": "4.1.2"
|
||||
}
|
||||
}
|
||||
@ -1,40 +0,0 @@
|
||||
import { resolve } from 'node:path'
|
||||
import { createBackupServer } from './server.mjs'
|
||||
|
||||
const port = Number.parseInt(process.env.PORT ?? '8787', 10)
|
||||
const host = process.env.HOST ?? '127.0.0.1'
|
||||
const rootDir = resolve(process.env.BACKUP_DATA_DIR ?? './data')
|
||||
const allowedOrigins = (process.env.ALLOWED_ORIGINS ?? '')
|
||||
.split(',')
|
||||
.map(origin => origin.trim())
|
||||
.filter(Boolean)
|
||||
const rateLimit = {
|
||||
max: Number.parseInt(process.env.RATE_LIMIT_MAX ?? '60', 10),
|
||||
windowMs: Number.parseInt(process.env.RATE_LIMIT_WINDOW_MS ?? '60000', 10)
|
||||
}
|
||||
const uploadRateLimit = {
|
||||
max: Number.parseInt(process.env.UPLOAD_RATE_LIMIT_MAX ?? '10', 10),
|
||||
windowMs: Number.parseInt(process.env.UPLOAD_RATE_LIMIT_WINDOW_MS ?? '3600000', 10)
|
||||
}
|
||||
const maxStorageBytes = Number.parseInt(process.env.MAX_STORAGE_BYTES ?? String(10 * 1024 * 1024 * 1024), 10)
|
||||
const trustedProxy = process.env.TRUST_PROXY === 'true'
|
||||
|
||||
if (!Number.isSafeInteger(port) || port < 1 || port > 65_535) throw new Error('Invalid PORT')
|
||||
|
||||
const server = createBackupServer({ rootDir, allowedOrigins, rateLimit, uploadRateLimit, maxStorageBytes, trustedProxy })
|
||||
|
||||
server.listen(port, host, () => {
|
||||
process.stdout.write(`Backup API listening on ${host}:${port}\n`)
|
||||
})
|
||||
|
||||
function shutdown() {
|
||||
server.close(error => {
|
||||
if (error) {
|
||||
process.stderr.write('Backup API shutdown failed\n')
|
||||
process.exitCode = 1
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
process.on('SIGINT', shutdown)
|
||||
process.on('SIGTERM', shutdown)
|
||||
@ -1,18 +0,0 @@
|
||||
import type { Server } from "node:http";
|
||||
|
||||
export interface BackupServerOptions {
|
||||
rootDir: string;
|
||||
allowedOrigins?: string[];
|
||||
rateLimit?: {
|
||||
max: number;
|
||||
windowMs: number;
|
||||
};
|
||||
uploadRateLimit?: {
|
||||
max: number;
|
||||
windowMs: number;
|
||||
};
|
||||
maxStorageBytes?: number;
|
||||
trustedProxy?: boolean;
|
||||
}
|
||||
|
||||
export function createBackupServer(options: BackupServerOptions): Server;
|
||||
@ -1,463 +0,0 @@
|
||||
import { createHash, timingSafeEqual, randomBytes } from 'node:crypto'
|
||||
import { createServer } from 'node:http'
|
||||
import { link, mkdir, open, readFile, readdir, stat, unlink } from 'node:fs/promises'
|
||||
import { isIP } from 'node:net'
|
||||
import { join } from 'node:path'
|
||||
import lockfile from 'proper-lockfile'
|
||||
|
||||
const maxBlobBytes = 256 * 1024
|
||||
const maxBodyBytes = 384 * 1024
|
||||
const idPattern = /^[A-Za-z0-9_-]{22}$/
|
||||
const verifierPattern = /^[A-Za-z0-9_-]{43}$/
|
||||
const blobPattern = /^[A-Za-z0-9_-]+$/
|
||||
const notFoundBody = '{"error":"not_found"}'
|
||||
|
||||
function isCanonicalBase64Url(value, byteLength, pattern) {
|
||||
if (typeof value !== 'string' || !pattern.test(value)) return false
|
||||
const decoded = Buffer.from(value, 'base64url')
|
||||
return decoded.length === byteLength && decoded.toString('base64url') === value
|
||||
}
|
||||
|
||||
function isValidBackup(payload) {
|
||||
if (!payload || typeof payload !== 'object' || Array.isArray(payload)) return false
|
||||
const keys = Object.keys(payload).sort()
|
||||
if (keys.join(',') !== 'blob,deleteVerifier,id') return false
|
||||
if (!isCanonicalBase64Url(payload.id, 16, idPattern)) return false
|
||||
if (!isCanonicalBase64Url(payload.deleteVerifier, 32, verifierPattern)) return false
|
||||
if (typeof payload.blob !== 'string' || !blobPattern.test(payload.blob)) return false
|
||||
const decoded = Buffer.from(payload.blob, 'base64url')
|
||||
return decoded.length <= maxBlobBytes && decoded.toString('base64url') === payload.blob
|
||||
}
|
||||
|
||||
function createRateLimiter({ max, windowMs }) {
|
||||
const clients = new Map()
|
||||
let requestCount = 0
|
||||
return address => {
|
||||
const now = Date.now()
|
||||
requestCount += 1
|
||||
if (requestCount % 1024 === 0) {
|
||||
for (const [key, value] of clients) {
|
||||
if (now - value.startedAt >= windowMs) clients.delete(key)
|
||||
}
|
||||
}
|
||||
const current = clients.get(address)
|
||||
if (!current || now - current.startedAt >= windowMs) {
|
||||
clients.set(address, { startedAt: now, count: 1 })
|
||||
return null
|
||||
}
|
||||
if (current.count >= max) return Math.max(1, Math.ceil((windowMs - (now - current.startedAt)) / 1000))
|
||||
current.count += 1
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function readJsonBody(request) {
|
||||
const declaredLength = Number.parseInt(request.headers['content-length'] ?? '', 10)
|
||||
if (Number.isFinite(declaredLength) && declaredLength > maxBodyBytes) {
|
||||
request.resume()
|
||||
return Promise.resolve({ error: 413 })
|
||||
}
|
||||
return new Promise((resolve, reject) => {
|
||||
let size = 0
|
||||
let settled = false
|
||||
const chunks = []
|
||||
const cleanup = () => {
|
||||
request.off('data', onData)
|
||||
request.off('end', onEnd)
|
||||
request.off('aborted', onAborted)
|
||||
request.off('error', onError)
|
||||
}
|
||||
const finish = result => {
|
||||
if (settled) return
|
||||
settled = true
|
||||
cleanup()
|
||||
resolve(result)
|
||||
}
|
||||
const onData = chunk => {
|
||||
size += chunk.length
|
||||
if (size > maxBodyBytes) {
|
||||
finish({ error: 413 })
|
||||
request.resume()
|
||||
return
|
||||
}
|
||||
chunks.push(chunk)
|
||||
}
|
||||
const onEnd = () => {
|
||||
try {
|
||||
finish({ value: JSON.parse(Buffer.concat(chunks).toString('utf8')) })
|
||||
} catch {
|
||||
finish({ error: 400 })
|
||||
}
|
||||
}
|
||||
const onAborted = () => reject(new Error('Request aborted'))
|
||||
const onError = error => reject(error)
|
||||
request.on('data', onData)
|
||||
request.on('end', onEnd)
|
||||
request.on('aborted', onAborted)
|
||||
request.on('error', onError)
|
||||
})
|
||||
}
|
||||
|
||||
function recordPath(rootDir, id) {
|
||||
return join(rootDir, `${id}.json`)
|
||||
}
|
||||
|
||||
function createMutationQueue() {
|
||||
let pending = Promise.resolve()
|
||||
return operation => {
|
||||
const result = pending.then(operation, operation)
|
||||
pending = result.catch(() => {})
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
async function directoryUsage(rootDir) {
|
||||
let total = 0
|
||||
const entries = await readdir(rootDir, { withFileTypes: true })
|
||||
for (const entry of entries) {
|
||||
if (!entry.isFile() || !entry.name.endsWith('.json')) continue
|
||||
try {
|
||||
total += (await stat(join(rootDir, entry.name))).size
|
||||
} catch (error) {
|
||||
if (error.code !== 'ENOENT') throw error
|
||||
}
|
||||
}
|
||||
return total
|
||||
}
|
||||
|
||||
async function syncDirectory(rootDir) {
|
||||
let handle
|
||||
try {
|
||||
handle = await open(rootDir, 'r')
|
||||
await handle.sync()
|
||||
} catch (error) {
|
||||
if (!['EISDIR', 'EINVAL', 'ENOTSUP', 'EPERM', 'EBADF'].includes(error.code)) throw error
|
||||
} finally {
|
||||
await handle?.close().catch(() => {})
|
||||
}
|
||||
}
|
||||
|
||||
async function withStorageLock(rootDir, operation) {
|
||||
await mkdir(rootDir, { recursive: true })
|
||||
const release = await lockfile.lock(rootDir, {
|
||||
realpath: false,
|
||||
lockfilePath: join(rootDir, '.storage.lock'),
|
||||
stale: 30_000,
|
||||
update: 10_000,
|
||||
retries: {
|
||||
retries: 100,
|
||||
factor: 1.1,
|
||||
minTimeout: 10,
|
||||
maxTimeout: 100,
|
||||
randomize: true
|
||||
}
|
||||
})
|
||||
try {
|
||||
return await operation()
|
||||
} finally {
|
||||
let releaseError
|
||||
try {
|
||||
await release()
|
||||
} catch (error) {
|
||||
releaseError = error
|
||||
}
|
||||
try {
|
||||
await syncDirectory(rootDir)
|
||||
} catch (error) {
|
||||
releaseError ??= error
|
||||
}
|
||||
if (releaseError) throw releaseError
|
||||
}
|
||||
}
|
||||
|
||||
async function recordExists(rootDir, id) {
|
||||
try {
|
||||
await stat(recordPath(rootDir, id))
|
||||
return true
|
||||
} catch (error) {
|
||||
if (error.code === 'ENOENT') return false
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
async function createRecord(rootDir, payload, maxStorageBytes) {
|
||||
await mkdir(rootDir, { recursive: true })
|
||||
if (await recordExists(rootDir, payload.id)) return 'duplicate'
|
||||
const contents = Buffer.from(JSON.stringify({
|
||||
version: 1,
|
||||
blob: payload.blob,
|
||||
deleteVerifier: payload.deleteVerifier,
|
||||
createdAt: new Date().toISOString()
|
||||
}), 'utf8')
|
||||
if (await directoryUsage(rootDir) + contents.length > maxStorageBytes) return 'full'
|
||||
const temporaryPath = join(rootDir, `.${randomBytes(16).toString('hex')}.tmp`)
|
||||
let handle
|
||||
let temporaryCreated = false
|
||||
let published = false
|
||||
try {
|
||||
handle = await open(temporaryPath, 'wx', 0o600)
|
||||
temporaryCreated = true
|
||||
try {
|
||||
await handle.writeFile(contents)
|
||||
await handle.sync()
|
||||
} finally {
|
||||
await handle.close()
|
||||
handle = undefined
|
||||
}
|
||||
try {
|
||||
await link(temporaryPath, recordPath(rootDir, payload.id))
|
||||
} catch (error) {
|
||||
if (error.code === 'EEXIST') return 'duplicate'
|
||||
throw error
|
||||
}
|
||||
published = true
|
||||
return 'created'
|
||||
} finally {
|
||||
let cleanupError
|
||||
try {
|
||||
await handle?.close()
|
||||
} catch (error) {
|
||||
cleanupError = error
|
||||
}
|
||||
if (temporaryCreated) {
|
||||
try {
|
||||
await unlink(temporaryPath)
|
||||
} catch (error) {
|
||||
cleanupError ??= error
|
||||
}
|
||||
}
|
||||
if (published) {
|
||||
try {
|
||||
await syncDirectory(rootDir)
|
||||
} catch (error) {
|
||||
cleanupError ??= error
|
||||
}
|
||||
}
|
||||
if (cleanupError) throw cleanupError
|
||||
}
|
||||
}
|
||||
|
||||
async function readRecord(rootDir, id) {
|
||||
try {
|
||||
const raw = await readFile(recordPath(rootDir, id), 'utf8')
|
||||
const record = JSON.parse(raw)
|
||||
if (record?.version !== 1 || typeof record.blob !== 'string' || !isCanonicalBase64Url(record.deleteVerifier, 32, verifierPattern)) {
|
||||
throw new Error('Invalid stored record')
|
||||
}
|
||||
return record
|
||||
} catch (error) {
|
||||
if (error.code === 'ENOENT') return null
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
function securityHeaders(response) {
|
||||
response.setHeader('cache-control', 'no-store')
|
||||
response.setHeader('x-content-type-options', 'nosniff')
|
||||
response.setHeader('content-security-policy', "default-src 'none'")
|
||||
response.setHeader('referrer-policy', 'no-referrer')
|
||||
}
|
||||
|
||||
function sendJson(response, status, body) {
|
||||
response.statusCode = status
|
||||
response.setHeader('content-type', 'application/json; charset=utf-8')
|
||||
response.end(JSON.stringify(body))
|
||||
}
|
||||
|
||||
function sendNotFound(response) {
|
||||
response.statusCode = 404
|
||||
response.setHeader('content-type', 'application/json; charset=utf-8')
|
||||
response.end(notFoundBody)
|
||||
}
|
||||
|
||||
function authorizeOrigin(request, response, allowedOrigins) {
|
||||
const origin = request.headers.origin
|
||||
if (!origin) return true
|
||||
if (!allowedOrigins.has(origin)) {
|
||||
sendJson(response, 403, { error: 'origin_denied' })
|
||||
return false
|
||||
}
|
||||
response.setHeader('access-control-allow-origin', origin)
|
||||
response.setHeader('vary', 'Origin')
|
||||
return true
|
||||
}
|
||||
|
||||
function verifierMatches(secret, expectedVerifier) {
|
||||
const actual = createHash('sha256').update(Buffer.from(secret, 'base64url')).digest()
|
||||
const expected = Buffer.from(expectedVerifier, 'base64url')
|
||||
return actual.length === expected.length && timingSafeEqual(actual, expected)
|
||||
}
|
||||
|
||||
async function storageIsReady(rootDir) {
|
||||
const probePath = join(rootDir, `.${randomBytes(16).toString('hex')}.health`)
|
||||
try {
|
||||
await mkdir(rootDir, { recursive: true })
|
||||
const handle = await open(probePath, 'wx', 0o600)
|
||||
await handle.close()
|
||||
await unlink(probePath)
|
||||
return true
|
||||
} catch {
|
||||
await unlink(probePath).catch(() => {})
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
function clientAddress(request, trustedProxy) {
|
||||
if (trustedProxy) {
|
||||
const forwarded = request.headers['x-forwarded-for']
|
||||
const candidate = Array.isArray(forwarded) ? forwarded[0] : forwarded?.split(',', 1)[0].trim()
|
||||
if (candidate && isIP(candidate)) return candidate
|
||||
}
|
||||
return request.socket.remoteAddress ?? 'unknown'
|
||||
}
|
||||
|
||||
export function createBackupServer(options) {
|
||||
if (!options?.rootDir) throw new Error('rootDir is required')
|
||||
const allowedOrigins = new Set(options.allowedOrigins ?? [])
|
||||
const rateLimit = options.rateLimit ?? { max: 60, windowMs: 60_000 }
|
||||
const uploadRateLimit = options.uploadRateLimit ?? { max: 10, windowMs: 3_600_000 }
|
||||
const maxStorageBytes = options.maxStorageBytes ?? 10 * 1024 * 1024 * 1024
|
||||
if (!Number.isSafeInteger(rateLimit.max) || rateLimit.max < 1 || !Number.isSafeInteger(rateLimit.windowMs) || rateLimit.windowMs < 1) {
|
||||
throw new Error('Invalid rate limit')
|
||||
}
|
||||
if (!Number.isSafeInteger(uploadRateLimit.max) || uploadRateLimit.max < 1 || !Number.isSafeInteger(uploadRateLimit.windowMs) || uploadRateLimit.windowMs < 1) {
|
||||
throw new Error('Invalid upload rate limit')
|
||||
}
|
||||
if (!Number.isSafeInteger(maxStorageBytes) || maxStorageBytes < 1) throw new Error('Invalid max storage size')
|
||||
const consumeRateLimit = createRateLimiter(rateLimit)
|
||||
const consumeUploadRateLimit = createRateLimiter(uploadRateLimit)
|
||||
const runStorageMutation = createMutationQueue()
|
||||
|
||||
return createServer(async (request, response) => {
|
||||
securityHeaders(response)
|
||||
try {
|
||||
const url = new URL(request.url, 'http://localhost')
|
||||
if (!authorizeOrigin(request, response, allowedOrigins)) return
|
||||
if (url.search) {
|
||||
sendNotFound(response)
|
||||
return
|
||||
}
|
||||
|
||||
if (request.method === 'OPTIONS') {
|
||||
const requestedMethod = request.headers['access-control-request-method']
|
||||
if (!request.headers.origin || requestedMethod !== 'POST') {
|
||||
sendJson(response, 400, { error: 'invalid_preflight' })
|
||||
return
|
||||
}
|
||||
response.statusCode = 204
|
||||
response.setHeader('access-control-allow-methods', 'POST, OPTIONS')
|
||||
response.setHeader('access-control-allow-headers', 'content-type')
|
||||
response.setHeader('access-control-max-age', '600')
|
||||
response.end()
|
||||
return
|
||||
}
|
||||
|
||||
if (request.method === 'GET' && url.pathname === '/health') {
|
||||
const ready = await storageIsReady(options.rootDir)
|
||||
sendJson(response, ready ? 200 : 503, { status: ready ? 'ok' : 'unavailable' })
|
||||
return
|
||||
}
|
||||
|
||||
if (url.pathname === '/v1/backups/restore' || url.pathname === '/v1/backups/delete') {
|
||||
const retryAfter = consumeRateLimit(clientAddress(request, options.trustedProxy === true))
|
||||
if (retryAfter !== null) {
|
||||
response.setHeader('retry-after', String(retryAfter))
|
||||
sendJson(response, 429, { error: 'rate_limited' })
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if (request.method === 'POST' && ['/v1/backups', '/v1/backups/restore', '/v1/backups/delete'].includes(url.pathname)) {
|
||||
if (request.headers['content-type']?.split(';', 1)[0].trim().toLowerCase() !== 'application/json') {
|
||||
sendJson(response, 415, { error: 'unsupported_media_type' })
|
||||
return
|
||||
}
|
||||
const parsed = await readJsonBody(request)
|
||||
if (parsed.error) {
|
||||
if (parsed.error === 413) response.setHeader('connection', 'close')
|
||||
sendJson(response, parsed.error, { error: parsed.error === 413 ? 'payload_too_large' : 'invalid_request' })
|
||||
return
|
||||
}
|
||||
if (url.pathname === '/v1/backups/restore') {
|
||||
const keys = parsed.value && typeof parsed.value === 'object' && !Array.isArray(parsed.value)
|
||||
? Object.keys(parsed.value)
|
||||
: []
|
||||
if (keys.length !== 1 || keys[0] !== 'id' || !isCanonicalBase64Url(parsed.value.id, 16, idPattern)) {
|
||||
sendNotFound(response)
|
||||
return
|
||||
}
|
||||
const record = await readRecord(options.rootDir, parsed.value.id)
|
||||
if (!record) {
|
||||
sendNotFound(response)
|
||||
return
|
||||
}
|
||||
sendJson(response, 200, { blob: record.blob })
|
||||
return
|
||||
}
|
||||
if (url.pathname === '/v1/backups/delete') {
|
||||
const keys = parsed.value && typeof parsed.value === 'object' && !Array.isArray(parsed.value)
|
||||
? Object.keys(parsed.value).sort()
|
||||
: []
|
||||
const valid = keys.join(',') === 'deleteSecret,id'
|
||||
&& isCanonicalBase64Url(parsed.value.id, 16, idPattern)
|
||||
&& isCanonicalBase64Url(parsed.value.deleteSecret, 32, verifierPattern)
|
||||
if (!valid) {
|
||||
sendNotFound(response)
|
||||
return
|
||||
}
|
||||
const deleted = await runStorageMutation(() => withStorageLock(options.rootDir, async () => {
|
||||
const record = await readRecord(options.rootDir, parsed.value.id)
|
||||
if (!record || !verifierMatches(parsed.value.deleteSecret, record.deleteVerifier)) return false
|
||||
try {
|
||||
await unlink(recordPath(options.rootDir, parsed.value.id))
|
||||
return true
|
||||
} catch (error) {
|
||||
if (error.code === 'ENOENT') return false
|
||||
throw error
|
||||
}
|
||||
}))
|
||||
if (!deleted) {
|
||||
sendNotFound(response)
|
||||
return
|
||||
}
|
||||
response.statusCode = 204
|
||||
response.end()
|
||||
return
|
||||
}
|
||||
if (typeof parsed.value?.blob === 'string' && blobPattern.test(parsed.value.blob) && Buffer.from(parsed.value.blob, 'base64url').length > maxBlobBytes) {
|
||||
sendJson(response, 413, { error: 'payload_too_large' })
|
||||
return
|
||||
}
|
||||
if (!isValidBackup(parsed.value)) {
|
||||
sendJson(response, 400, { error: 'invalid_request' })
|
||||
return
|
||||
}
|
||||
const uploadRetryAfter = consumeUploadRateLimit(clientAddress(request, options.trustedProxy === true))
|
||||
if (uploadRetryAfter !== null) {
|
||||
response.setHeader('retry-after', String(uploadRetryAfter))
|
||||
sendJson(response, 429, { error: 'rate_limited' })
|
||||
return
|
||||
}
|
||||
const result = await runStorageMutation(() => withStorageLock(
|
||||
options.rootDir,
|
||||
() => createRecord(options.rootDir, parsed.value, maxStorageBytes)
|
||||
))
|
||||
if (result === 'duplicate') {
|
||||
sendJson(response, 409, { error: 'already_exists' })
|
||||
return
|
||||
}
|
||||
if (result === 'full') {
|
||||
sendJson(response, 507, { error: 'insufficient_storage' })
|
||||
return
|
||||
}
|
||||
sendJson(response, 201, { created: true })
|
||||
return
|
||||
}
|
||||
|
||||
sendNotFound(response)
|
||||
} catch {
|
||||
if (!response.headersSent) sendJson(response, 500, { error: 'internal_error' })
|
||||
else response.destroy()
|
||||
}
|
||||
})
|
||||
}
|
||||
@ -1,511 +0,0 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { createHash, randomBytes } from 'node:crypto'
|
||||
import { mkdir, mkdtemp, readFile, readdir, rm, utimes, writeFile } from 'node:fs/promises'
|
||||
import { request as createHttpRequest } from 'node:http'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import test from 'node:test'
|
||||
import lockfile from 'proper-lockfile'
|
||||
import { createBackupServer } from '../src/server.mjs'
|
||||
|
||||
const allowedOrigin = 'https://downloads.24-music.de'
|
||||
|
||||
function backupFixture() {
|
||||
const deleteSecret = randomBytes(32).toString('base64url')
|
||||
return {
|
||||
deleteSecret,
|
||||
payload: {
|
||||
id: randomBytes(16).toString('base64url'),
|
||||
blob: randomBytes(96).toString('base64url'),
|
||||
deleteVerifier: createHash('sha256').update(Buffer.from(deleteSecret, 'base64url')).digest('base64url')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function startApi(options = {}) {
|
||||
const rootDir = await mkdtemp(join(tmpdir(), 'mdd-backup-api-'))
|
||||
const server = createBackupServer({
|
||||
rootDir,
|
||||
allowedOrigins: [allowedOrigin],
|
||||
rateLimit: { max: 100, windowMs: 60_000 },
|
||||
...options
|
||||
})
|
||||
await new Promise((resolve, reject) => {
|
||||
server.once('error', reject)
|
||||
server.listen(0, '127.0.0.1', resolve)
|
||||
})
|
||||
const address = server.address()
|
||||
return {
|
||||
rootDir,
|
||||
server,
|
||||
baseUrl: `http://127.0.0.1:${address.port}`,
|
||||
async close() {
|
||||
await new Promise((resolve, reject) => server.close(error => error ? reject(error) : resolve()))
|
||||
await rm(rootDir, { recursive: true, force: true })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function request(api, path, options = {}) {
|
||||
return fetch(`${api.baseUrl}${path}`, options)
|
||||
}
|
||||
|
||||
test('health endpoint reports readiness without exposing storage details', async t => {
|
||||
const api = await startApi()
|
||||
t.after(() => api.close())
|
||||
|
||||
const response = await request(api, '/health')
|
||||
|
||||
assert.equal(response.status, 200)
|
||||
assert.deepEqual(await response.json(), { status: 'ok' })
|
||||
assert.equal(response.headers.get('cache-control'), 'no-store')
|
||||
})
|
||||
|
||||
test('health endpoint rejects an unusable storage path', async t => {
|
||||
const container = await mkdtemp(join(tmpdir(), 'mdd-backup-health-'))
|
||||
const rootDir = join(container, 'not-a-directory')
|
||||
await writeFile(rootDir, 'occupied')
|
||||
const server = createBackupServer({ rootDir, allowedOrigins: [allowedOrigin] })
|
||||
await new Promise((resolve, reject) => {
|
||||
server.once('error', reject)
|
||||
server.listen(0, '127.0.0.1', resolve)
|
||||
})
|
||||
const baseUrl = `http://127.0.0.1:${server.address().port}`
|
||||
t.after(async () => {
|
||||
await new Promise(resolve => server.close(resolve))
|
||||
await rm(container, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
const response = await fetch(`${baseUrl}/health`)
|
||||
|
||||
assert.equal(response.status, 503)
|
||||
assert.deepEqual(await response.json(), { status: 'unavailable' })
|
||||
})
|
||||
|
||||
test('creates and retrieves an immutable opaque backup across server restarts', async t => {
|
||||
const api = await startApi()
|
||||
const fixture = backupFixture()
|
||||
t.after(async () => {
|
||||
if (api.server.listening) await new Promise(resolve => api.server.close(resolve))
|
||||
await rm(api.rootDir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
const created = await request(api, '/v1/backups', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json', origin: allowedOrigin },
|
||||
body: JSON.stringify(fixture.payload)
|
||||
})
|
||||
|
||||
assert.equal(created.status, 201)
|
||||
assert.deepEqual(await created.json(), { created: true })
|
||||
await new Promise(resolve => api.server.close(resolve))
|
||||
|
||||
api.server = createBackupServer({ rootDir: api.rootDir, allowedOrigins: [allowedOrigin] })
|
||||
await new Promise((resolve, reject) => {
|
||||
api.server.once('error', reject)
|
||||
api.server.listen(0, '127.0.0.1', resolve)
|
||||
})
|
||||
api.baseUrl = `http://127.0.0.1:${api.server.address().port}`
|
||||
|
||||
const retrieved = await request(api, '/v1/backups/restore', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json', origin: allowedOrigin },
|
||||
body: JSON.stringify({ id: fixture.payload.id })
|
||||
})
|
||||
assert.equal(retrieved.status, 200)
|
||||
assert.deepEqual(await retrieved.json(), { blob: fixture.payload.blob })
|
||||
|
||||
const duplicate = await request(api, '/v1/backups', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json', origin: allowedOrigin },
|
||||
body: JSON.stringify({ ...fixture.payload, blob: randomBytes(96).toString('base64url') })
|
||||
})
|
||||
assert.equal(duplicate.status, 409)
|
||||
|
||||
const unchanged = await request(api, '/v1/backups/restore', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ id: fixture.payload.id })
|
||||
})
|
||||
assert.deepEqual(await unchanged.json(), { blob: fixture.payload.blob })
|
||||
})
|
||||
|
||||
test('validates IDs, verifiers, content type, JSON and opaque blob encoding', async t => {
|
||||
const api = await startApi()
|
||||
t.after(() => api.close())
|
||||
const fixture = backupFixture()
|
||||
const invalidPayloads = [
|
||||
{ ...fixture.payload, id: 'short' },
|
||||
{ ...fixture.payload, blob: 'not+base64url' },
|
||||
{ ...fixture.payload, deleteVerifier: 'short' },
|
||||
{ id: fixture.payload.id, blob: fixture.payload.blob },
|
||||
{ ...fixture.payload, extra: true }
|
||||
]
|
||||
|
||||
for (const payload of invalidPayloads) {
|
||||
const response = await request(api, '/v1/backups', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify(payload)
|
||||
})
|
||||
assert.equal(response.status, 400)
|
||||
}
|
||||
|
||||
const malformed = await request(api, '/v1/backups', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: '{'
|
||||
})
|
||||
assert.equal(malformed.status, 400)
|
||||
|
||||
const wrongType = await request(api, '/v1/backups', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'text/plain' },
|
||||
body: JSON.stringify(fixture.payload)
|
||||
})
|
||||
assert.equal(wrongType.status, 415)
|
||||
})
|
||||
|
||||
test('accepts a 256 KiB decoded blob and rejects one byte more', async t => {
|
||||
const api = await startApi()
|
||||
t.after(() => api.close())
|
||||
const fixture = backupFixture()
|
||||
|
||||
const accepted = await request(api, '/v1/backups', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ ...fixture.payload, blob: randomBytes(262_144).toString('base64url') })
|
||||
})
|
||||
const oversizedFixture = backupFixture()
|
||||
const rejected = await request(api, '/v1/backups', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ ...oversizedFixture.payload, blob: randomBytes(262_145).toString('base64url') })
|
||||
})
|
||||
|
||||
assert.equal(accepted.status, 201)
|
||||
assert.equal(rejected.status, 413)
|
||||
})
|
||||
|
||||
test('responds before an oversized request body finishes streaming', async t => {
|
||||
const api = await startApi()
|
||||
t.after(() => api.close())
|
||||
const url = new URL('/v1/backups', api.baseUrl)
|
||||
const client = createHttpRequest(url, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' }
|
||||
})
|
||||
t.after(() => client.destroy())
|
||||
|
||||
const responsePromise = new Promise((resolve, reject) => {
|
||||
client.once('response', resolve)
|
||||
client.once('error', reject)
|
||||
})
|
||||
client.write('A'.repeat(393_217))
|
||||
const response = await Promise.race([
|
||||
responsePromise,
|
||||
new Promise((_, reject) => setTimeout(() => reject(new Error('Server did not reject streaming body')), 1_000))
|
||||
])
|
||||
|
||||
assert.equal(response.statusCode, 413)
|
||||
response.resume()
|
||||
})
|
||||
|
||||
test('deletes only with the matching client secret and uses constant not-found responses', async t => {
|
||||
const api = await startApi()
|
||||
t.after(() => api.close())
|
||||
const fixture = backupFixture()
|
||||
await request(api, '/v1/backups', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify(fixture.payload)
|
||||
})
|
||||
|
||||
const missing = await request(api, '/v1/backups/restore', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ id: randomBytes(16).toString('base64url') })
|
||||
})
|
||||
const unauthorized = await request(api, '/v1/backups/delete', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ id: fixture.payload.id })
|
||||
})
|
||||
const wrongSecret = await request(api, '/v1/backups/delete', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ id: fixture.payload.id, deleteSecret: randomBytes(32).toString('base64url') })
|
||||
})
|
||||
|
||||
assert.equal(missing.status, 404)
|
||||
assert.equal(unauthorized.status, 404)
|
||||
assert.equal(wrongSecret.status, 404)
|
||||
const missingBody = await missing.text()
|
||||
const unauthorizedBody = await unauthorized.text()
|
||||
const wrongSecretBody = await wrongSecret.text()
|
||||
assert.equal(missingBody, unauthorizedBody)
|
||||
assert.equal(unauthorizedBody, wrongSecretBody)
|
||||
assert.equal(wrongSecretBody, '{"error":"not_found"}')
|
||||
|
||||
const deleted = await request(api, '/v1/backups/delete', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ id: fixture.payload.id, deleteSecret: fixture.deleteSecret })
|
||||
})
|
||||
assert.equal(deleted.status, 204)
|
||||
assert.equal((await readdir(api.rootDir)).length, 0)
|
||||
|
||||
const afterDelete = await request(api, '/v1/backups/restore', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ id: fixture.payload.id })
|
||||
})
|
||||
assert.equal(afterDelete.status, 404)
|
||||
})
|
||||
|
||||
test('allows only configured browser origins and supports preflight', async t => {
|
||||
const api = await startApi()
|
||||
t.after(() => api.close())
|
||||
|
||||
const allowed = await request(api, '/health', { headers: { origin: allowedOrigin } })
|
||||
assert.equal(allowed.headers.get('access-control-allow-origin'), allowedOrigin)
|
||||
assert.equal(allowed.headers.get('vary'), 'Origin')
|
||||
|
||||
const denied = await request(api, '/health', { headers: { origin: 'https://attacker.example' } })
|
||||
assert.equal(denied.status, 403)
|
||||
assert.equal(denied.headers.get('access-control-allow-origin'), null)
|
||||
|
||||
const preflight = await request(api, '/v1/backups', {
|
||||
method: 'OPTIONS',
|
||||
headers: {
|
||||
origin: allowedOrigin,
|
||||
'access-control-request-method': 'POST',
|
||||
'access-control-request-headers': 'content-type'
|
||||
}
|
||||
})
|
||||
assert.equal(preflight.status, 204)
|
||||
assert.equal(preflight.headers.get('access-control-allow-origin'), allowedOrigin)
|
||||
assert.match(preflight.headers.get('access-control-allow-methods'), /POST/)
|
||||
})
|
||||
|
||||
test('rate limits backup routes without limiting health checks', async t => {
|
||||
const api = await startApi({ trustedProxy: true, rateLimit: { max: 2, windowMs: 60_000 } })
|
||||
t.after(() => api.close())
|
||||
|
||||
const restore = (id, address) => request(api, '/v1/backups/restore', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json', 'x-forwarded-for': address },
|
||||
body: JSON.stringify({ id })
|
||||
})
|
||||
const first = await restore(randomBytes(16).toString('base64url'), '198.51.100.1')
|
||||
const independent = await restore(randomBytes(16).toString('base64url'), '198.51.100.2')
|
||||
const second = await restore(randomBytes(16).toString('base64url'), '198.51.100.1')
|
||||
const limited = await restore(randomBytes(16).toString('base64url'), '198.51.100.1')
|
||||
const health = await request(api, '/health')
|
||||
|
||||
assert.equal(first.status, 404)
|
||||
assert.equal(independent.status, 404)
|
||||
assert.equal(second.status, 404)
|
||||
assert.equal(limited.status, 429)
|
||||
assert.equal(limited.headers.get('retry-after'), '60')
|
||||
assert.equal(health.status, 200)
|
||||
})
|
||||
|
||||
test('enforces an atomic global storage capacity without blocking existing restores', async t => {
|
||||
const api = await startApi({ maxStorageBytes: 420 })
|
||||
const secondServer = createBackupServer({ rootDir: api.rootDir, allowedOrigins: [allowedOrigin], maxStorageBytes: 420 })
|
||||
await new Promise((resolve, reject) => {
|
||||
secondServer.once('error', reject)
|
||||
secondServer.listen(0, '127.0.0.1', resolve)
|
||||
})
|
||||
const secondBaseUrl = `http://127.0.0.1:${secondServer.address().port}`
|
||||
t.after(() => api.close())
|
||||
t.after(() => new Promise(resolve => secondServer.close(resolve)))
|
||||
const first = backupFixture()
|
||||
const second = backupFixture()
|
||||
const create = (fixture, baseUrl = api.baseUrl) => fetch(`${baseUrl}/v1/backups`, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify(fixture.payload)
|
||||
})
|
||||
|
||||
const results = await Promise.all([create(first), create(second, secondBaseUrl)])
|
||||
|
||||
assert.deepEqual(results.map(response => response.status).sort(), [201, 507])
|
||||
const stored = results[0].status === 201 ? first : second
|
||||
const restored = await request(api, '/v1/backups/restore', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ id: stored.payload.id })
|
||||
})
|
||||
assert.equal(restored.status, 200)
|
||||
const storedFiles = await readdir(api.rootDir)
|
||||
assert.equal(storedFiles.length, 1)
|
||||
assert.match(storedFiles[0], /^[A-Za-z0-9_-]{22}\.json$/)
|
||||
|
||||
const deleted = await request(api, '/v1/backups/delete', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ id: stored.payload.id, deleteSecret: stored.deleteSecret })
|
||||
})
|
||||
assert.equal(deleted.status, 204)
|
||||
const blocked = stored === first ? second : first
|
||||
assert.equal((await create(blocked)).status, 201)
|
||||
})
|
||||
|
||||
test('limits anonymous uploads separately while keeping restores available', async t => {
|
||||
const api = await startApi({
|
||||
rateLimit: { max: 2, windowMs: 60_000 },
|
||||
uploadRateLimit: { max: 1, windowMs: 60_000 }
|
||||
})
|
||||
t.after(() => api.close())
|
||||
const first = backupFixture()
|
||||
const second = backupFixture()
|
||||
const create = fixture => request(api, '/v1/backups', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify(fixture.payload)
|
||||
})
|
||||
|
||||
assert.equal((await create(first)).status, 201)
|
||||
assert.equal((await create(second)).status, 429)
|
||||
const restored = await request(api, '/v1/backups/restore', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ id: first.payload.id })
|
||||
})
|
||||
assert.equal(restored.status, 200)
|
||||
})
|
||||
|
||||
test('never takes over an old storage lock that may still have an active owner', async t => {
|
||||
const api = await startApi()
|
||||
t.after(() => api.close())
|
||||
const lockPath = join(api.rootDir, '.storage.lock')
|
||||
const release = await lockfile.lock(api.rootDir, {
|
||||
realpath: false,
|
||||
lockfilePath: lockPath,
|
||||
stale: 30_000,
|
||||
update: 10_000
|
||||
})
|
||||
t.after(() => release().catch(() => {}))
|
||||
const fixture = backupFixture()
|
||||
const pending = request(api, '/v1/backups', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify(fixture.payload)
|
||||
})
|
||||
|
||||
const early = await Promise.race([
|
||||
pending.then(() => 'responded'),
|
||||
new Promise(resolve => setTimeout(() => resolve('waiting'), 100))
|
||||
])
|
||||
|
||||
assert.equal(early, 'waiting')
|
||||
await release()
|
||||
assert.equal((await pending).status, 201)
|
||||
})
|
||||
|
||||
test('recovers a storage lock left by a terminated process', async t => {
|
||||
const api = await startApi()
|
||||
t.after(() => api.close())
|
||||
const lockPath = join(api.rootDir, '.storage.lock')
|
||||
await mkdir(lockPath)
|
||||
const old = new Date(Date.now() - 60_000)
|
||||
await utimes(lockPath, old, old)
|
||||
const fixture = backupFixture()
|
||||
const pending = request(api, '/v1/backups', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify(fixture.payload)
|
||||
})
|
||||
|
||||
const early = await Promise.race([
|
||||
pending.then(response => response.status),
|
||||
new Promise(resolve => setTimeout(() => resolve('timeout'), 500))
|
||||
])
|
||||
await pending
|
||||
|
||||
assert.equal(early, 201)
|
||||
assert.equal((await readdir(api.rootDir)).some(name => name.includes('.stale.')), false)
|
||||
})
|
||||
|
||||
test('revalidates delete authorization inside the storage lock', async t => {
|
||||
const api = await startApi()
|
||||
t.after(() => api.close())
|
||||
const original = backupFixture()
|
||||
const replacement = backupFixture()
|
||||
replacement.payload.id = original.payload.id
|
||||
await request(api, '/v1/backups', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify(original.payload)
|
||||
})
|
||||
const lockPath = join(api.rootDir, '.storage.lock')
|
||||
const release = await lockfile.lock(api.rootDir, {
|
||||
realpath: false,
|
||||
lockfilePath: lockPath,
|
||||
stale: 30_000,
|
||||
update: 10_000
|
||||
})
|
||||
t.after(() => release().catch(() => {}))
|
||||
const pendingDelete = request(api, '/v1/backups/delete', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ id: original.payload.id, deleteSecret: original.deleteSecret })
|
||||
})
|
||||
await new Promise(resolve => setTimeout(resolve, 50))
|
||||
await writeFile(join(api.rootDir, `${original.payload.id}.json`), JSON.stringify({
|
||||
version: 1,
|
||||
blob: replacement.payload.blob,
|
||||
deleteVerifier: replacement.payload.deleteVerifier,
|
||||
createdAt: new Date().toISOString()
|
||||
}))
|
||||
await release()
|
||||
|
||||
assert.equal((await pendingDelete).status, 404)
|
||||
const restored = await request(api, '/v1/backups/restore', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ id: original.payload.id })
|
||||
})
|
||||
assert.equal(restored.status, 200)
|
||||
assert.deepEqual(await restored.json(), { blob: replacement.payload.blob })
|
||||
})
|
||||
|
||||
test('never accepts backup IDs in URLs', async t => {
|
||||
const api = await startApi()
|
||||
t.after(() => api.close())
|
||||
const id = randomBytes(16).toString('base64url')
|
||||
|
||||
const read = await request(api, `/v1/backups/${id}`)
|
||||
const remove = await request(api, `/v1/backups/${id}`, { method: 'DELETE' })
|
||||
const query = await request(api, `/v1/backups?backup=${id}`, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify(backupFixture().payload)
|
||||
})
|
||||
|
||||
assert.equal(read.status, 404)
|
||||
assert.equal(remove.status, 404)
|
||||
assert.equal(query.status, 404)
|
||||
})
|
||||
|
||||
test('stored records contain no delete secret and operational logs contain no IDs or blobs', async t => {
|
||||
const messages = []
|
||||
const api = await startApi({ logger: message => messages.push(String(message)) })
|
||||
t.after(() => api.close())
|
||||
const fixture = backupFixture()
|
||||
|
||||
await request(api, '/v1/backups', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify(fixture.payload)
|
||||
})
|
||||
|
||||
const files = await readdir(api.rootDir)
|
||||
assert.equal(files.length, 1)
|
||||
const stored = await readFile(join(api.rootDir, files[0]), 'utf8')
|
||||
assert.equal(stored.includes(fixture.deleteSecret), false)
|
||||
assert.equal(messages.some(message => message.includes(fixture.payload.id)), false)
|
||||
assert.equal(messages.some(message => message.includes(fixture.payload.blob)), false)
|
||||
})
|
||||
@ -1,197 +1,197 @@
|
||||
import type { AppSettings, DebridAccountStatus } from "../shared/types";
|
||||
import { parseMegaDebridAccounts, type MegaDebridAccountEntry } from "../shared/mega-debrid-accounts";
|
||||
import { parseDebridLinkApiKeys, type DebridLinkApiKeyEntry } from "../shared/debrid-link-keys";
|
||||
import { logger } from "./logger";
|
||||
import { compactErrorText } from "./utils";
|
||||
|
||||
const MEGA_DEBRID_API = "https://www.mega-debrid.eu/api.php";
|
||||
const DEBRID_LINK_API = "https://debrid-link.com/api/v2";
|
||||
const CHECK_USER_AGENT =
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0 Safari/537.36";
|
||||
const CHECK_TIMEOUT_MS = 20000;
|
||||
|
||||
function timeoutSignal(signal: AbortSignal | undefined, ms: number): AbortSignal {
|
||||
const timeout = AbortSignal.timeout(ms);
|
||||
return signal ? AbortSignal.any([signal, timeout]) : timeout;
|
||||
}
|
||||
|
||||
function parseJsonSafe(text: string): Record<string, unknown> | null {
|
||||
try {
|
||||
const parsed = JSON.parse(text) as unknown;
|
||||
return parsed && typeof parsed === "object" ? (parsed as Record<string, unknown>) : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function formatRemaining(premiumUntilMs: number | null, now: number): string {
|
||||
if (premiumUntilMs == null) {
|
||||
return "Premium-Status unbekannt";
|
||||
}
|
||||
if (premiumUntilMs <= 0) {
|
||||
return "Kein Premium";
|
||||
}
|
||||
const remainingMs = premiumUntilMs - now;
|
||||
if (remainingMs <= 0) {
|
||||
return "Premium abgelaufen";
|
||||
}
|
||||
const days = Math.floor(remainingMs / (24 * 60 * 60 * 1000));
|
||||
if (days >= 1) {
|
||||
return `Premium noch ${days} Tag${days === 1 ? "" : "e"}`;
|
||||
}
|
||||
const hours = Math.max(1, Math.floor(remainingMs / (60 * 60 * 1000)));
|
||||
return `Premium noch ${hours} Std`;
|
||||
}
|
||||
|
||||
export async function checkMegaDebridAccount(
|
||||
account: MegaDebridAccountEntry,
|
||||
signal?: AbortSignal,
|
||||
now = Date.now()
|
||||
): Promise<DebridAccountStatus> {
|
||||
const base: DebridAccountStatus = {
|
||||
accountId: account.id,
|
||||
provider: "megadebrid",
|
||||
label: account.label,
|
||||
maskedLogin: account.maskedLogin,
|
||||
valid: false,
|
||||
isPremium: false,
|
||||
premiumUntilMs: null,
|
||||
message: "",
|
||||
checkedAt: now
|
||||
};
|
||||
try {
|
||||
const url = `${MEGA_DEBRID_API}?action=connectUser&login=${encodeURIComponent(account.login)}&password=${encodeURIComponent(account.password)}`;
|
||||
const response = await fetch(url, {
|
||||
headers: { "User-Agent": CHECK_USER_AGENT },
|
||||
signal: timeoutSignal(signal, CHECK_TIMEOUT_MS)
|
||||
});
|
||||
const text = await response.text();
|
||||
const payload = parseJsonSafe(text);
|
||||
if (!response.ok || !payload) {
|
||||
return { ...base, message: `Login fehlgeschlagen (HTTP ${response.status})` };
|
||||
}
|
||||
if (payload.response_code !== "ok") {
|
||||
const reason = String(payload.response_text || payload.response_code || "Login abgelehnt");
|
||||
return { ...base, message: `Ungueltiger Login: ${reason}` };
|
||||
}
|
||||
const vipEndRaw = Number(payload.vip_end || 0);
|
||||
const premiumUntilMs = Number.isFinite(vipEndRaw) && vipEndRaw > 0 ? vipEndRaw * 1000 : 0;
|
||||
const isPremium = premiumUntilMs > now;
|
||||
const email = String(payload.email || "").trim() || undefined;
|
||||
return {
|
||||
...base,
|
||||
valid: true,
|
||||
isPremium,
|
||||
premiumUntilMs,
|
||||
email,
|
||||
message: formatRemaining(premiumUntilMs, now)
|
||||
};
|
||||
} catch (error) {
|
||||
const errText = compactErrorText(error);
|
||||
const aborted = signal?.aborted || /aborted/i.test(errText);
|
||||
return {
|
||||
...base,
|
||||
message: aborted ? "Pruefung abgebrochen" : `Pruefung fehlgeschlagen: ${errText}`
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export async function checkDebridLinkKey(
|
||||
key: DebridLinkApiKeyEntry,
|
||||
signal?: AbortSignal,
|
||||
now = Date.now()
|
||||
): Promise<DebridAccountStatus> {
|
||||
const base: DebridAccountStatus = {
|
||||
accountId: key.id,
|
||||
provider: "debridlink",
|
||||
label: key.label,
|
||||
maskedLogin: key.masked,
|
||||
valid: false,
|
||||
isPremium: false,
|
||||
premiumUntilMs: null,
|
||||
message: "",
|
||||
checkedAt: now
|
||||
};
|
||||
try {
|
||||
const response = await fetch(`${DEBRID_LINK_API}/account/infos`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${key.token}`,
|
||||
"User-Agent": CHECK_USER_AGENT
|
||||
},
|
||||
signal: timeoutSignal(signal, CHECK_TIMEOUT_MS)
|
||||
});
|
||||
const text = await response.text();
|
||||
const payload = parseJsonSafe(text);
|
||||
if (!response.ok || !payload) {
|
||||
if (response.status === 401 || response.status === 403) {
|
||||
return { ...base, message: "Ungueltiger API-Key (nicht autorisiert)" };
|
||||
}
|
||||
return { ...base, message: `Pruefung fehlgeschlagen (HTTP ${response.status})` };
|
||||
}
|
||||
if (payload.success === false) {
|
||||
const reason = String(payload.error || "Key abgelehnt");
|
||||
return { ...base, message: `Ungueltiger API-Key: ${reason}` };
|
||||
}
|
||||
const value = (payload.value && typeof payload.value === "object" ? payload.value : payload) as Record<string, unknown>;
|
||||
const premiumLeftSec = Number(value.premiumLeft || 0);
|
||||
const accountType = Number(value.accountType || 0);
|
||||
const premiumUntilMs = Number.isFinite(premiumLeftSec) && premiumLeftSec > 0 ? now + premiumLeftSec * 1000 : 0;
|
||||
const isPremium = premiumUntilMs > now || accountType > 0;
|
||||
const username = String(value.username || "").trim() || undefined;
|
||||
return {
|
||||
...base,
|
||||
valid: true,
|
||||
isPremium,
|
||||
premiumUntilMs: premiumUntilMs > 0 ? premiumUntilMs : (accountType > 0 ? null : 0),
|
||||
email: username,
|
||||
message: premiumUntilMs > 0
|
||||
? formatRemaining(premiumUntilMs, now)
|
||||
: (accountType > 0 ? "Premium aktiv" : "Kein Premium (Free)")
|
||||
};
|
||||
} catch (error) {
|
||||
const errText = compactErrorText(error);
|
||||
const aborted = signal?.aborted || /aborted/i.test(errText);
|
||||
return {
|
||||
...base,
|
||||
message: aborted ? "Pruefung abgebrochen" : `Pruefung fehlgeschlagen: ${errText}`
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export async function checkAllDebridAccounts(
|
||||
settings: AppSettings,
|
||||
signal?: AbortSignal
|
||||
): Promise<DebridAccountStatus[]> {
|
||||
const now = Date.now();
|
||||
const megaAccounts = parseMegaDebridAccounts(settings.megaCredentials || "", settings.megaPassword || "");
|
||||
const debridLinkKeys = parseDebridLinkApiKeys(settings.debridLinkApiKeys || "");
|
||||
|
||||
const taskFns: Array<() => Promise<DebridAccountStatus>> = [
|
||||
...megaAccounts.map((account) => () => checkMegaDebridAccount(account, signal, now)),
|
||||
...debridLinkKeys.map((key) => () => checkDebridLinkKey(key, signal, now))
|
||||
];
|
||||
|
||||
const results = await runWithConcurrency(taskFns, CHECK_CONCURRENCY);
|
||||
logger.info(
|
||||
`Account-Check abgeschlossen: ${results.length} Accounts geprueft ` +
|
||||
`(${results.filter((r) => r.valid).length} gueltig, ${results.filter((r) => r.isPremium).length} premium)`
|
||||
);
|
||||
return results;
|
||||
}
|
||||
|
||||
const CHECK_CONCURRENCY = 4;
|
||||
|
||||
async function runWithConcurrency<T>(taskFns: Array<() => Promise<T>>, limit: number): Promise<T[]> {
|
||||
const results: T[] = new Array(taskFns.length);
|
||||
let nextIndex = 0;
|
||||
const worker = async (): Promise<void> => {
|
||||
while (nextIndex < taskFns.length) {
|
||||
const current = nextIndex;
|
||||
nextIndex += 1;
|
||||
results[current] = await taskFns[current]();
|
||||
}
|
||||
};
|
||||
const workers = Array.from({ length: Math.min(limit, taskFns.length) }, () => worker());
|
||||
await Promise.all(workers);
|
||||
return results;
|
||||
}
|
||||
import type { AppSettings, DebridAccountStatus } from "../shared/types";
|
||||
import { parseMegaDebridAccounts, type MegaDebridAccountEntry } from "../shared/mega-debrid-accounts";
|
||||
import { parseDebridLinkApiKeys, type DebridLinkApiKeyEntry } from "../shared/debrid-link-keys";
|
||||
import { logger } from "./logger";
|
||||
import { compactErrorText } from "./utils";
|
||||
|
||||
const MEGA_DEBRID_API = "https://www.mega-debrid.eu/api.php";
|
||||
const DEBRID_LINK_API = "https://debrid-link.com/api/v2";
|
||||
const CHECK_USER_AGENT =
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0 Safari/537.36";
|
||||
const CHECK_TIMEOUT_MS = 20000;
|
||||
|
||||
function timeoutSignal(signal: AbortSignal | undefined, ms: number): AbortSignal {
|
||||
const timeout = AbortSignal.timeout(ms);
|
||||
return signal ? AbortSignal.any([signal, timeout]) : timeout;
|
||||
}
|
||||
|
||||
function parseJsonSafe(text: string): Record<string, unknown> | null {
|
||||
try {
|
||||
const parsed = JSON.parse(text) as unknown;
|
||||
return parsed && typeof parsed === "object" ? (parsed as Record<string, unknown>) : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function formatRemaining(premiumUntilMs: number | null, now: number): string {
|
||||
if (premiumUntilMs == null) {
|
||||
return "Premium-Status unbekannt";
|
||||
}
|
||||
if (premiumUntilMs <= 0) {
|
||||
return "Kein Premium";
|
||||
}
|
||||
const remainingMs = premiumUntilMs - now;
|
||||
if (remainingMs <= 0) {
|
||||
return "Premium abgelaufen";
|
||||
}
|
||||
const days = Math.floor(remainingMs / (24 * 60 * 60 * 1000));
|
||||
if (days >= 1) {
|
||||
return `Premium noch ${days} Tag${days === 1 ? "" : "e"}`;
|
||||
}
|
||||
const hours = Math.max(1, Math.floor(remainingMs / (60 * 60 * 1000)));
|
||||
return `Premium noch ${hours} Std`;
|
||||
}
|
||||
|
||||
export async function checkMegaDebridAccount(
|
||||
account: MegaDebridAccountEntry,
|
||||
signal?: AbortSignal,
|
||||
now = Date.now()
|
||||
): Promise<DebridAccountStatus> {
|
||||
const base: DebridAccountStatus = {
|
||||
accountId: account.id,
|
||||
provider: "megadebrid",
|
||||
label: account.label,
|
||||
maskedLogin: account.maskedLogin,
|
||||
valid: false,
|
||||
isPremium: false,
|
||||
premiumUntilMs: null,
|
||||
message: "",
|
||||
checkedAt: now
|
||||
};
|
||||
try {
|
||||
const url = `${MEGA_DEBRID_API}?action=connectUser&login=${encodeURIComponent(account.login)}&password=${encodeURIComponent(account.password)}`;
|
||||
const response = await fetch(url, {
|
||||
headers: { "User-Agent": CHECK_USER_AGENT },
|
||||
signal: timeoutSignal(signal, CHECK_TIMEOUT_MS)
|
||||
});
|
||||
const text = await response.text();
|
||||
const payload = parseJsonSafe(text);
|
||||
if (!response.ok || !payload) {
|
||||
return { ...base, message: `Login fehlgeschlagen (HTTP ${response.status})` };
|
||||
}
|
||||
if (payload.response_code !== "ok") {
|
||||
const reason = String(payload.response_text || payload.response_code || "Login abgelehnt");
|
||||
return { ...base, message: `Ungueltiger Login: ${reason}` };
|
||||
}
|
||||
const vipEndRaw = Number(payload.vip_end || 0);
|
||||
const premiumUntilMs = Number.isFinite(vipEndRaw) && vipEndRaw > 0 ? vipEndRaw * 1000 : 0;
|
||||
const isPremium = premiumUntilMs > now;
|
||||
const email = String(payload.email || "").trim() || undefined;
|
||||
return {
|
||||
...base,
|
||||
valid: true,
|
||||
isPremium,
|
||||
premiumUntilMs,
|
||||
email,
|
||||
message: formatRemaining(premiumUntilMs, now)
|
||||
};
|
||||
} catch (error) {
|
||||
const errText = compactErrorText(error);
|
||||
const aborted = signal?.aborted || /aborted/i.test(errText);
|
||||
return {
|
||||
...base,
|
||||
message: aborted ? "Pruefung abgebrochen" : `Pruefung fehlgeschlagen: ${errText}`
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export async function checkDebridLinkKey(
|
||||
key: DebridLinkApiKeyEntry,
|
||||
signal?: AbortSignal,
|
||||
now = Date.now()
|
||||
): Promise<DebridAccountStatus> {
|
||||
const base: DebridAccountStatus = {
|
||||
accountId: key.id,
|
||||
provider: "debridlink",
|
||||
label: key.label,
|
||||
maskedLogin: key.masked,
|
||||
valid: false,
|
||||
isPremium: false,
|
||||
premiumUntilMs: null,
|
||||
message: "",
|
||||
checkedAt: now
|
||||
};
|
||||
try {
|
||||
const response = await fetch(`${DEBRID_LINK_API}/account/infos`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${key.token}`,
|
||||
"User-Agent": CHECK_USER_AGENT
|
||||
},
|
||||
signal: timeoutSignal(signal, CHECK_TIMEOUT_MS)
|
||||
});
|
||||
const text = await response.text();
|
||||
const payload = parseJsonSafe(text);
|
||||
if (!response.ok || !payload) {
|
||||
if (response.status === 401 || response.status === 403) {
|
||||
return { ...base, message: "Ungueltiger API-Key (nicht autorisiert)" };
|
||||
}
|
||||
return { ...base, message: `Pruefung fehlgeschlagen (HTTP ${response.status})` };
|
||||
}
|
||||
if (payload.success === false) {
|
||||
const reason = String(payload.error || "Key abgelehnt");
|
||||
return { ...base, message: `Ungueltiger API-Key: ${reason}` };
|
||||
}
|
||||
const value = (payload.value && typeof payload.value === "object" ? payload.value : payload) as Record<string, unknown>;
|
||||
const premiumLeftSec = Number(value.premiumLeft || 0);
|
||||
const accountType = Number(value.accountType || 0);
|
||||
const premiumUntilMs = Number.isFinite(premiumLeftSec) && premiumLeftSec > 0 ? now + premiumLeftSec * 1000 : 0;
|
||||
const isPremium = premiumUntilMs > now || accountType > 0;
|
||||
const username = String(value.username || "").trim() || undefined;
|
||||
return {
|
||||
...base,
|
||||
valid: true,
|
||||
isPremium,
|
||||
premiumUntilMs: premiumUntilMs > 0 ? premiumUntilMs : (accountType > 0 ? null : 0),
|
||||
email: username,
|
||||
message: premiumUntilMs > 0
|
||||
? formatRemaining(premiumUntilMs, now)
|
||||
: (accountType > 0 ? "Premium aktiv" : "Kein Premium (Free)")
|
||||
};
|
||||
} catch (error) {
|
||||
const errText = compactErrorText(error);
|
||||
const aborted = signal?.aborted || /aborted/i.test(errText);
|
||||
return {
|
||||
...base,
|
||||
message: aborted ? "Pruefung abgebrochen" : `Pruefung fehlgeschlagen: ${errText}`
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export async function checkAllDebridAccounts(
|
||||
settings: AppSettings,
|
||||
signal?: AbortSignal
|
||||
): Promise<DebridAccountStatus[]> {
|
||||
const now = Date.now();
|
||||
const megaAccounts = parseMegaDebridAccounts(settings.megaCredentials || "", settings.megaPassword || "");
|
||||
const debridLinkKeys = parseDebridLinkApiKeys(settings.debridLinkApiKeys || "");
|
||||
|
||||
const taskFns: Array<() => Promise<DebridAccountStatus>> = [
|
||||
...megaAccounts.map((account) => () => checkMegaDebridAccount(account, signal, now)),
|
||||
...debridLinkKeys.map((key) => () => checkDebridLinkKey(key, signal, now))
|
||||
];
|
||||
|
||||
const results = await runWithConcurrency(taskFns, CHECK_CONCURRENCY);
|
||||
logger.info(
|
||||
`Account-Check abgeschlossen: ${results.length} Accounts geprueft ` +
|
||||
`(${results.filter((r) => r.valid).length} gueltig, ${results.filter((r) => r.isPremium).length} premium)`
|
||||
);
|
||||
return results;
|
||||
}
|
||||
|
||||
const CHECK_CONCURRENCY = 4;
|
||||
|
||||
async function runWithConcurrency<T>(taskFns: Array<() => Promise<T>>, limit: number): Promise<T[]> {
|
||||
const results: T[] = new Array(taskFns.length);
|
||||
let nextIndex = 0;
|
||||
const worker = async (): Promise<void> => {
|
||||
while (nextIndex < taskFns.length) {
|
||||
const current = nextIndex;
|
||||
nextIndex += 1;
|
||||
results[current] = await taskFns[current]();
|
||||
}
|
||||
};
|
||||
const workers = Array.from({ length: Math.min(limit, taskFns.length) }, () => worker());
|
||||
await Promise.all(workers);
|
||||
return results;
|
||||
}
|
||||
|
||||
@ -1,204 +1,204 @@
|
||||
import fs from "node:fs";
|
||||
import { logTimestamp } from "./log-timestamp";
|
||||
import path from "node:path";
|
||||
import { AsyncLocalStorage } from "node:async_hooks";
|
||||
import type { RotationEvent } from "../shared/types";
|
||||
|
||||
export type RotationItemSink = (event: RotationEvent) => void;
|
||||
const rotationItemContext = new AsyncLocalStorage<RotationItemSink>();
|
||||
|
||||
export function runWithRotationItemSink<T>(sink: RotationItemSink, fn: () => Promise<T>): Promise<T> {
|
||||
return rotationItemContext.run(sink, fn);
|
||||
}
|
||||
|
||||
type RotationLevel = "INFO" | "WARN" | "ERROR";
|
||||
|
||||
const ROTATION_EVENT_RING_MAX = 60;
|
||||
const rotationEventRing: RotationEvent[] = [];
|
||||
let rotationEventSeq = 0;
|
||||
let rotationEventListener: ((event: RotationEvent) => void) | null = null;
|
||||
|
||||
export function setRotationEventListener(listener: ((event: RotationEvent) => void) | null): void {
|
||||
rotationEventListener = listener;
|
||||
}
|
||||
|
||||
export function getRecentRotationEvents(limit = ROTATION_EVENT_RING_MAX): RotationEvent[] {
|
||||
const slice = rotationEventRing.slice(-limit);
|
||||
slice.reverse();
|
||||
return slice;
|
||||
}
|
||||
|
||||
function isUiRelevantRotationEvent(event: string): boolean {
|
||||
return event !== "TEST";
|
||||
}
|
||||
|
||||
function pushRotationEvent(
|
||||
level: RotationLevel,
|
||||
provider: string,
|
||||
accountLabel: string,
|
||||
event: string,
|
||||
fields?: Record<string, unknown>,
|
||||
at = Date.now()
|
||||
): void {
|
||||
rotationEventSeq += 1;
|
||||
const entry: RotationEvent = {
|
||||
id: `rot_${at}_${rotationEventSeq}`,
|
||||
at,
|
||||
level,
|
||||
provider,
|
||||
accountLabel,
|
||||
event,
|
||||
reason: fields && fields.reason != null ? String(fields.reason) : undefined,
|
||||
category: fields && fields.category != null ? String(fields.category) : undefined,
|
||||
cooldownSec: fields && fields.cooldownSec != null ? Number(fields.cooldownSec) || 0 : undefined,
|
||||
next: fields && fields.next != null ? String(fields.next) : undefined
|
||||
};
|
||||
|
||||
const itemSink = rotationItemContext.getStore();
|
||||
if (itemSink) {
|
||||
try {
|
||||
itemSink(entry);
|
||||
} catch {
|
||||
}
|
||||
}
|
||||
|
||||
if (!isUiRelevantRotationEvent(event)) {
|
||||
return;
|
||||
}
|
||||
rotationEventRing.push(entry);
|
||||
if (rotationEventRing.length > ROTATION_EVENT_RING_MAX) {
|
||||
rotationEventRing.splice(0, rotationEventRing.length - ROTATION_EVENT_RING_MAX);
|
||||
}
|
||||
if (rotationEventListener) {
|
||||
try {
|
||||
rotationEventListener(entry);
|
||||
} catch {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const ROTATION_LOG_MAX_FILE_BYTES = Number(process.env.RD_ACCOUNT_ROTATION_LOG_MAX_BYTES || 5 * 1024 * 1024);
|
||||
const ROTATION_LOG_RETENTION_DAYS = Number(process.env.RD_ACCOUNT_ROTATION_LOG_RETENTION_DAYS || 14);
|
||||
|
||||
let rotationLogPath: string | null = null;
|
||||
|
||||
function sanitizeFieldValue(value: unknown): string {
|
||||
if (value === undefined || value === null) {
|
||||
return "";
|
||||
}
|
||||
if (typeof value === "string") {
|
||||
return value.replace(/\r?\n/g, "\\n");
|
||||
}
|
||||
if (typeof value === "number" || typeof value === "boolean") {
|
||||
return String(value);
|
||||
}
|
||||
try {
|
||||
return JSON.stringify(value).replace(/\r?\n/g, "\\n");
|
||||
} catch {
|
||||
return String(value);
|
||||
}
|
||||
}
|
||||
|
||||
function formatFields(fields?: Record<string, unknown>): string {
|
||||
if (!fields) {
|
||||
return "";
|
||||
}
|
||||
const parts = Object.entries(fields)
|
||||
.filter(([, value]) => value !== undefined && value !== null && sanitizeFieldValue(value) !== "")
|
||||
.map(([key, value]) => `${key}=${sanitizeFieldValue(value)}`);
|
||||
return parts.length > 0 ? ` | ${parts.join(" | ")}` : "";
|
||||
}
|
||||
|
||||
function rotateIfNeeded(filePath: string): void {
|
||||
try {
|
||||
const stat = fs.statSync(filePath);
|
||||
if (stat.size < ROTATION_LOG_MAX_FILE_BYTES) {
|
||||
return;
|
||||
}
|
||||
const backup = `${filePath}.old`;
|
||||
try {
|
||||
fs.rmSync(backup, { force: true });
|
||||
} catch {
|
||||
}
|
||||
fs.renameSync(filePath, backup);
|
||||
} catch {
|
||||
}
|
||||
}
|
||||
|
||||
function cleanupOldBackup(filePath: string): void {
|
||||
const backup = `${filePath}.old`;
|
||||
try {
|
||||
const stat = fs.statSync(backup);
|
||||
const cutoff = Date.now() - ROTATION_LOG_RETENTION_DAYS * 24 * 60 * 60 * 1000;
|
||||
if (stat.mtimeMs < cutoff) {
|
||||
fs.rmSync(backup, { force: true });
|
||||
}
|
||||
} catch {
|
||||
}
|
||||
}
|
||||
|
||||
export function initAccountRotationLog(baseDir: string): void {
|
||||
rotationLogPath = path.join(baseDir, "account-rotation.log");
|
||||
try {
|
||||
fs.mkdirSync(path.dirname(rotationLogPath), { recursive: true });
|
||||
cleanupOldBackup(rotationLogPath);
|
||||
if (!fs.existsSync(rotationLogPath)) {
|
||||
fs.writeFileSync(rotationLogPath, "", "utf8");
|
||||
}
|
||||
rotateIfNeeded(rotationLogPath);
|
||||
if (!fs.existsSync(rotationLogPath)) {
|
||||
fs.writeFileSync(rotationLogPath, "", "utf8");
|
||||
}
|
||||
fs.appendFileSync(
|
||||
rotationLogPath,
|
||||
`=== Account-Rotation Log Start: ${logTimestamp()} ===\n`,
|
||||
"utf8"
|
||||
);
|
||||
} catch {
|
||||
rotationLogPath = null;
|
||||
}
|
||||
}
|
||||
|
||||
export function logAccountRotation(
|
||||
level: RotationLevel,
|
||||
provider: string,
|
||||
accountLabel: string,
|
||||
event: string,
|
||||
fields?: Record<string, unknown>
|
||||
): void {
|
||||
pushRotationEvent(level, provider, accountLabel, event, fields);
|
||||
if (!rotationLogPath) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
rotateIfNeeded(rotationLogPath);
|
||||
if (!fs.existsSync(rotationLogPath)) {
|
||||
fs.writeFileSync(rotationLogPath, "", "utf8");
|
||||
}
|
||||
const head = `${logTimestamp()} [${level}] ${provider} | ${accountLabel} | ${event}`;
|
||||
fs.appendFileSync(rotationLogPath, `${head}${formatFields(fields)}\n`, "utf8");
|
||||
} catch {
|
||||
}
|
||||
}
|
||||
|
||||
export function getAccountRotationLogPath(): string | null {
|
||||
if (!rotationLogPath) {
|
||||
return null;
|
||||
}
|
||||
return fs.existsSync(rotationLogPath) ? rotationLogPath : null;
|
||||
}
|
||||
|
||||
export function shutdownAccountRotationLog(): void {
|
||||
if (!rotationLogPath) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
fs.appendFileSync(
|
||||
rotationLogPath,
|
||||
`=== Account-Rotation Log Ende: ${logTimestamp()} ===\n`,
|
||||
"utf8"
|
||||
);
|
||||
} catch {
|
||||
}
|
||||
rotationLogPath = null;
|
||||
}
|
||||
import fs from "node:fs";
|
||||
import { logTimestamp } from "./log-timestamp";
|
||||
import path from "node:path";
|
||||
import { AsyncLocalStorage } from "node:async_hooks";
|
||||
import type { RotationEvent } from "../shared/types";
|
||||
|
||||
export type RotationItemSink = (event: RotationEvent) => void;
|
||||
const rotationItemContext = new AsyncLocalStorage<RotationItemSink>();
|
||||
|
||||
export function runWithRotationItemSink<T>(sink: RotationItemSink, fn: () => Promise<T>): Promise<T> {
|
||||
return rotationItemContext.run(sink, fn);
|
||||
}
|
||||
|
||||
type RotationLevel = "INFO" | "WARN" | "ERROR";
|
||||
|
||||
const ROTATION_EVENT_RING_MAX = 60;
|
||||
const rotationEventRing: RotationEvent[] = [];
|
||||
let rotationEventSeq = 0;
|
||||
let rotationEventListener: ((event: RotationEvent) => void) | null = null;
|
||||
|
||||
export function setRotationEventListener(listener: ((event: RotationEvent) => void) | null): void {
|
||||
rotationEventListener = listener;
|
||||
}
|
||||
|
||||
export function getRecentRotationEvents(limit = ROTATION_EVENT_RING_MAX): RotationEvent[] {
|
||||
const slice = rotationEventRing.slice(-limit);
|
||||
slice.reverse();
|
||||
return slice;
|
||||
}
|
||||
|
||||
function isUiRelevantRotationEvent(event: string): boolean {
|
||||
return event !== "TEST";
|
||||
}
|
||||
|
||||
function pushRotationEvent(
|
||||
level: RotationLevel,
|
||||
provider: string,
|
||||
accountLabel: string,
|
||||
event: string,
|
||||
fields?: Record<string, unknown>,
|
||||
at = Date.now()
|
||||
): void {
|
||||
rotationEventSeq += 1;
|
||||
const entry: RotationEvent = {
|
||||
id: `rot_${at}_${rotationEventSeq}`,
|
||||
at,
|
||||
level,
|
||||
provider,
|
||||
accountLabel,
|
||||
event,
|
||||
reason: fields && fields.reason != null ? String(fields.reason) : undefined,
|
||||
category: fields && fields.category != null ? String(fields.category) : undefined,
|
||||
cooldownSec: fields && fields.cooldownSec != null ? Number(fields.cooldownSec) || 0 : undefined,
|
||||
next: fields && fields.next != null ? String(fields.next) : undefined
|
||||
};
|
||||
|
||||
const itemSink = rotationItemContext.getStore();
|
||||
if (itemSink) {
|
||||
try {
|
||||
itemSink(entry);
|
||||
} catch {
|
||||
}
|
||||
}
|
||||
|
||||
if (!isUiRelevantRotationEvent(event)) {
|
||||
return;
|
||||
}
|
||||
rotationEventRing.push(entry);
|
||||
if (rotationEventRing.length > ROTATION_EVENT_RING_MAX) {
|
||||
rotationEventRing.splice(0, rotationEventRing.length - ROTATION_EVENT_RING_MAX);
|
||||
}
|
||||
if (rotationEventListener) {
|
||||
try {
|
||||
rotationEventListener(entry);
|
||||
} catch {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const ROTATION_LOG_MAX_FILE_BYTES = Number(process.env.RD_ACCOUNT_ROTATION_LOG_MAX_BYTES || 5 * 1024 * 1024);
|
||||
const ROTATION_LOG_RETENTION_DAYS = Number(process.env.RD_ACCOUNT_ROTATION_LOG_RETENTION_DAYS || 14);
|
||||
|
||||
let rotationLogPath: string | null = null;
|
||||
|
||||
function sanitizeFieldValue(value: unknown): string {
|
||||
if (value === undefined || value === null) {
|
||||
return "";
|
||||
}
|
||||
if (typeof value === "string") {
|
||||
return value.replace(/\r?\n/g, "\\n");
|
||||
}
|
||||
if (typeof value === "number" || typeof value === "boolean") {
|
||||
return String(value);
|
||||
}
|
||||
try {
|
||||
return JSON.stringify(value).replace(/\r?\n/g, "\\n");
|
||||
} catch {
|
||||
return String(value);
|
||||
}
|
||||
}
|
||||
|
||||
function formatFields(fields?: Record<string, unknown>): string {
|
||||
if (!fields) {
|
||||
return "";
|
||||
}
|
||||
const parts = Object.entries(fields)
|
||||
.filter(([, value]) => value !== undefined && value !== null && sanitizeFieldValue(value) !== "")
|
||||
.map(([key, value]) => `${key}=${sanitizeFieldValue(value)}`);
|
||||
return parts.length > 0 ? ` | ${parts.join(" | ")}` : "";
|
||||
}
|
||||
|
||||
function rotateIfNeeded(filePath: string): void {
|
||||
try {
|
||||
const stat = fs.statSync(filePath);
|
||||
if (stat.size < ROTATION_LOG_MAX_FILE_BYTES) {
|
||||
return;
|
||||
}
|
||||
const backup = `${filePath}.old`;
|
||||
try {
|
||||
fs.rmSync(backup, { force: true });
|
||||
} catch {
|
||||
}
|
||||
fs.renameSync(filePath, backup);
|
||||
} catch {
|
||||
}
|
||||
}
|
||||
|
||||
function cleanupOldBackup(filePath: string): void {
|
||||
const backup = `${filePath}.old`;
|
||||
try {
|
||||
const stat = fs.statSync(backup);
|
||||
const cutoff = Date.now() - ROTATION_LOG_RETENTION_DAYS * 24 * 60 * 60 * 1000;
|
||||
if (stat.mtimeMs < cutoff) {
|
||||
fs.rmSync(backup, { force: true });
|
||||
}
|
||||
} catch {
|
||||
}
|
||||
}
|
||||
|
||||
export function initAccountRotationLog(baseDir: string): void {
|
||||
rotationLogPath = path.join(baseDir, "account-rotation.log");
|
||||
try {
|
||||
fs.mkdirSync(path.dirname(rotationLogPath), { recursive: true });
|
||||
cleanupOldBackup(rotationLogPath);
|
||||
if (!fs.existsSync(rotationLogPath)) {
|
||||
fs.writeFileSync(rotationLogPath, "", "utf8");
|
||||
}
|
||||
rotateIfNeeded(rotationLogPath);
|
||||
if (!fs.existsSync(rotationLogPath)) {
|
||||
fs.writeFileSync(rotationLogPath, "", "utf8");
|
||||
}
|
||||
fs.appendFileSync(
|
||||
rotationLogPath,
|
||||
`=== Account-Rotation Log Start: ${logTimestamp()} ===\n`,
|
||||
"utf8"
|
||||
);
|
||||
} catch {
|
||||
rotationLogPath = null;
|
||||
}
|
||||
}
|
||||
|
||||
export function logAccountRotation(
|
||||
level: RotationLevel,
|
||||
provider: string,
|
||||
accountLabel: string,
|
||||
event: string,
|
||||
fields?: Record<string, unknown>
|
||||
): void {
|
||||
pushRotationEvent(level, provider, accountLabel, event, fields);
|
||||
if (!rotationLogPath) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
rotateIfNeeded(rotationLogPath);
|
||||
if (!fs.existsSync(rotationLogPath)) {
|
||||
fs.writeFileSync(rotationLogPath, "", "utf8");
|
||||
}
|
||||
const head = `${logTimestamp()} [${level}] ${provider} | ${accountLabel} | ${event}`;
|
||||
fs.appendFileSync(rotationLogPath, `${head}${formatFields(fields)}\n`, "utf8");
|
||||
} catch {
|
||||
}
|
||||
}
|
||||
|
||||
export function getAccountRotationLogPath(): string | null {
|
||||
if (!rotationLogPath) {
|
||||
return null;
|
||||
}
|
||||
return fs.existsSync(rotationLogPath) ? rotationLogPath : null;
|
||||
}
|
||||
|
||||
export function shutdownAccountRotationLog(): void {
|
||||
if (!rotationLogPath) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
fs.appendFileSync(
|
||||
rotationLogPath,
|
||||
`=== Account-Rotation Log Ende: ${logTimestamp()} ===\n`,
|
||||
"utf8"
|
||||
);
|
||||
} catch {
|
||||
}
|
||||
rotationLogPath = null;
|
||||
}
|
||||
|
||||
@ -1,482 +1,482 @@
|
||||
import { BrowserWindow, session } from "electron";
|
||||
import { AllDebridHostInfo } from "../shared/types";
|
||||
import { UnrestrictedLink } from "./realdebrid";
|
||||
import { filenameFromUrl, sleep } from "./utils";
|
||||
|
||||
const ALLDEBRID_BASE_URL = "https://alldebrid.com";
|
||||
const ALLDEBRID_LOGIN_URL = `${ALLDEBRID_BASE_URL}/register/?from=de`;
|
||||
const ALLDEBRID_SERVICE_URL = `${ALLDEBRID_BASE_URL}/service.php`;
|
||||
const ALLDEBRID_SERVICE_REFERER = `${ALLDEBRID_BASE_URL}/service/?from=de`;
|
||||
const ALLDEBRID_DELAYED_URL = `${ALLDEBRID_BASE_URL}/internalapi/v4/link/delayed`;
|
||||
const ALLDEBRID_STATUS_URL = `${ALLDEBRID_BASE_URL}/status/`;
|
||||
const ALLDEBRID_PERSISTENT_PARTITION = "persist:alldebrid-web";
|
||||
const ALLDEBRID_TRANSIENT_PARTITION = "alldebrid-web";
|
||||
const ALLDEBRID_USER_AGENT = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/133.0.0.0 Safari/537.36";
|
||||
|
||||
type DelayedStatusPayload = {
|
||||
status: number;
|
||||
link: string;
|
||||
timeLeft: number;
|
||||
};
|
||||
|
||||
type GenerateOutcome =
|
||||
| { kind: "success"; value: UnrestrictedLink }
|
||||
| { kind: "login_required" };
|
||||
|
||||
function abortError(): Error {
|
||||
return new Error("aborted:alldebrid-web");
|
||||
}
|
||||
|
||||
function withTimeoutSignal(signal: AbortSignal | undefined, timeoutMs: number): AbortSignal {
|
||||
const timeoutSignal = AbortSignal.timeout(timeoutMs);
|
||||
if (!signal) {
|
||||
return timeoutSignal;
|
||||
}
|
||||
return AbortSignal.any([signal, timeoutSignal]);
|
||||
}
|
||||
|
||||
function throwIfAborted(signal?: AbortSignal): void {
|
||||
if (signal?.aborted) {
|
||||
throw abortError();
|
||||
}
|
||||
}
|
||||
|
||||
async function sleepWithSignal(ms: number, signal?: AbortSignal): Promise<void> {
|
||||
if (!signal) {
|
||||
await sleep(ms);
|
||||
return;
|
||||
}
|
||||
if (signal.aborted) {
|
||||
throw abortError();
|
||||
}
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
let timer: NodeJS.Timeout | null = setTimeout(() => {
|
||||
timer = null;
|
||||
signal.removeEventListener("abort", onAbort);
|
||||
resolve();
|
||||
}, Math.max(0, ms));
|
||||
|
||||
const onAbort = (): void => {
|
||||
if (timer) {
|
||||
clearTimeout(timer);
|
||||
timer = null;
|
||||
}
|
||||
signal.removeEventListener("abort", onAbort);
|
||||
reject(abortError());
|
||||
};
|
||||
|
||||
signal.addEventListener("abort", onAbort, { once: true });
|
||||
});
|
||||
}
|
||||
|
||||
function asRecord(value: unknown): Record<string, unknown> | null {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
return null;
|
||||
}
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function pickString(payload: Record<string, unknown> | null, keys: string[]): string {
|
||||
if (!payload) {
|
||||
return "";
|
||||
}
|
||||
for (const key of keys) {
|
||||
const value = payload[key];
|
||||
if (typeof value === "string" && value.trim()) {
|
||||
return value.trim();
|
||||
}
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
function pickNumber(payload: Record<string, unknown> | null, keys: string[]): number | null {
|
||||
if (!payload) {
|
||||
return null;
|
||||
}
|
||||
for (const key of keys) {
|
||||
const value = Number(payload[key] ?? NaN);
|
||||
if (Number.isFinite(value) && value >= 0) {
|
||||
return Math.floor(value);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function parseJson(text: string): Record<string, unknown> | null {
|
||||
try {
|
||||
return asRecord(JSON.parse(text) as unknown);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeHostName(value: string): string {
|
||||
return String(value || "").replace(/[^a-z0-9]+/gi, "").toLowerCase();
|
||||
}
|
||||
|
||||
function toHostStateFromIcon(url: string): AllDebridHostInfo["state"] {
|
||||
const normalized = String(url || "").toLowerCase();
|
||||
if (normalized.includes("up.gif")) {
|
||||
return "up";
|
||||
}
|
||||
if (normalized.includes("down.gif")) {
|
||||
return "down";
|
||||
}
|
||||
if (normalized.includes("not.tracked")) {
|
||||
return "not_tracked";
|
||||
}
|
||||
return "unknown";
|
||||
}
|
||||
|
||||
function toHostStatusLabel(state: AllDebridHostInfo["state"]): string {
|
||||
if (state === "up") {
|
||||
return "Verfügbar";
|
||||
}
|
||||
if (state === "down") {
|
||||
return "Unverfügbar";
|
||||
}
|
||||
if (state === "not_tracked") {
|
||||
return "Nicht getrackt";
|
||||
}
|
||||
return "Unbekannt";
|
||||
}
|
||||
|
||||
function extractHostInfoFromStatusPage(html: string, host: string): AllDebridHostInfo | null {
|
||||
const wanted = normalizeHostName(host);
|
||||
const rowRegex = /<tr class=['"]g1['"]>\s*<td[^>]*>[\s\S]*?<i[^>]*alt=['"]([^'"]+)['"][^>]*>[\s\S]*?<\/td>\s*<td[^>]*class=['"]comparatif_content['"][^>]*>[\s\S]*?<img[^>]*src=['"]([^'"]+)['"][^>]*>[\s\S]*?\((?:<span[^>]*data-fdate=['"](\d+)['"][^>]*><\/span>|([^<)]*))\)/gi;
|
||||
|
||||
for (let match = rowRegex.exec(html); match; match = rowRegex.exec(html)) {
|
||||
const hostAlt = normalizeHostName(match[1] || "");
|
||||
if (hostAlt !== wanted) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const state = toHostStateFromIcon(match[2] || "");
|
||||
const lastCheckedSeconds = Number(match[3] ?? NaN);
|
||||
return {
|
||||
host,
|
||||
source: "web",
|
||||
state,
|
||||
statusLabel: toHostStatusLabel(state),
|
||||
fetchedAt: Date.now(),
|
||||
lastCheckedAt: Number.isFinite(lastCheckedSeconds) ? lastCheckedSeconds * 1000 : null,
|
||||
quota: null,
|
||||
quotaMax: null,
|
||||
quotaType: "",
|
||||
limitSimuDl: null,
|
||||
note: "Quota und Simultan-Slots sind per Web-Login nicht öffentlich verfügbar."
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export class AllDebridWebFallback {
|
||||
private queue: Promise<unknown> = Promise.resolve();
|
||||
|
||||
private loginWindow: BrowserWindow | null = null;
|
||||
|
||||
private loginWindowPartition = "";
|
||||
|
||||
private getRememberSession: () => boolean;
|
||||
|
||||
public constructor(getRememberSession: () => boolean) {
|
||||
this.getRememberSession = getRememberSession;
|
||||
}
|
||||
|
||||
public async unrestrict(link: string, signal?: AbortSignal): Promise<UnrestrictedLink | null> {
|
||||
const overallSignal = withTimeoutSignal(signal, 10 * 60 * 1000);
|
||||
return this.runExclusive(async () => {
|
||||
throwIfAborted(overallSignal);
|
||||
if (!String(link || "").trim()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const initial = await this.generate(link, overallSignal);
|
||||
if (initial.kind === "success") {
|
||||
return initial.value;
|
||||
}
|
||||
return this.waitForLoginAndGenerate(link, overallSignal);
|
||||
}, overallSignal);
|
||||
}
|
||||
|
||||
public async openLoginWindow(): Promise<void> {
|
||||
const window = await this.ensureLoginWindow();
|
||||
if (window.isMinimized()) {
|
||||
window.restore();
|
||||
}
|
||||
window.show();
|
||||
window.focus();
|
||||
}
|
||||
|
||||
public async getHostInfo(host: string): Promise<AllDebridHostInfo> {
|
||||
const currentSession = session.fromPartition(this.getPartition());
|
||||
const response = await currentSession.fetch(ALLDEBRID_STATUS_URL, {
|
||||
headers: {
|
||||
Accept: "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
|
||||
Referer: ALLDEBRID_SERVICE_REFERER,
|
||||
"User-Agent": ALLDEBRID_USER_AGENT
|
||||
},
|
||||
signal: withTimeoutSignal(undefined, 30_000)
|
||||
});
|
||||
const text = await response.text();
|
||||
if (!response.ok) {
|
||||
throw new Error(`AllDebrid Web Status HTTP ${response.status}`);
|
||||
}
|
||||
if (!/id=['"]statusContainer['"]/i.test(text)) {
|
||||
throw new Error("AllDebrid Web-Status nicht verfügbar. Bitte zuerst im AllDebrid-Fenster einloggen.");
|
||||
}
|
||||
const info = extractHostInfoFromStatusPage(text, host);
|
||||
if (!info) {
|
||||
throw new Error(`AllDebrid Web-Status für ${host} nicht gefunden`);
|
||||
}
|
||||
return info;
|
||||
}
|
||||
|
||||
public async clearSessions(): Promise<void> {
|
||||
this.disposeLoginWindow();
|
||||
for (const partition of [ALLDEBRID_PERSISTENT_PARTITION, ALLDEBRID_TRANSIENT_PARTITION]) {
|
||||
const currentSession = session.fromPartition(partition);
|
||||
try {
|
||||
await currentSession.clearStorageData({
|
||||
storages: ["cookies", "indexdb", "localstorage", "serviceworkers", "cachestorage"]
|
||||
});
|
||||
} catch {
|
||||
}
|
||||
try {
|
||||
await currentSession.clearCache();
|
||||
} catch {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public dispose(): void {
|
||||
this.disposeLoginWindow();
|
||||
}
|
||||
|
||||
private getPartition(): string {
|
||||
return this.getRememberSession() ? ALLDEBRID_PERSISTENT_PARTITION : ALLDEBRID_TRANSIENT_PARTITION;
|
||||
}
|
||||
|
||||
private disposeLoginWindow(): void {
|
||||
const current = this.loginWindow;
|
||||
this.loginWindow = null;
|
||||
this.loginWindowPartition = "";
|
||||
if (current && !current.isDestroyed()) {
|
||||
current.close();
|
||||
}
|
||||
}
|
||||
|
||||
private async runExclusive<T>(job: () => Promise<T>, signal?: AbortSignal): Promise<T> {
|
||||
const queuedAt = Date.now();
|
||||
const queueWaitTimeoutMs = 90_000;
|
||||
const guardedJob = async (): Promise<T> => {
|
||||
throwIfAborted(signal);
|
||||
const waited = Date.now() - queuedAt;
|
||||
if (waited > queueWaitTimeoutMs) {
|
||||
throw new Error(`AllDebrid-Web Queue-Timeout (${Math.floor(waited / 1000)}s gewartet)`);
|
||||
}
|
||||
return job();
|
||||
};
|
||||
const run = this.queue.then(guardedJob, guardedJob);
|
||||
this.queue = run.then(() => undefined, () => undefined);
|
||||
return run;
|
||||
}
|
||||
|
||||
private async ensureLoginWindow(): Promise<BrowserWindow> {
|
||||
const partition = this.getPartition();
|
||||
const existing = this.loginWindow;
|
||||
if (existing && !existing.isDestroyed() && this.loginWindowPartition === partition) {
|
||||
return existing;
|
||||
}
|
||||
|
||||
if (existing && !existing.isDestroyed()) {
|
||||
existing.close();
|
||||
}
|
||||
|
||||
const window = new BrowserWindow({
|
||||
width: 1120,
|
||||
height: 900,
|
||||
minWidth: 980,
|
||||
minHeight: 760,
|
||||
autoHideMenuBar: true,
|
||||
title: "AllDebrid Web-Login",
|
||||
webPreferences: {
|
||||
partition,
|
||||
contextIsolation: true,
|
||||
nodeIntegration: false
|
||||
}
|
||||
});
|
||||
window.setMenuBarVisibility(false);
|
||||
window.on("closed", () => {
|
||||
if (this.loginWindow === window) {
|
||||
this.loginWindow = null;
|
||||
this.loginWindowPartition = "";
|
||||
}
|
||||
});
|
||||
this.loginWindow = window;
|
||||
this.loginWindowPartition = partition;
|
||||
await window.loadURL(ALLDEBRID_LOGIN_URL);
|
||||
return window;
|
||||
}
|
||||
|
||||
private async postForm(
|
||||
url: string,
|
||||
body: URLSearchParams,
|
||||
referer: string,
|
||||
signal?: AbortSignal
|
||||
): Promise<{ response: Response; text: string }> {
|
||||
const currentSession = session.fromPartition(this.getPartition());
|
||||
const response = await currentSession.fetch(url, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Accept: "application/json, text/javascript, */*; q=0.01",
|
||||
"Content-Type": "application/x-www-form-urlencoded; charset=UTF-8",
|
||||
Origin: ALLDEBRID_BASE_URL,
|
||||
Referer: referer,
|
||||
"User-Agent": ALLDEBRID_USER_AGENT,
|
||||
"X-Requested-With": "XMLHttpRequest"
|
||||
},
|
||||
body: body.toString(),
|
||||
signal: withTimeoutSignal(signal, 30_000)
|
||||
});
|
||||
const text = await response.text();
|
||||
return { response, text };
|
||||
}
|
||||
|
||||
private async generate(link: string, signal?: AbortSignal): Promise<GenerateOutcome> {
|
||||
throwIfAborted(signal);
|
||||
const body = new URLSearchParams({
|
||||
link,
|
||||
nb: "0",
|
||||
json: "true",
|
||||
pw: ""
|
||||
});
|
||||
const { response, text } = await this.postForm(ALLDEBRID_SERVICE_URL, body, ALLDEBRID_SERVICE_REFERER, signal);
|
||||
if (!response.ok) {
|
||||
throw new Error(`AllDebrid Web HTTP ${response.status}`);
|
||||
}
|
||||
|
||||
const trimmed = text.trim();
|
||||
if (trimmed === "login") {
|
||||
return { kind: "login_required" };
|
||||
}
|
||||
|
||||
const payload = parseJson(trimmed);
|
||||
if (!payload) {
|
||||
throw new Error("AllDebrid Web lieferte keine JSON-Antwort");
|
||||
}
|
||||
|
||||
const errorText = pickString(payload, ["error"]);
|
||||
if (errorText) {
|
||||
if (errorText.toLowerCase() === "premium") {
|
||||
throw new Error("AllDebrid Web: Premium erforderlich");
|
||||
}
|
||||
throw new Error(`AllDebrid Web: ${errorText}`);
|
||||
}
|
||||
|
||||
const directUrl = pickString(payload, ["link"]);
|
||||
const fileName = pickString(payload, ["filename"]);
|
||||
const fileSize = pickNumber(payload, ["filesize"]);
|
||||
if (directUrl) {
|
||||
return {
|
||||
kind: "success",
|
||||
value: {
|
||||
directUrl,
|
||||
fileName: fileName || filenameFromUrl(directUrl) || filenameFromUrl(link),
|
||||
fileSize,
|
||||
retriesUsed: 0
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const delayedId = payload.delayed;
|
||||
if (delayedId !== undefined && delayedId !== null && delayedId !== false && String(delayedId).trim()) {
|
||||
const delayed = await this.waitForDelayedLink(String(delayedId).trim(), signal);
|
||||
return {
|
||||
kind: "success",
|
||||
value: {
|
||||
directUrl: delayed.link,
|
||||
fileName: fileName || filenameFromUrl(delayed.link) || filenameFromUrl(link),
|
||||
fileSize: fileSize,
|
||||
retriesUsed: 0
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
if (Array.isArray(payload.streams) && payload.streams.length > 0) {
|
||||
throw new Error("AllDebrid Web: Streaming-Auswahl wird derzeit nicht unterstützt");
|
||||
}
|
||||
|
||||
throw new Error("AllDebrid Web: Antwort ohne Download-Link");
|
||||
}
|
||||
|
||||
private async waitForDelayedLink(delayedId: string, signal?: AbortSignal): Promise<DelayedStatusPayload> {
|
||||
for (let attempt = 1; attempt <= 120; attempt += 1) {
|
||||
throwIfAborted(signal);
|
||||
const body = new URLSearchParams({ id: delayedId });
|
||||
const { response, text } = await this.postForm(ALLDEBRID_DELAYED_URL, body, ALLDEBRID_SERVICE_REFERER, signal);
|
||||
if (!response.ok) {
|
||||
throw new Error(`AllDebrid Web delayed HTTP ${response.status}`);
|
||||
}
|
||||
const payload = parseJson(text.trim());
|
||||
const data = asRecord(payload?.data);
|
||||
if (pickString(payload, ["status"]).toLowerCase() !== "success" || !data) {
|
||||
throw new Error("AllDebrid Web: Delayed-Status ungültig");
|
||||
}
|
||||
|
||||
const status = Number(data.status ?? NaN);
|
||||
if (!Number.isFinite(status)) {
|
||||
throw new Error("AllDebrid Web: Delayed-Status ohne Status");
|
||||
}
|
||||
|
||||
if (status >= 2) {
|
||||
const link = pickString(data, ["link"]);
|
||||
if (!link) {
|
||||
throw new Error("AllDebrid Web: Delayed-Link fehlt");
|
||||
}
|
||||
return {
|
||||
status,
|
||||
link,
|
||||
timeLeft: Math.max(0, Number(data.time_left ?? 0) || 0)
|
||||
};
|
||||
}
|
||||
|
||||
const timeLeft = Math.max(0, Number(data.time_left ?? 0) || 0);
|
||||
const delayMs = timeLeft > 0 ? Math.min(5_000, Math.max(1_500, timeLeft * 250)) : 2_000;
|
||||
await sleepWithSignal(delayMs, signal);
|
||||
}
|
||||
|
||||
throw new Error("AllDebrid Web: Delayed-Link Timeout");
|
||||
}
|
||||
|
||||
private async waitForLoginAndGenerate(link: string, signal?: AbortSignal): Promise<UnrestrictedLink | null> {
|
||||
const window = await this.ensureLoginWindow();
|
||||
if (window.isMinimized()) {
|
||||
window.restore();
|
||||
}
|
||||
window.show();
|
||||
window.focus();
|
||||
|
||||
const startedAt = Date.now();
|
||||
while (Date.now() - startedAt < 10 * 60 * 1000) {
|
||||
throwIfAborted(signal);
|
||||
if (window.isDestroyed()) {
|
||||
throw new Error("AllDebrid Web-Login abgebrochen");
|
||||
}
|
||||
|
||||
const outcome = await this.generate(link, signal);
|
||||
if (outcome.kind === "success") {
|
||||
if (!window.isDestroyed()) {
|
||||
window.close();
|
||||
}
|
||||
return outcome.value;
|
||||
}
|
||||
|
||||
await sleepWithSignal(1_500, signal);
|
||||
}
|
||||
|
||||
throw new Error("AllDebrid Web-Login Timeout");
|
||||
}
|
||||
}
|
||||
import { BrowserWindow, session } from "electron";
|
||||
import { AllDebridHostInfo } from "../shared/types";
|
||||
import { UnrestrictedLink } from "./realdebrid";
|
||||
import { filenameFromUrl, sleep } from "./utils";
|
||||
|
||||
const ALLDEBRID_BASE_URL = "https://alldebrid.com";
|
||||
const ALLDEBRID_LOGIN_URL = `${ALLDEBRID_BASE_URL}/register/?from=de`;
|
||||
const ALLDEBRID_SERVICE_URL = `${ALLDEBRID_BASE_URL}/service.php`;
|
||||
const ALLDEBRID_SERVICE_REFERER = `${ALLDEBRID_BASE_URL}/service/?from=de`;
|
||||
const ALLDEBRID_DELAYED_URL = `${ALLDEBRID_BASE_URL}/internalapi/v4/link/delayed`;
|
||||
const ALLDEBRID_STATUS_URL = `${ALLDEBRID_BASE_URL}/status/`;
|
||||
const ALLDEBRID_PERSISTENT_PARTITION = "persist:alldebrid-web";
|
||||
const ALLDEBRID_TRANSIENT_PARTITION = "alldebrid-web";
|
||||
const ALLDEBRID_USER_AGENT = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/133.0.0.0 Safari/537.36";
|
||||
|
||||
type DelayedStatusPayload = {
|
||||
status: number;
|
||||
link: string;
|
||||
timeLeft: number;
|
||||
};
|
||||
|
||||
type GenerateOutcome =
|
||||
| { kind: "success"; value: UnrestrictedLink }
|
||||
| { kind: "login_required" };
|
||||
|
||||
function abortError(): Error {
|
||||
return new Error("aborted:alldebrid-web");
|
||||
}
|
||||
|
||||
function withTimeoutSignal(signal: AbortSignal | undefined, timeoutMs: number): AbortSignal {
|
||||
const timeoutSignal = AbortSignal.timeout(timeoutMs);
|
||||
if (!signal) {
|
||||
return timeoutSignal;
|
||||
}
|
||||
return AbortSignal.any([signal, timeoutSignal]);
|
||||
}
|
||||
|
||||
function throwIfAborted(signal?: AbortSignal): void {
|
||||
if (signal?.aborted) {
|
||||
throw abortError();
|
||||
}
|
||||
}
|
||||
|
||||
async function sleepWithSignal(ms: number, signal?: AbortSignal): Promise<void> {
|
||||
if (!signal) {
|
||||
await sleep(ms);
|
||||
return;
|
||||
}
|
||||
if (signal.aborted) {
|
||||
throw abortError();
|
||||
}
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
let timer: NodeJS.Timeout | null = setTimeout(() => {
|
||||
timer = null;
|
||||
signal.removeEventListener("abort", onAbort);
|
||||
resolve();
|
||||
}, Math.max(0, ms));
|
||||
|
||||
const onAbort = (): void => {
|
||||
if (timer) {
|
||||
clearTimeout(timer);
|
||||
timer = null;
|
||||
}
|
||||
signal.removeEventListener("abort", onAbort);
|
||||
reject(abortError());
|
||||
};
|
||||
|
||||
signal.addEventListener("abort", onAbort, { once: true });
|
||||
});
|
||||
}
|
||||
|
||||
function asRecord(value: unknown): Record<string, unknown> | null {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
return null;
|
||||
}
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function pickString(payload: Record<string, unknown> | null, keys: string[]): string {
|
||||
if (!payload) {
|
||||
return "";
|
||||
}
|
||||
for (const key of keys) {
|
||||
const value = payload[key];
|
||||
if (typeof value === "string" && value.trim()) {
|
||||
return value.trim();
|
||||
}
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
function pickNumber(payload: Record<string, unknown> | null, keys: string[]): number | null {
|
||||
if (!payload) {
|
||||
return null;
|
||||
}
|
||||
for (const key of keys) {
|
||||
const value = Number(payload[key] ?? NaN);
|
||||
if (Number.isFinite(value) && value >= 0) {
|
||||
return Math.floor(value);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function parseJson(text: string): Record<string, unknown> | null {
|
||||
try {
|
||||
return asRecord(JSON.parse(text) as unknown);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeHostName(value: string): string {
|
||||
return String(value || "").replace(/[^a-z0-9]+/gi, "").toLowerCase();
|
||||
}
|
||||
|
||||
function toHostStateFromIcon(url: string): AllDebridHostInfo["state"] {
|
||||
const normalized = String(url || "").toLowerCase();
|
||||
if (normalized.includes("up.gif")) {
|
||||
return "up";
|
||||
}
|
||||
if (normalized.includes("down.gif")) {
|
||||
return "down";
|
||||
}
|
||||
if (normalized.includes("not.tracked")) {
|
||||
return "not_tracked";
|
||||
}
|
||||
return "unknown";
|
||||
}
|
||||
|
||||
function toHostStatusLabel(state: AllDebridHostInfo["state"]): string {
|
||||
if (state === "up") {
|
||||
return "Verfügbar";
|
||||
}
|
||||
if (state === "down") {
|
||||
return "Unverfügbar";
|
||||
}
|
||||
if (state === "not_tracked") {
|
||||
return "Nicht getrackt";
|
||||
}
|
||||
return "Unbekannt";
|
||||
}
|
||||
|
||||
function extractHostInfoFromStatusPage(html: string, host: string): AllDebridHostInfo | null {
|
||||
const wanted = normalizeHostName(host);
|
||||
const rowRegex = /<tr class=['"]g1['"]>\s*<td[^>]*>[\s\S]*?<i[^>]*alt=['"]([^'"]+)['"][^>]*>[\s\S]*?<\/td>\s*<td[^>]*class=['"]comparatif_content['"][^>]*>[\s\S]*?<img[^>]*src=['"]([^'"]+)['"][^>]*>[\s\S]*?\((?:<span[^>]*data-fdate=['"](\d+)['"][^>]*><\/span>|([^<)]*))\)/gi;
|
||||
|
||||
for (let match = rowRegex.exec(html); match; match = rowRegex.exec(html)) {
|
||||
const hostAlt = normalizeHostName(match[1] || "");
|
||||
if (hostAlt !== wanted) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const state = toHostStateFromIcon(match[2] || "");
|
||||
const lastCheckedSeconds = Number(match[3] ?? NaN);
|
||||
return {
|
||||
host,
|
||||
source: "web",
|
||||
state,
|
||||
statusLabel: toHostStatusLabel(state),
|
||||
fetchedAt: Date.now(),
|
||||
lastCheckedAt: Number.isFinite(lastCheckedSeconds) ? lastCheckedSeconds * 1000 : null,
|
||||
quota: null,
|
||||
quotaMax: null,
|
||||
quotaType: "",
|
||||
limitSimuDl: null,
|
||||
note: "Quota und Simultan-Slots sind per Web-Login nicht öffentlich verfügbar."
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export class AllDebridWebFallback {
|
||||
private queue: Promise<unknown> = Promise.resolve();
|
||||
|
||||
private loginWindow: BrowserWindow | null = null;
|
||||
|
||||
private loginWindowPartition = "";
|
||||
|
||||
private getRememberSession: () => boolean;
|
||||
|
||||
public constructor(getRememberSession: () => boolean) {
|
||||
this.getRememberSession = getRememberSession;
|
||||
}
|
||||
|
||||
public async unrestrict(link: string, signal?: AbortSignal): Promise<UnrestrictedLink | null> {
|
||||
const overallSignal = withTimeoutSignal(signal, 10 * 60 * 1000);
|
||||
return this.runExclusive(async () => {
|
||||
throwIfAborted(overallSignal);
|
||||
if (!String(link || "").trim()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const initial = await this.generate(link, overallSignal);
|
||||
if (initial.kind === "success") {
|
||||
return initial.value;
|
||||
}
|
||||
return this.waitForLoginAndGenerate(link, overallSignal);
|
||||
}, overallSignal);
|
||||
}
|
||||
|
||||
public async openLoginWindow(): Promise<void> {
|
||||
const window = await this.ensureLoginWindow();
|
||||
if (window.isMinimized()) {
|
||||
window.restore();
|
||||
}
|
||||
window.show();
|
||||
window.focus();
|
||||
}
|
||||
|
||||
public async getHostInfo(host: string): Promise<AllDebridHostInfo> {
|
||||
const currentSession = session.fromPartition(this.getPartition());
|
||||
const response = await currentSession.fetch(ALLDEBRID_STATUS_URL, {
|
||||
headers: {
|
||||
Accept: "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
|
||||
Referer: ALLDEBRID_SERVICE_REFERER,
|
||||
"User-Agent": ALLDEBRID_USER_AGENT
|
||||
},
|
||||
signal: withTimeoutSignal(undefined, 30_000)
|
||||
});
|
||||
const text = await response.text();
|
||||
if (!response.ok) {
|
||||
throw new Error(`AllDebrid Web Status HTTP ${response.status}`);
|
||||
}
|
||||
if (!/id=['"]statusContainer['"]/i.test(text)) {
|
||||
throw new Error("AllDebrid Web-Status nicht verfügbar. Bitte zuerst im AllDebrid-Fenster einloggen.");
|
||||
}
|
||||
const info = extractHostInfoFromStatusPage(text, host);
|
||||
if (!info) {
|
||||
throw new Error(`AllDebrid Web-Status für ${host} nicht gefunden`);
|
||||
}
|
||||
return info;
|
||||
}
|
||||
|
||||
public async clearSessions(): Promise<void> {
|
||||
this.disposeLoginWindow();
|
||||
for (const partition of [ALLDEBRID_PERSISTENT_PARTITION, ALLDEBRID_TRANSIENT_PARTITION]) {
|
||||
const currentSession = session.fromPartition(partition);
|
||||
try {
|
||||
await currentSession.clearStorageData({
|
||||
storages: ["cookies", "indexdb", "localstorage", "serviceworkers", "cachestorage"]
|
||||
});
|
||||
} catch {
|
||||
}
|
||||
try {
|
||||
await currentSession.clearCache();
|
||||
} catch {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public dispose(): void {
|
||||
this.disposeLoginWindow();
|
||||
}
|
||||
|
||||
private getPartition(): string {
|
||||
return this.getRememberSession() ? ALLDEBRID_PERSISTENT_PARTITION : ALLDEBRID_TRANSIENT_PARTITION;
|
||||
}
|
||||
|
||||
private disposeLoginWindow(): void {
|
||||
const current = this.loginWindow;
|
||||
this.loginWindow = null;
|
||||
this.loginWindowPartition = "";
|
||||
if (current && !current.isDestroyed()) {
|
||||
current.close();
|
||||
}
|
||||
}
|
||||
|
||||
private async runExclusive<T>(job: () => Promise<T>, signal?: AbortSignal): Promise<T> {
|
||||
const queuedAt = Date.now();
|
||||
const queueWaitTimeoutMs = 90_000;
|
||||
const guardedJob = async (): Promise<T> => {
|
||||
throwIfAborted(signal);
|
||||
const waited = Date.now() - queuedAt;
|
||||
if (waited > queueWaitTimeoutMs) {
|
||||
throw new Error(`AllDebrid-Web Queue-Timeout (${Math.floor(waited / 1000)}s gewartet)`);
|
||||
}
|
||||
return job();
|
||||
};
|
||||
const run = this.queue.then(guardedJob, guardedJob);
|
||||
this.queue = run.then(() => undefined, () => undefined);
|
||||
return run;
|
||||
}
|
||||
|
||||
private async ensureLoginWindow(): Promise<BrowserWindow> {
|
||||
const partition = this.getPartition();
|
||||
const existing = this.loginWindow;
|
||||
if (existing && !existing.isDestroyed() && this.loginWindowPartition === partition) {
|
||||
return existing;
|
||||
}
|
||||
|
||||
if (existing && !existing.isDestroyed()) {
|
||||
existing.close();
|
||||
}
|
||||
|
||||
const window = new BrowserWindow({
|
||||
width: 1120,
|
||||
height: 900,
|
||||
minWidth: 980,
|
||||
minHeight: 760,
|
||||
autoHideMenuBar: true,
|
||||
title: "AllDebrid Web-Login",
|
||||
webPreferences: {
|
||||
partition,
|
||||
contextIsolation: true,
|
||||
nodeIntegration: false
|
||||
}
|
||||
});
|
||||
window.setMenuBarVisibility(false);
|
||||
window.on("closed", () => {
|
||||
if (this.loginWindow === window) {
|
||||
this.loginWindow = null;
|
||||
this.loginWindowPartition = "";
|
||||
}
|
||||
});
|
||||
this.loginWindow = window;
|
||||
this.loginWindowPartition = partition;
|
||||
await window.loadURL(ALLDEBRID_LOGIN_URL);
|
||||
return window;
|
||||
}
|
||||
|
||||
private async postForm(
|
||||
url: string,
|
||||
body: URLSearchParams,
|
||||
referer: string,
|
||||
signal?: AbortSignal
|
||||
): Promise<{ response: Response; text: string }> {
|
||||
const currentSession = session.fromPartition(this.getPartition());
|
||||
const response = await currentSession.fetch(url, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Accept: "application/json, text/javascript, */*; q=0.01",
|
||||
"Content-Type": "application/x-www-form-urlencoded; charset=UTF-8",
|
||||
Origin: ALLDEBRID_BASE_URL,
|
||||
Referer: referer,
|
||||
"User-Agent": ALLDEBRID_USER_AGENT,
|
||||
"X-Requested-With": "XMLHttpRequest"
|
||||
},
|
||||
body: body.toString(),
|
||||
signal: withTimeoutSignal(signal, 30_000)
|
||||
});
|
||||
const text = await response.text();
|
||||
return { response, text };
|
||||
}
|
||||
|
||||
private async generate(link: string, signal?: AbortSignal): Promise<GenerateOutcome> {
|
||||
throwIfAborted(signal);
|
||||
const body = new URLSearchParams({
|
||||
link,
|
||||
nb: "0",
|
||||
json: "true",
|
||||
pw: ""
|
||||
});
|
||||
const { response, text } = await this.postForm(ALLDEBRID_SERVICE_URL, body, ALLDEBRID_SERVICE_REFERER, signal);
|
||||
if (!response.ok) {
|
||||
throw new Error(`AllDebrid Web HTTP ${response.status}`);
|
||||
}
|
||||
|
||||
const trimmed = text.trim();
|
||||
if (trimmed === "login") {
|
||||
return { kind: "login_required" };
|
||||
}
|
||||
|
||||
const payload = parseJson(trimmed);
|
||||
if (!payload) {
|
||||
throw new Error("AllDebrid Web lieferte keine JSON-Antwort");
|
||||
}
|
||||
|
||||
const errorText = pickString(payload, ["error"]);
|
||||
if (errorText) {
|
||||
if (errorText.toLowerCase() === "premium") {
|
||||
throw new Error("AllDebrid Web: Premium erforderlich");
|
||||
}
|
||||
throw new Error(`AllDebrid Web: ${errorText}`);
|
||||
}
|
||||
|
||||
const directUrl = pickString(payload, ["link"]);
|
||||
const fileName = pickString(payload, ["filename"]);
|
||||
const fileSize = pickNumber(payload, ["filesize"]);
|
||||
if (directUrl) {
|
||||
return {
|
||||
kind: "success",
|
||||
value: {
|
||||
directUrl,
|
||||
fileName: fileName || filenameFromUrl(directUrl) || filenameFromUrl(link),
|
||||
fileSize,
|
||||
retriesUsed: 0
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const delayedId = payload.delayed;
|
||||
if (delayedId !== undefined && delayedId !== null && delayedId !== false && String(delayedId).trim()) {
|
||||
const delayed = await this.waitForDelayedLink(String(delayedId).trim(), signal);
|
||||
return {
|
||||
kind: "success",
|
||||
value: {
|
||||
directUrl: delayed.link,
|
||||
fileName: fileName || filenameFromUrl(delayed.link) || filenameFromUrl(link),
|
||||
fileSize: fileSize,
|
||||
retriesUsed: 0
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
if (Array.isArray(payload.streams) && payload.streams.length > 0) {
|
||||
throw new Error("AllDebrid Web: Streaming-Auswahl wird derzeit nicht unterstützt");
|
||||
}
|
||||
|
||||
throw new Error("AllDebrid Web: Antwort ohne Download-Link");
|
||||
}
|
||||
|
||||
private async waitForDelayedLink(delayedId: string, signal?: AbortSignal): Promise<DelayedStatusPayload> {
|
||||
for (let attempt = 1; attempt <= 120; attempt += 1) {
|
||||
throwIfAborted(signal);
|
||||
const body = new URLSearchParams({ id: delayedId });
|
||||
const { response, text } = await this.postForm(ALLDEBRID_DELAYED_URL, body, ALLDEBRID_SERVICE_REFERER, signal);
|
||||
if (!response.ok) {
|
||||
throw new Error(`AllDebrid Web delayed HTTP ${response.status}`);
|
||||
}
|
||||
const payload = parseJson(text.trim());
|
||||
const data = asRecord(payload?.data);
|
||||
if (pickString(payload, ["status"]).toLowerCase() !== "success" || !data) {
|
||||
throw new Error("AllDebrid Web: Delayed-Status ungültig");
|
||||
}
|
||||
|
||||
const status = Number(data.status ?? NaN);
|
||||
if (!Number.isFinite(status)) {
|
||||
throw new Error("AllDebrid Web: Delayed-Status ohne Status");
|
||||
}
|
||||
|
||||
if (status >= 2) {
|
||||
const link = pickString(data, ["link"]);
|
||||
if (!link) {
|
||||
throw new Error("AllDebrid Web: Delayed-Link fehlt");
|
||||
}
|
||||
return {
|
||||
status,
|
||||
link,
|
||||
timeLeft: Math.max(0, Number(data.time_left ?? 0) || 0)
|
||||
};
|
||||
}
|
||||
|
||||
const timeLeft = Math.max(0, Number(data.time_left ?? 0) || 0);
|
||||
const delayMs = timeLeft > 0 ? Math.min(5_000, Math.max(1_500, timeLeft * 250)) : 2_000;
|
||||
await sleepWithSignal(delayMs, signal);
|
||||
}
|
||||
|
||||
throw new Error("AllDebrid Web: Delayed-Link Timeout");
|
||||
}
|
||||
|
||||
private async waitForLoginAndGenerate(link: string, signal?: AbortSignal): Promise<UnrestrictedLink | null> {
|
||||
const window = await this.ensureLoginWindow();
|
||||
if (window.isMinimized()) {
|
||||
window.restore();
|
||||
}
|
||||
window.show();
|
||||
window.focus();
|
||||
|
||||
const startedAt = Date.now();
|
||||
while (Date.now() - startedAt < 10 * 60 * 1000) {
|
||||
throwIfAborted(signal);
|
||||
if (window.isDestroyed()) {
|
||||
throw new Error("AllDebrid Web-Login abgebrochen");
|
||||
}
|
||||
|
||||
const outcome = await this.generate(link, signal);
|
||||
if (outcome.kind === "success") {
|
||||
if (!window.isDestroyed()) {
|
||||
window.close();
|
||||
}
|
||||
return outcome.value;
|
||||
}
|
||||
|
||||
await sleepWithSignal(1_500, signal);
|
||||
}
|
||||
|
||||
throw new Error("AllDebrid Web-Login Timeout");
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -1,119 +1,119 @@
|
||||
import fs from "node:fs";
|
||||
import { logTimestamp } from "./log-timestamp";
|
||||
import path from "node:path";
|
||||
|
||||
type AuditLevel = "INFO" | "WARN" | "ERROR";
|
||||
|
||||
const AUDIT_LOG_MAX_FILE_BYTES = Number(process.env.RD_AUDIT_LOG_MAX_BYTES || 10 * 1024 * 1024);
|
||||
const AUDIT_LOG_RETENTION_DAYS = Number(process.env.RD_AUDIT_LOG_RETENTION_DAYS || 30);
|
||||
|
||||
let auditLogPath: string | null = null;
|
||||
|
||||
function sanitizeFieldValue(value: unknown): string {
|
||||
if (value === undefined || value === null) {
|
||||
return "";
|
||||
}
|
||||
if (typeof value === "string") {
|
||||
return value.replace(/\r?\n/g, "\\n");
|
||||
}
|
||||
if (typeof value === "number" || typeof value === "boolean") {
|
||||
return String(value);
|
||||
}
|
||||
try {
|
||||
return JSON.stringify(value).replace(/\r?\n/g, "\\n");
|
||||
} catch {
|
||||
return String(value);
|
||||
}
|
||||
}
|
||||
|
||||
function formatFields(fields?: Record<string, unknown>): string {
|
||||
if (!fields) {
|
||||
return "";
|
||||
}
|
||||
const parts = Object.entries(fields)
|
||||
.filter(([, value]) => value !== undefined && value !== null && sanitizeFieldValue(value) !== "")
|
||||
.map(([key, value]) => `${key}=${sanitizeFieldValue(value)}`);
|
||||
return parts.length > 0 ? ` | ${parts.join(" | ")}` : "";
|
||||
}
|
||||
|
||||
function rotateIfNeeded(filePath: string): void {
|
||||
try {
|
||||
const stat = fs.statSync(filePath);
|
||||
if (stat.size < AUDIT_LOG_MAX_FILE_BYTES) {
|
||||
return;
|
||||
}
|
||||
const backup = `${filePath}.old`;
|
||||
try {
|
||||
fs.rmSync(backup, { force: true });
|
||||
} catch {
|
||||
}
|
||||
fs.renameSync(filePath, backup);
|
||||
} catch {
|
||||
}
|
||||
}
|
||||
|
||||
function cleanupOldBackup(filePath: string): void {
|
||||
const backup = `${filePath}.old`;
|
||||
try {
|
||||
const stat = fs.statSync(backup);
|
||||
const cutoff = Date.now() - AUDIT_LOG_RETENTION_DAYS * 24 * 60 * 60 * 1000;
|
||||
if (stat.mtimeMs < cutoff) {
|
||||
fs.rmSync(backup, { force: true });
|
||||
}
|
||||
} catch {
|
||||
}
|
||||
}
|
||||
|
||||
export function initAuditLog(baseDir: string): void {
|
||||
auditLogPath = path.join(baseDir, "audit.log");
|
||||
try {
|
||||
fs.mkdirSync(path.dirname(auditLogPath), { recursive: true });
|
||||
cleanupOldBackup(auditLogPath);
|
||||
if (!fs.existsSync(auditLogPath)) {
|
||||
fs.writeFileSync(auditLogPath, "", "utf8");
|
||||
}
|
||||
rotateIfNeeded(auditLogPath);
|
||||
if (!fs.existsSync(auditLogPath)) {
|
||||
fs.writeFileSync(auditLogPath, "", "utf8");
|
||||
}
|
||||
fs.appendFileSync(auditLogPath, `=== Audit-Log Start: ${logTimestamp()} ===\n`, "utf8");
|
||||
} catch {
|
||||
auditLogPath = null;
|
||||
}
|
||||
}
|
||||
|
||||
export function logAuditEvent(level: AuditLevel, message: string, fields?: Record<string, unknown>): void {
|
||||
if (!auditLogPath) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
rotateIfNeeded(auditLogPath);
|
||||
if (!fs.existsSync(auditLogPath)) {
|
||||
fs.writeFileSync(auditLogPath, "", "utf8");
|
||||
}
|
||||
fs.appendFileSync(
|
||||
auditLogPath,
|
||||
`${logTimestamp()} [${level}] ${message}${formatFields(fields)}\n`,
|
||||
"utf8"
|
||||
);
|
||||
} catch {
|
||||
}
|
||||
}
|
||||
|
||||
export function getAuditLogPath(): string | null {
|
||||
if (!auditLogPath) {
|
||||
return null;
|
||||
}
|
||||
return fs.existsSync(auditLogPath) ? auditLogPath : null;
|
||||
}
|
||||
|
||||
export function shutdownAuditLog(): void {
|
||||
if (!auditLogPath) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
fs.appendFileSync(auditLogPath, `=== Audit-Log Ende: ${logTimestamp()} ===\n`, "utf8");
|
||||
} catch {
|
||||
}
|
||||
auditLogPath = null;
|
||||
}
|
||||
import fs from "node:fs";
|
||||
import { logTimestamp } from "./log-timestamp";
|
||||
import path from "node:path";
|
||||
|
||||
type AuditLevel = "INFO" | "WARN" | "ERROR";
|
||||
|
||||
const AUDIT_LOG_MAX_FILE_BYTES = Number(process.env.RD_AUDIT_LOG_MAX_BYTES || 10 * 1024 * 1024);
|
||||
const AUDIT_LOG_RETENTION_DAYS = Number(process.env.RD_AUDIT_LOG_RETENTION_DAYS || 30);
|
||||
|
||||
let auditLogPath: string | null = null;
|
||||
|
||||
function sanitizeFieldValue(value: unknown): string {
|
||||
if (value === undefined || value === null) {
|
||||
return "";
|
||||
}
|
||||
if (typeof value === "string") {
|
||||
return value.replace(/\r?\n/g, "\\n");
|
||||
}
|
||||
if (typeof value === "number" || typeof value === "boolean") {
|
||||
return String(value);
|
||||
}
|
||||
try {
|
||||
return JSON.stringify(value).replace(/\r?\n/g, "\\n");
|
||||
} catch {
|
||||
return String(value);
|
||||
}
|
||||
}
|
||||
|
||||
function formatFields(fields?: Record<string, unknown>): string {
|
||||
if (!fields) {
|
||||
return "";
|
||||
}
|
||||
const parts = Object.entries(fields)
|
||||
.filter(([, value]) => value !== undefined && value !== null && sanitizeFieldValue(value) !== "")
|
||||
.map(([key, value]) => `${key}=${sanitizeFieldValue(value)}`);
|
||||
return parts.length > 0 ? ` | ${parts.join(" | ")}` : "";
|
||||
}
|
||||
|
||||
function rotateIfNeeded(filePath: string): void {
|
||||
try {
|
||||
const stat = fs.statSync(filePath);
|
||||
if (stat.size < AUDIT_LOG_MAX_FILE_BYTES) {
|
||||
return;
|
||||
}
|
||||
const backup = `${filePath}.old`;
|
||||
try {
|
||||
fs.rmSync(backup, { force: true });
|
||||
} catch {
|
||||
}
|
||||
fs.renameSync(filePath, backup);
|
||||
} catch {
|
||||
}
|
||||
}
|
||||
|
||||
function cleanupOldBackup(filePath: string): void {
|
||||
const backup = `${filePath}.old`;
|
||||
try {
|
||||
const stat = fs.statSync(backup);
|
||||
const cutoff = Date.now() - AUDIT_LOG_RETENTION_DAYS * 24 * 60 * 60 * 1000;
|
||||
if (stat.mtimeMs < cutoff) {
|
||||
fs.rmSync(backup, { force: true });
|
||||
}
|
||||
} catch {
|
||||
}
|
||||
}
|
||||
|
||||
export function initAuditLog(baseDir: string): void {
|
||||
auditLogPath = path.join(baseDir, "audit.log");
|
||||
try {
|
||||
fs.mkdirSync(path.dirname(auditLogPath), { recursive: true });
|
||||
cleanupOldBackup(auditLogPath);
|
||||
if (!fs.existsSync(auditLogPath)) {
|
||||
fs.writeFileSync(auditLogPath, "", "utf8");
|
||||
}
|
||||
rotateIfNeeded(auditLogPath);
|
||||
if (!fs.existsSync(auditLogPath)) {
|
||||
fs.writeFileSync(auditLogPath, "", "utf8");
|
||||
}
|
||||
fs.appendFileSync(auditLogPath, `=== Audit-Log Start: ${logTimestamp()} ===\n`, "utf8");
|
||||
} catch {
|
||||
auditLogPath = null;
|
||||
}
|
||||
}
|
||||
|
||||
export function logAuditEvent(level: AuditLevel, message: string, fields?: Record<string, unknown>): void {
|
||||
if (!auditLogPath) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
rotateIfNeeded(auditLogPath);
|
||||
if (!fs.existsSync(auditLogPath)) {
|
||||
fs.writeFileSync(auditLogPath, "", "utf8");
|
||||
}
|
||||
fs.appendFileSync(
|
||||
auditLogPath,
|
||||
`${logTimestamp()} [${level}] ${message}${formatFields(fields)}\n`,
|
||||
"utf8"
|
||||
);
|
||||
} catch {
|
||||
}
|
||||
}
|
||||
|
||||
export function getAuditLogPath(): string | null {
|
||||
if (!auditLogPath) {
|
||||
return null;
|
||||
}
|
||||
return fs.existsSync(auditLogPath) ? auditLogPath : null;
|
||||
}
|
||||
|
||||
export function shutdownAuditLog(): void {
|
||||
if (!auditLogPath) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
fs.appendFileSync(auditLogPath, `=== Audit-Log Ende: ${logTimestamp()} ===\n`, "utf8");
|
||||
} catch {
|
||||
}
|
||||
auditLogPath = null;
|
||||
}
|
||||
|
||||
@ -1,39 +1,39 @@
|
||||
import crypto from "node:crypto";
|
||||
|
||||
const APP_KEY_MATERIAL = "MDD-v2-backup-aes256gcm-2026";
|
||||
const ALGORITHM = "aes-256-gcm";
|
||||
const IV_LENGTH = 12;
|
||||
const AUTH_TAG_LENGTH = 16;
|
||||
const MAGIC = Buffer.from("MDD1");
|
||||
|
||||
function deriveKey(): Buffer {
|
||||
return crypto.createHash("sha256").update(APP_KEY_MATERIAL).digest();
|
||||
}
|
||||
|
||||
export function encryptBackup(plaintext: string): Buffer {
|
||||
const key = deriveKey();
|
||||
const iv = crypto.randomBytes(IV_LENGTH);
|
||||
const cipher = crypto.createCipheriv(ALGORITHM, key, iv, { authTagLength: AUTH_TAG_LENGTH });
|
||||
const encrypted = Buffer.concat([cipher.update(plaintext, "utf8"), cipher.final()]);
|
||||
const authTag = cipher.getAuthTag();
|
||||
return Buffer.concat([MAGIC, iv, authTag, encrypted]);
|
||||
}
|
||||
|
||||
export function decryptBackup(data: Buffer): string {
|
||||
if (data.length < MAGIC.length + IV_LENGTH + AUTH_TAG_LENGTH) {
|
||||
throw new Error("Backup-Datei zu kurz oder ungültig");
|
||||
}
|
||||
const magic = data.subarray(0, MAGIC.length);
|
||||
if (!magic.equals(MAGIC)) {
|
||||
throw new Error("Keine gültige MDD-Backup-Datei (falsche Signatur)");
|
||||
}
|
||||
const iv = data.subarray(MAGIC.length, MAGIC.length + IV_LENGTH);
|
||||
const authTag = data.subarray(MAGIC.length + IV_LENGTH, MAGIC.length + IV_LENGTH + AUTH_TAG_LENGTH);
|
||||
const ciphertext = data.subarray(MAGIC.length + IV_LENGTH + AUTH_TAG_LENGTH);
|
||||
|
||||
const key = deriveKey();
|
||||
const decipher = crypto.createDecipheriv(ALGORITHM, key, iv, { authTagLength: AUTH_TAG_LENGTH });
|
||||
decipher.setAuthTag(authTag);
|
||||
const decrypted = Buffer.concat([decipher.update(ciphertext), decipher.final()]);
|
||||
return decrypted.toString("utf8");
|
||||
}
|
||||
import crypto from "node:crypto";
|
||||
|
||||
const APP_KEY_MATERIAL = "MDD-v2-backup-aes256gcm-2026";
|
||||
const ALGORITHM = "aes-256-gcm";
|
||||
const IV_LENGTH = 12;
|
||||
const AUTH_TAG_LENGTH = 16;
|
||||
const MAGIC = Buffer.from("MDD1");
|
||||
|
||||
function deriveKey(): Buffer {
|
||||
return crypto.createHash("sha256").update(APP_KEY_MATERIAL).digest();
|
||||
}
|
||||
|
||||
export function encryptBackup(plaintext: string): Buffer {
|
||||
const key = deriveKey();
|
||||
const iv = crypto.randomBytes(IV_LENGTH);
|
||||
const cipher = crypto.createCipheriv(ALGORITHM, key, iv, { authTagLength: AUTH_TAG_LENGTH });
|
||||
const encrypted = Buffer.concat([cipher.update(plaintext, "utf8"), cipher.final()]);
|
||||
const authTag = cipher.getAuthTag();
|
||||
return Buffer.concat([MAGIC, iv, authTag, encrypted]);
|
||||
}
|
||||
|
||||
export function decryptBackup(data: Buffer): string {
|
||||
if (data.length < MAGIC.length + IV_LENGTH + AUTH_TAG_LENGTH) {
|
||||
throw new Error("Backup-Datei zu kurz oder ungültig");
|
||||
}
|
||||
const magic = data.subarray(0, MAGIC.length);
|
||||
if (!magic.equals(MAGIC)) {
|
||||
throw new Error("Keine gültige MDD-Backup-Datei (falsche Signatur)");
|
||||
}
|
||||
const iv = data.subarray(MAGIC.length, MAGIC.length + IV_LENGTH);
|
||||
const authTag = data.subarray(MAGIC.length + IV_LENGTH, MAGIC.length + IV_LENGTH + AUTH_TAG_LENGTH);
|
||||
const ciphertext = data.subarray(MAGIC.length + IV_LENGTH + AUTH_TAG_LENGTH);
|
||||
|
||||
const key = deriveKey();
|
||||
const decipher = crypto.createDecipheriv(ALGORITHM, key, iv, { authTagLength: AUTH_TAG_LENGTH });
|
||||
decipher.setAuthTag(authTag);
|
||||
const decrypted = Buffer.concat([decipher.update(ciphertext), decipher.final()]);
|
||||
return decrypted.toString("utf8");
|
||||
}
|
||||
|
||||
@ -1,115 +1,115 @@
|
||||
import type { AppSettings, SessionState, HistoryEntry } from "../shared/types";
|
||||
|
||||
export type BackupKind = "full" | "settings-only";
|
||||
|
||||
export interface BackupRemoteDiagnostics {
|
||||
allowlist: string[];
|
||||
port: number;
|
||||
hostMode: "local" | "network";
|
||||
}
|
||||
|
||||
export interface BackupPayload {
|
||||
version: 2;
|
||||
kind: BackupKind;
|
||||
appVersion: string;
|
||||
exportedAt: string;
|
||||
settings: AppSettings;
|
||||
session?: SessionState;
|
||||
history?: HistoryEntry[];
|
||||
remoteDiagnostics?: BackupRemoteDiagnostics;
|
||||
}
|
||||
|
||||
export interface BuildBackupInput {
|
||||
settings: AppSettings;
|
||||
appVersion: string;
|
||||
exportedAt: string;
|
||||
/** Only bundled when includeDownloads is true. */
|
||||
session: SessionState;
|
||||
history: HistoryEntry[];
|
||||
remoteDiagnostics?: BackupRemoteDiagnostics;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the backup payload. By default ("Download-Liste mitsichern" off) the
|
||||
* payload contains ONLY settings — no session, no history. The download list is
|
||||
* bundled solely when settings.backupIncludeDownloads is true. An explicit kind
|
||||
* marker makes the import side unambiguous and survives hand-edited files.
|
||||
*/
|
||||
export function buildBackupPayload(input: BuildBackupInput): BackupPayload {
|
||||
const includeDownloads = Boolean(input.settings.backupIncludeDownloads);
|
||||
const base: BackupPayload = {
|
||||
version: 2,
|
||||
kind: includeDownloads ? "full" : "settings-only",
|
||||
appVersion: input.appVersion,
|
||||
exportedAt: input.exportedAt,
|
||||
settings: input.settings
|
||||
};
|
||||
if (includeDownloads) {
|
||||
base.session = input.session;
|
||||
base.history = input.history;
|
||||
}
|
||||
if (Boolean(input.settings.backupIncludeRemoteDiagnostics) && input.remoteDiagnostics) {
|
||||
base.remoteDiagnostics = input.remoteDiagnostics;
|
||||
}
|
||||
return base;
|
||||
}
|
||||
|
||||
export interface RemoteDiagnosticsRestore {
|
||||
host?: "127.0.0.1" | "0.0.0.0";
|
||||
port?: number;
|
||||
allowlist?: string[];
|
||||
}
|
||||
|
||||
export function resolveRemoteDiagnosticsRestore(section: unknown): RemoteDiagnosticsRestore | null {
|
||||
if (!section || typeof section !== "object") {
|
||||
return null;
|
||||
}
|
||||
const s = section as { allowlist?: unknown; port?: unknown; hostMode?: unknown };
|
||||
const allowlist = Array.isArray(s.allowlist)
|
||||
? s.allowlist.filter((entry): entry is string => typeof entry === "string" && entry.trim().length > 0).map((entry) => entry.trim())
|
||||
: undefined;
|
||||
const port = (typeof s.port === "number" && Number.isInteger(s.port) && s.port >= 1024 && s.port <= 65535) ? s.port : undefined;
|
||||
let host: "127.0.0.1" | "0.0.0.0" | undefined;
|
||||
if (s.hostMode === "network") {
|
||||
host = allowlist && allowlist.length > 0 ? "0.0.0.0" : "127.0.0.1";
|
||||
} else if (s.hostMode === "local") {
|
||||
host = "127.0.0.1";
|
||||
}
|
||||
if (host === undefined && port === undefined && allowlist === undefined) {
|
||||
return null;
|
||||
}
|
||||
return { host, port, allowlist };
|
||||
}
|
||||
|
||||
export interface ImportPlan {
|
||||
valid: boolean;
|
||||
/** Restore the download list (session + history) and relaunch. */
|
||||
restoreDownloads: boolean;
|
||||
message: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decide how to apply an imported backup based on what the FILE physically
|
||||
* contains — NOT the local toggle. A backup without a session restores settings
|
||||
* only (no queue wipe, no relaunch); a full backup (with session) restores the
|
||||
* queue too. This way an old full backup still restores fully even if the local
|
||||
* toggle is currently off, and a settings-only backup never disturbs a running
|
||||
* queue.
|
||||
*/
|
||||
export function planBackupImport(parsed: unknown): ImportPlan {
|
||||
if (!parsed || typeof parsed !== "object") {
|
||||
return { valid: false, restoreDownloads: false, message: "Kein gültiges Backup (settings fehlen)" };
|
||||
}
|
||||
const record = parsed as Record<string, unknown>;
|
||||
if (!record.settings || typeof record.settings !== "object") {
|
||||
return { valid: false, restoreDownloads: false, message: "Kein gültiges Backup (settings fehlen)" };
|
||||
}
|
||||
const hasSession = Boolean(record.session) && typeof record.session === "object";
|
||||
return {
|
||||
valid: true,
|
||||
restoreDownloads: hasSession,
|
||||
message: hasSession
|
||||
? "Backup wiederhergestellt – App startet automatisch neu…"
|
||||
: "Einstellungen wiederhergestellt"
|
||||
};
|
||||
}
|
||||
import type { AppSettings, SessionState, HistoryEntry } from "../shared/types";
|
||||
|
||||
export type BackupKind = "full" | "settings-only";
|
||||
|
||||
export interface BackupMcpRemote {
|
||||
allowlist: string[];
|
||||
port: number;
|
||||
hostMode: "local" | "network";
|
||||
}
|
||||
|
||||
export interface BackupPayload {
|
||||
version: 2;
|
||||
kind: BackupKind;
|
||||
appVersion: string;
|
||||
exportedAt: string;
|
||||
settings: AppSettings;
|
||||
session?: SessionState;
|
||||
history?: HistoryEntry[];
|
||||
mcpRemote?: BackupMcpRemote;
|
||||
}
|
||||
|
||||
export interface BuildBackupInput {
|
||||
settings: AppSettings;
|
||||
appVersion: string;
|
||||
exportedAt: string;
|
||||
/** Only bundled when includeDownloads is true. */
|
||||
session: SessionState;
|
||||
history: HistoryEntry[];
|
||||
mcpRemote?: BackupMcpRemote;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the backup payload. By default ("Download-Liste mitsichern" off) the
|
||||
* payload contains ONLY settings — no session, no history. The download list is
|
||||
* bundled solely when settings.backupIncludeDownloads is true. An explicit kind
|
||||
* marker makes the import side unambiguous and survives hand-edited files.
|
||||
*/
|
||||
export function buildBackupPayload(input: BuildBackupInput): BackupPayload {
|
||||
const includeDownloads = Boolean(input.settings.backupIncludeDownloads);
|
||||
const base: BackupPayload = {
|
||||
version: 2,
|
||||
kind: includeDownloads ? "full" : "settings-only",
|
||||
appVersion: input.appVersion,
|
||||
exportedAt: input.exportedAt,
|
||||
settings: input.settings
|
||||
};
|
||||
if (includeDownloads) {
|
||||
base.session = input.session;
|
||||
base.history = input.history;
|
||||
}
|
||||
if (Boolean(input.settings.backupIncludeMcp) && input.mcpRemote) {
|
||||
base.mcpRemote = input.mcpRemote;
|
||||
}
|
||||
return base;
|
||||
}
|
||||
|
||||
export interface McpRemoteRestore {
|
||||
host?: "127.0.0.1" | "0.0.0.0";
|
||||
port?: number;
|
||||
allowlist?: string[];
|
||||
}
|
||||
|
||||
export function resolveMcpRemoteRestore(section: unknown): McpRemoteRestore | null {
|
||||
if (!section || typeof section !== "object") {
|
||||
return null;
|
||||
}
|
||||
const s = section as { allowlist?: unknown; port?: unknown; hostMode?: unknown };
|
||||
const allowlist = Array.isArray(s.allowlist)
|
||||
? s.allowlist.filter((entry): entry is string => typeof entry === "string" && entry.trim().length > 0).map((entry) => entry.trim())
|
||||
: undefined;
|
||||
const port = (typeof s.port === "number" && Number.isInteger(s.port) && s.port >= 1024 && s.port <= 65535) ? s.port : undefined;
|
||||
let host: "127.0.0.1" | "0.0.0.0" | undefined;
|
||||
if (s.hostMode === "network") {
|
||||
host = allowlist && allowlist.length > 0 ? "0.0.0.0" : "127.0.0.1";
|
||||
} else if (s.hostMode === "local") {
|
||||
host = "127.0.0.1";
|
||||
}
|
||||
if (host === undefined && port === undefined && allowlist === undefined) {
|
||||
return null;
|
||||
}
|
||||
return { host, port, allowlist };
|
||||
}
|
||||
|
||||
export interface ImportPlan {
|
||||
valid: boolean;
|
||||
/** Restore the download list (session + history) and relaunch. */
|
||||
restoreDownloads: boolean;
|
||||
message: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decide how to apply an imported backup based on what the FILE physically
|
||||
* contains — NOT the local toggle. A backup without a session restores settings
|
||||
* only (no queue wipe, no relaunch); a full backup (with session) restores the
|
||||
* queue too. This way an old full backup still restores fully even if the local
|
||||
* toggle is currently off, and a settings-only backup never disturbs a running
|
||||
* queue.
|
||||
*/
|
||||
export function planBackupImport(parsed: unknown): ImportPlan {
|
||||
if (!parsed || typeof parsed !== "object") {
|
||||
return { valid: false, restoreDownloads: false, message: "Kein gültiges Backup (settings fehlen)" };
|
||||
}
|
||||
const record = parsed as Record<string, unknown>;
|
||||
if (!record.settings || typeof record.settings !== "object") {
|
||||
return { valid: false, restoreDownloads: false, message: "Kein gültiges Backup (settings fehlen)" };
|
||||
}
|
||||
const hasSession = Boolean(record.session) && typeof record.session === "object";
|
||||
return {
|
||||
valid: true,
|
||||
restoreDownloads: hasSession,
|
||||
message: hasSession
|
||||
? "Backup wiederhergestellt – App startet automatisch neu…"
|
||||
: "Einstellungen wiederhergestellt"
|
||||
};
|
||||
}
|
||||
|
||||
@ -1,346 +1,346 @@
|
||||
import fs from "node:fs";
|
||||
import { session, type Session } from "electron";
|
||||
import { UnrestrictedLink } from "./realdebrid";
|
||||
import { filenameFromUrl, sleep } from "./utils";
|
||||
import { logger } from "./logger";
|
||||
|
||||
const BESTDEBRID_BASE_URL = "https://bestdebrid.com";
|
||||
const BESTDEBRID_DOWNLOADER_URL = `${BESTDEBRID_BASE_URL}/en/downloader/`;
|
||||
const BESTDEBRID_GENERATE_URL = `${BESTDEBRID_BASE_URL}/api/v1/generateLink`;
|
||||
const BESTDEBRID_PERSISTENT_PARTITION = "persist:bestdebrid-web";
|
||||
const BESTDEBRID_TRANSIENT_PARTITION = "bestdebrid-web";
|
||||
const BESTDEBRID_USER_AGENT = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/133.0.0.0 Safari/537.36";
|
||||
|
||||
function abortError(): Error {
|
||||
return new Error("aborted:bestdebrid-web");
|
||||
}
|
||||
|
||||
function withTimeoutSignal(signal: AbortSignal | undefined, timeoutMs: number): AbortSignal {
|
||||
const timeoutSignal = AbortSignal.timeout(timeoutMs);
|
||||
if (!signal) {
|
||||
return timeoutSignal;
|
||||
}
|
||||
return AbortSignal.any([signal, timeoutSignal]);
|
||||
}
|
||||
|
||||
function throwIfAborted(signal?: AbortSignal): void {
|
||||
if (signal?.aborted) {
|
||||
throw abortError();
|
||||
}
|
||||
}
|
||||
|
||||
function parseJson(text: string): Record<string, unknown> | null {
|
||||
try {
|
||||
const parsed = JSON.parse(text) as unknown;
|
||||
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
||||
return null;
|
||||
}
|
||||
return parsed as Record<string, unknown>;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
interface NetscapeCookie {
|
||||
domain: string;
|
||||
includeSubdomains: boolean;
|
||||
httpOnly: boolean;
|
||||
path: string;
|
||||
secure: boolean;
|
||||
expirationDate: number;
|
||||
name: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
function normalizeCookieDomain(domain: string): string {
|
||||
return String(domain || "").trim().replace(/^\./, "").toLowerCase();
|
||||
}
|
||||
|
||||
function dedupeCookies(cookies: NetscapeCookie[]): NetscapeCookie[] {
|
||||
const deduped = new Map<string, NetscapeCookie>();
|
||||
for (const cookie of cookies) {
|
||||
const key = `${normalizeCookieDomain(cookie.domain)}\t${cookie.path}\t${cookie.name}`;
|
||||
const existing = deduped.get(key);
|
||||
if (!existing) {
|
||||
deduped.set(key, cookie);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (cookie.httpOnly && !existing.httpOnly) {
|
||||
deduped.set(key, cookie);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (cookie.expirationDate > existing.expirationDate) {
|
||||
deduped.set(key, cookie);
|
||||
}
|
||||
}
|
||||
return [...deduped.values()];
|
||||
}
|
||||
|
||||
function parseNetscapeCookieFile(text: string): NetscapeCookie[] {
|
||||
const cookies: NetscapeCookie[] = [];
|
||||
for (const line of text.split(/\r?\n/)) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let normalizedLine = trimmed;
|
||||
let httpOnly = false;
|
||||
if (normalizedLine.startsWith("#HttpOnly_")) {
|
||||
httpOnly = true;
|
||||
normalizedLine = normalizedLine.slice("#HttpOnly_".length);
|
||||
} else if (normalizedLine.startsWith("#")) {
|
||||
continue;
|
||||
}
|
||||
const parts = normalizedLine.split("\t");
|
||||
if (parts.length < 7) {
|
||||
continue;
|
||||
}
|
||||
cookies.push({
|
||||
domain: parts[0],
|
||||
includeSubdomains: parts[1].toUpperCase() === "TRUE",
|
||||
httpOnly,
|
||||
path: parts[2],
|
||||
secure: parts[3].toUpperCase() === "TRUE",
|
||||
expirationDate: Number(parts[4]) || 0,
|
||||
name: parts[5],
|
||||
value: parts[6]
|
||||
});
|
||||
}
|
||||
return cookies;
|
||||
}
|
||||
|
||||
function isLikelyBestDebridAuthCookie(name: string): boolean {
|
||||
const normalized = String(name || "").trim();
|
||||
return /phpsessid|sess(?:ion)?|auth|login/i.test(normalized);
|
||||
}
|
||||
|
||||
function isAuthenticatedBestDebridHtml(html: string): boolean {
|
||||
const normalized = String(html || "");
|
||||
if (!normalized) {
|
||||
return false;
|
||||
}
|
||||
return /href\s*=\s*["']logout["']/i.test(normalized)
|
||||
|| /title\s*=\s*["'][^"']*premium until/i.test(normalized)
|
||||
|| (/user-profile-image/i.test(normalized) && !/>\s*guest\s*</i.test(normalized));
|
||||
}
|
||||
|
||||
function looksLikeGuestAccessMessage(message: string): boolean {
|
||||
return /free users are not allowed|purchase a premium plan|premium required/i.test(String(message || ""));
|
||||
}
|
||||
|
||||
export class BestDebridWebFallback {
|
||||
private queue: Promise<unknown> = Promise.resolve();
|
||||
|
||||
private cookiesImported = false;
|
||||
|
||||
private getRememberSession: () => boolean;
|
||||
|
||||
public constructor(getRememberSession: () => boolean) {
|
||||
this.getRememberSession = getRememberSession;
|
||||
}
|
||||
|
||||
public async unrestrict(link: string, signal?: AbortSignal): Promise<UnrestrictedLink | null> {
|
||||
const overallSignal = withTimeoutSignal(signal, 60_000);
|
||||
return this.runExclusive(async () => {
|
||||
throwIfAborted(overallSignal);
|
||||
if (!String(link || "").trim()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!this.cookiesImported) {
|
||||
throw new Error("BestDebrid: Keine Cookies importiert. Bitte zuerst über Einstellungen eine Cookie-Datei importieren.");
|
||||
}
|
||||
|
||||
const result = await this.generate(link, overallSignal);
|
||||
if (result.kind === "success") {
|
||||
return result.value;
|
||||
}
|
||||
this.cookiesImported = false;
|
||||
throw new Error("BestDebrid: Nicht eingeloggt. Bitte neue Cookie-Datei importieren.");
|
||||
}, overallSignal);
|
||||
}
|
||||
|
||||
public async importCookiesFromFile(filePath: string): Promise<number> {
|
||||
const text = fs.readFileSync(filePath, "utf-8");
|
||||
const cookies = parseNetscapeCookieFile(text);
|
||||
const bestDebridCookies = dedupeCookies(cookies.filter((c) =>
|
||||
c.domain.includes("bestdebrid.com")
|
||||
));
|
||||
|
||||
if (bestDebridCookies.length === 0) {
|
||||
throw new Error("Keine BestDebrid-Cookies in der Datei gefunden");
|
||||
}
|
||||
|
||||
if (!bestDebridCookies.some((cookie) => isLikelyBestDebridAuthCookie(cookie.name))) {
|
||||
throw new Error("BestDebrid: Cookie-Datei enthält keinen Login-Cookie. Bitte nach dem Login erneut exportieren.");
|
||||
}
|
||||
|
||||
const currentSession = session.fromPartition(this.getPartition());
|
||||
await this.clearPartitionState(currentSession);
|
||||
|
||||
for (const cookie of bestDebridCookies) {
|
||||
const url = `https://${cookie.domain.replace(/^\./, "")}${cookie.path}`;
|
||||
const details: Parameters<typeof currentSession.cookies.set>[0] = {
|
||||
url,
|
||||
name: cookie.name,
|
||||
value: cookie.value,
|
||||
path: cookie.path,
|
||||
secure: cookie.secure,
|
||||
httpOnly: cookie.httpOnly,
|
||||
expirationDate: cookie.expirationDate > 0 ? cookie.expirationDate : undefined
|
||||
};
|
||||
if (cookie.includeSubdomains || cookie.domain.startsWith(".")) {
|
||||
details.domain = cookie.domain;
|
||||
}
|
||||
await currentSession.cookies.set(details);
|
||||
}
|
||||
|
||||
this.cookiesImported = true;
|
||||
logger.info(`BestDebrid: ${bestDebridCookies.length} Cookies importiert aus ${filePath}`);
|
||||
return bestDebridCookies.length;
|
||||
}
|
||||
|
||||
public async clearSessions(): Promise<void> {
|
||||
this.cookiesImported = false;
|
||||
for (const partition of [BESTDEBRID_PERSISTENT_PARTITION, BESTDEBRID_TRANSIENT_PARTITION]) {
|
||||
const currentSession = session.fromPartition(partition);
|
||||
try {
|
||||
await currentSession.clearStorageData({
|
||||
storages: ["cookies", "indexdb", "localstorage", "serviceworkers", "cachestorage"]
|
||||
});
|
||||
} catch {
|
||||
}
|
||||
try {
|
||||
await currentSession.clearCache();
|
||||
} catch {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public dispose(): void {
|
||||
}
|
||||
|
||||
private getPartition(): string {
|
||||
return this.getRememberSession() ? BESTDEBRID_PERSISTENT_PARTITION : BESTDEBRID_TRANSIENT_PARTITION;
|
||||
}
|
||||
|
||||
private async runExclusive<T>(job: () => Promise<T>, signal?: AbortSignal): Promise<T> {
|
||||
const queuedAt = Date.now();
|
||||
const queueWaitTimeoutMs = 90_000;
|
||||
const guardedJob = async (): Promise<T> => {
|
||||
throwIfAborted(signal);
|
||||
const waited = Date.now() - queuedAt;
|
||||
if (waited > queueWaitTimeoutMs) {
|
||||
throw new Error(`BestDebrid-Web Queue-Timeout (${Math.floor(waited / 1000)}s gewartet)`);
|
||||
}
|
||||
return job();
|
||||
};
|
||||
const run = this.queue.then(guardedJob, guardedJob);
|
||||
this.queue = run.then(() => undefined, () => undefined);
|
||||
return run;
|
||||
}
|
||||
|
||||
private async generate(link: string, signal?: AbortSignal): Promise<{ kind: "success"; value: UnrestrictedLink } | { kind: "login_required" }> {
|
||||
throwIfAborted(signal);
|
||||
const currentSession = session.fromPartition(this.getPartition());
|
||||
const response = await currentSession.fetch(BESTDEBRID_GENERATE_URL, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Accept: "application/json, text/javascript, */*; q=0.01",
|
||||
"Content-Type": "application/x-www-form-urlencoded; charset=UTF-8",
|
||||
Origin: BESTDEBRID_BASE_URL,
|
||||
Referer: BESTDEBRID_DOWNLOADER_URL,
|
||||
"User-Agent": BESTDEBRID_USER_AGENT,
|
||||
"X-Requested-With": "XMLHttpRequest"
|
||||
},
|
||||
body: new URLSearchParams({ link, pass: "", boxlinklist: "" }).toString(),
|
||||
signal: withTimeoutSignal(signal, 30_000)
|
||||
});
|
||||
|
||||
const text = await response.text();
|
||||
|
||||
if (!response.ok || text.trim().startsWith("<!") || text.trim().startsWith("<html")) {
|
||||
return { kind: "login_required" };
|
||||
}
|
||||
|
||||
const payload = parseJson(text.trim());
|
||||
if (!payload) {
|
||||
return { kind: "login_required" };
|
||||
}
|
||||
|
||||
const error = Number(payload.error ?? -1);
|
||||
const message = String(payload.message || "").trim();
|
||||
|
||||
if (error !== 0) {
|
||||
if (/login|log in|sign in|not logged|session|auth/i.test(message)) {
|
||||
return { kind: "login_required" };
|
||||
}
|
||||
if (looksLikeGuestAccessMessage(message)) {
|
||||
const authenticated = await this.isAuthenticated(currentSession, signal).catch(() => null);
|
||||
if (authenticated === false) {
|
||||
return { kind: "login_required" };
|
||||
}
|
||||
}
|
||||
throw new Error(`BestDebrid Web: ${message || "Unbekannter Fehler"}`);
|
||||
}
|
||||
|
||||
const directUrl = String(payload.link || "").trim();
|
||||
if (!directUrl) {
|
||||
throw new Error("BestDebrid Web: Antwort ohne Download-Link");
|
||||
}
|
||||
|
||||
const fileName = String(payload.filename || "").trim() || filenameFromUrl(directUrl) || filenameFromUrl(link);
|
||||
const fileSizeRaw = String(payload.size || "").trim();
|
||||
let fileSize: number | null = null;
|
||||
if (fileSizeRaw) {
|
||||
const match = fileSizeRaw.match(/([\d.]+)\s*(KB|KiB|MB|MiB|GB|GiB|TB|TiB|B)/i);
|
||||
if (match) {
|
||||
const value = parseFloat(match[1]);
|
||||
const unit = match[2].toUpperCase().replace("IB", "B");
|
||||
const multipliers: Record<string, number> = { B: 1, KB: 1024, MB: 1024 * 1024, GB: 1024 * 1024 * 1024, TB: 1024 * 1024 * 1024 * 1024 };
|
||||
fileSize = Math.floor(value * (multipliers[unit] || 1));
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
kind: "success",
|
||||
value: {
|
||||
directUrl,
|
||||
fileName,
|
||||
fileSize,
|
||||
retriesUsed: 0
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private async isAuthenticated(currentSession: Session, signal?: AbortSignal): Promise<boolean> {
|
||||
throwIfAborted(signal);
|
||||
const response = await currentSession.fetch(BESTDEBRID_DOWNLOADER_URL, {
|
||||
method: "GET",
|
||||
headers: {
|
||||
Accept: "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
|
||||
Referer: BESTDEBRID_BASE_URL,
|
||||
"User-Agent": BESTDEBRID_USER_AGENT
|
||||
},
|
||||
signal: withTimeoutSignal(signal, 20_000)
|
||||
});
|
||||
if (!response.ok) {
|
||||
return false;
|
||||
}
|
||||
const text = await response.text();
|
||||
return isAuthenticatedBestDebridHtml(text);
|
||||
}
|
||||
|
||||
private async clearPartitionState(currentSession: Session): Promise<void> {
|
||||
await currentSession.clearStorageData({
|
||||
storages: ["cookies", "indexdb", "localstorage", "serviceworkers", "cachestorage"]
|
||||
});
|
||||
try {
|
||||
await currentSession.clearCache();
|
||||
} catch {
|
||||
}
|
||||
}
|
||||
}
|
||||
import fs from "node:fs";
|
||||
import { session, type Session } from "electron";
|
||||
import { UnrestrictedLink } from "./realdebrid";
|
||||
import { filenameFromUrl, sleep } from "./utils";
|
||||
import { logger } from "./logger";
|
||||
|
||||
const BESTDEBRID_BASE_URL = "https://bestdebrid.com";
|
||||
const BESTDEBRID_DOWNLOADER_URL = `${BESTDEBRID_BASE_URL}/en/downloader/`;
|
||||
const BESTDEBRID_GENERATE_URL = `${BESTDEBRID_BASE_URL}/api/v1/generateLink`;
|
||||
const BESTDEBRID_PERSISTENT_PARTITION = "persist:bestdebrid-web";
|
||||
const BESTDEBRID_TRANSIENT_PARTITION = "bestdebrid-web";
|
||||
const BESTDEBRID_USER_AGENT = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/133.0.0.0 Safari/537.36";
|
||||
|
||||
function abortError(): Error {
|
||||
return new Error("aborted:bestdebrid-web");
|
||||
}
|
||||
|
||||
function withTimeoutSignal(signal: AbortSignal | undefined, timeoutMs: number): AbortSignal {
|
||||
const timeoutSignal = AbortSignal.timeout(timeoutMs);
|
||||
if (!signal) {
|
||||
return timeoutSignal;
|
||||
}
|
||||
return AbortSignal.any([signal, timeoutSignal]);
|
||||
}
|
||||
|
||||
function throwIfAborted(signal?: AbortSignal): void {
|
||||
if (signal?.aborted) {
|
||||
throw abortError();
|
||||
}
|
||||
}
|
||||
|
||||
function parseJson(text: string): Record<string, unknown> | null {
|
||||
try {
|
||||
const parsed = JSON.parse(text) as unknown;
|
||||
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
||||
return null;
|
||||
}
|
||||
return parsed as Record<string, unknown>;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
interface NetscapeCookie {
|
||||
domain: string;
|
||||
includeSubdomains: boolean;
|
||||
httpOnly: boolean;
|
||||
path: string;
|
||||
secure: boolean;
|
||||
expirationDate: number;
|
||||
name: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
function normalizeCookieDomain(domain: string): string {
|
||||
return String(domain || "").trim().replace(/^\./, "").toLowerCase();
|
||||
}
|
||||
|
||||
function dedupeCookies(cookies: NetscapeCookie[]): NetscapeCookie[] {
|
||||
const deduped = new Map<string, NetscapeCookie>();
|
||||
for (const cookie of cookies) {
|
||||
const key = `${normalizeCookieDomain(cookie.domain)}\t${cookie.path}\t${cookie.name}`;
|
||||
const existing = deduped.get(key);
|
||||
if (!existing) {
|
||||
deduped.set(key, cookie);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (cookie.httpOnly && !existing.httpOnly) {
|
||||
deduped.set(key, cookie);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (cookie.expirationDate > existing.expirationDate) {
|
||||
deduped.set(key, cookie);
|
||||
}
|
||||
}
|
||||
return [...deduped.values()];
|
||||
}
|
||||
|
||||
function parseNetscapeCookieFile(text: string): NetscapeCookie[] {
|
||||
const cookies: NetscapeCookie[] = [];
|
||||
for (const line of text.split(/\r?\n/)) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let normalizedLine = trimmed;
|
||||
let httpOnly = false;
|
||||
if (normalizedLine.startsWith("#HttpOnly_")) {
|
||||
httpOnly = true;
|
||||
normalizedLine = normalizedLine.slice("#HttpOnly_".length);
|
||||
} else if (normalizedLine.startsWith("#")) {
|
||||
continue;
|
||||
}
|
||||
const parts = normalizedLine.split("\t");
|
||||
if (parts.length < 7) {
|
||||
continue;
|
||||
}
|
||||
cookies.push({
|
||||
domain: parts[0],
|
||||
includeSubdomains: parts[1].toUpperCase() === "TRUE",
|
||||
httpOnly,
|
||||
path: parts[2],
|
||||
secure: parts[3].toUpperCase() === "TRUE",
|
||||
expirationDate: Number(parts[4]) || 0,
|
||||
name: parts[5],
|
||||
value: parts[6]
|
||||
});
|
||||
}
|
||||
return cookies;
|
||||
}
|
||||
|
||||
function isLikelyBestDebridAuthCookie(name: string): boolean {
|
||||
const normalized = String(name || "").trim();
|
||||
return /phpsessid|sess(?:ion)?|auth|login/i.test(normalized);
|
||||
}
|
||||
|
||||
function isAuthenticatedBestDebridHtml(html: string): boolean {
|
||||
const normalized = String(html || "");
|
||||
if (!normalized) {
|
||||
return false;
|
||||
}
|
||||
return /href\s*=\s*["']logout["']/i.test(normalized)
|
||||
|| /title\s*=\s*["'][^"']*premium until/i.test(normalized)
|
||||
|| (/user-profile-image/i.test(normalized) && !/>\s*guest\s*</i.test(normalized));
|
||||
}
|
||||
|
||||
function looksLikeGuestAccessMessage(message: string): boolean {
|
||||
return /free users are not allowed|purchase a premium plan|premium required/i.test(String(message || ""));
|
||||
}
|
||||
|
||||
export class BestDebridWebFallback {
|
||||
private queue: Promise<unknown> = Promise.resolve();
|
||||
|
||||
private cookiesImported = false;
|
||||
|
||||
private getRememberSession: () => boolean;
|
||||
|
||||
public constructor(getRememberSession: () => boolean) {
|
||||
this.getRememberSession = getRememberSession;
|
||||
}
|
||||
|
||||
public async unrestrict(link: string, signal?: AbortSignal): Promise<UnrestrictedLink | null> {
|
||||
const overallSignal = withTimeoutSignal(signal, 60_000);
|
||||
return this.runExclusive(async () => {
|
||||
throwIfAborted(overallSignal);
|
||||
if (!String(link || "").trim()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!this.cookiesImported) {
|
||||
throw new Error("BestDebrid: Keine Cookies importiert. Bitte zuerst über Einstellungen eine Cookie-Datei importieren.");
|
||||
}
|
||||
|
||||
const result = await this.generate(link, overallSignal);
|
||||
if (result.kind === "success") {
|
||||
return result.value;
|
||||
}
|
||||
this.cookiesImported = false;
|
||||
throw new Error("BestDebrid: Nicht eingeloggt. Bitte neue Cookie-Datei importieren.");
|
||||
}, overallSignal);
|
||||
}
|
||||
|
||||
public async importCookiesFromFile(filePath: string): Promise<number> {
|
||||
const text = fs.readFileSync(filePath, "utf-8");
|
||||
const cookies = parseNetscapeCookieFile(text);
|
||||
const bestDebridCookies = dedupeCookies(cookies.filter((c) =>
|
||||
c.domain.includes("bestdebrid.com")
|
||||
));
|
||||
|
||||
if (bestDebridCookies.length === 0) {
|
||||
throw new Error("Keine BestDebrid-Cookies in der Datei gefunden");
|
||||
}
|
||||
|
||||
if (!bestDebridCookies.some((cookie) => isLikelyBestDebridAuthCookie(cookie.name))) {
|
||||
throw new Error("BestDebrid: Cookie-Datei enthält keinen Login-Cookie. Bitte nach dem Login erneut exportieren.");
|
||||
}
|
||||
|
||||
const currentSession = session.fromPartition(this.getPartition());
|
||||
await this.clearPartitionState(currentSession);
|
||||
|
||||
for (const cookie of bestDebridCookies) {
|
||||
const url = `https://${cookie.domain.replace(/^\./, "")}${cookie.path}`;
|
||||
const details: Parameters<typeof currentSession.cookies.set>[0] = {
|
||||
url,
|
||||
name: cookie.name,
|
||||
value: cookie.value,
|
||||
path: cookie.path,
|
||||
secure: cookie.secure,
|
||||
httpOnly: cookie.httpOnly,
|
||||
expirationDate: cookie.expirationDate > 0 ? cookie.expirationDate : undefined
|
||||
};
|
||||
if (cookie.includeSubdomains || cookie.domain.startsWith(".")) {
|
||||
details.domain = cookie.domain;
|
||||
}
|
||||
await currentSession.cookies.set(details);
|
||||
}
|
||||
|
||||
this.cookiesImported = true;
|
||||
logger.info(`BestDebrid: ${bestDebridCookies.length} Cookies importiert aus ${filePath}`);
|
||||
return bestDebridCookies.length;
|
||||
}
|
||||
|
||||
public async clearSessions(): Promise<void> {
|
||||
this.cookiesImported = false;
|
||||
for (const partition of [BESTDEBRID_PERSISTENT_PARTITION, BESTDEBRID_TRANSIENT_PARTITION]) {
|
||||
const currentSession = session.fromPartition(partition);
|
||||
try {
|
||||
await currentSession.clearStorageData({
|
||||
storages: ["cookies", "indexdb", "localstorage", "serviceworkers", "cachestorage"]
|
||||
});
|
||||
} catch {
|
||||
}
|
||||
try {
|
||||
await currentSession.clearCache();
|
||||
} catch {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public dispose(): void {
|
||||
}
|
||||
|
||||
private getPartition(): string {
|
||||
return this.getRememberSession() ? BESTDEBRID_PERSISTENT_PARTITION : BESTDEBRID_TRANSIENT_PARTITION;
|
||||
}
|
||||
|
||||
private async runExclusive<T>(job: () => Promise<T>, signal?: AbortSignal): Promise<T> {
|
||||
const queuedAt = Date.now();
|
||||
const queueWaitTimeoutMs = 90_000;
|
||||
const guardedJob = async (): Promise<T> => {
|
||||
throwIfAborted(signal);
|
||||
const waited = Date.now() - queuedAt;
|
||||
if (waited > queueWaitTimeoutMs) {
|
||||
throw new Error(`BestDebrid-Web Queue-Timeout (${Math.floor(waited / 1000)}s gewartet)`);
|
||||
}
|
||||
return job();
|
||||
};
|
||||
const run = this.queue.then(guardedJob, guardedJob);
|
||||
this.queue = run.then(() => undefined, () => undefined);
|
||||
return run;
|
||||
}
|
||||
|
||||
private async generate(link: string, signal?: AbortSignal): Promise<{ kind: "success"; value: UnrestrictedLink } | { kind: "login_required" }> {
|
||||
throwIfAborted(signal);
|
||||
const currentSession = session.fromPartition(this.getPartition());
|
||||
const response = await currentSession.fetch(BESTDEBRID_GENERATE_URL, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Accept: "application/json, text/javascript, */*; q=0.01",
|
||||
"Content-Type": "application/x-www-form-urlencoded; charset=UTF-8",
|
||||
Origin: BESTDEBRID_BASE_URL,
|
||||
Referer: BESTDEBRID_DOWNLOADER_URL,
|
||||
"User-Agent": BESTDEBRID_USER_AGENT,
|
||||
"X-Requested-With": "XMLHttpRequest"
|
||||
},
|
||||
body: new URLSearchParams({ link, pass: "", boxlinklist: "" }).toString(),
|
||||
signal: withTimeoutSignal(signal, 30_000)
|
||||
});
|
||||
|
||||
const text = await response.text();
|
||||
|
||||
if (!response.ok || text.trim().startsWith("<!") || text.trim().startsWith("<html")) {
|
||||
return { kind: "login_required" };
|
||||
}
|
||||
|
||||
const payload = parseJson(text.trim());
|
||||
if (!payload) {
|
||||
return { kind: "login_required" };
|
||||
}
|
||||
|
||||
const error = Number(payload.error ?? -1);
|
||||
const message = String(payload.message || "").trim();
|
||||
|
||||
if (error !== 0) {
|
||||
if (/login|log in|sign in|not logged|session|auth/i.test(message)) {
|
||||
return { kind: "login_required" };
|
||||
}
|
||||
if (looksLikeGuestAccessMessage(message)) {
|
||||
const authenticated = await this.isAuthenticated(currentSession, signal).catch(() => null);
|
||||
if (authenticated === false) {
|
||||
return { kind: "login_required" };
|
||||
}
|
||||
}
|
||||
throw new Error(`BestDebrid Web: ${message || "Unbekannter Fehler"}`);
|
||||
}
|
||||
|
||||
const directUrl = String(payload.link || "").trim();
|
||||
if (!directUrl) {
|
||||
throw new Error("BestDebrid Web: Antwort ohne Download-Link");
|
||||
}
|
||||
|
||||
const fileName = String(payload.filename || "").trim() || filenameFromUrl(directUrl) || filenameFromUrl(link);
|
||||
const fileSizeRaw = String(payload.size || "").trim();
|
||||
let fileSize: number | null = null;
|
||||
if (fileSizeRaw) {
|
||||
const match = fileSizeRaw.match(/([\d.]+)\s*(KB|KiB|MB|MiB|GB|GiB|TB|TiB|B)/i);
|
||||
if (match) {
|
||||
const value = parseFloat(match[1]);
|
||||
const unit = match[2].toUpperCase().replace("IB", "B");
|
||||
const multipliers: Record<string, number> = { B: 1, KB: 1024, MB: 1024 * 1024, GB: 1024 * 1024 * 1024, TB: 1024 * 1024 * 1024 * 1024 };
|
||||
fileSize = Math.floor(value * (multipliers[unit] || 1));
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
kind: "success",
|
||||
value: {
|
||||
directUrl,
|
||||
fileName,
|
||||
fileSize,
|
||||
retriesUsed: 0
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private async isAuthenticated(currentSession: Session, signal?: AbortSignal): Promise<boolean> {
|
||||
throwIfAborted(signal);
|
||||
const response = await currentSession.fetch(BESTDEBRID_DOWNLOADER_URL, {
|
||||
method: "GET",
|
||||
headers: {
|
||||
Accept: "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
|
||||
Referer: BESTDEBRID_BASE_URL,
|
||||
"User-Agent": BESTDEBRID_USER_AGENT
|
||||
},
|
||||
signal: withTimeoutSignal(signal, 20_000)
|
||||
});
|
||||
if (!response.ok) {
|
||||
return false;
|
||||
}
|
||||
const text = await response.text();
|
||||
return isAuthenticatedBestDebridHtml(text);
|
||||
}
|
||||
|
||||
private async clearPartitionState(currentSession: Session): Promise<void> {
|
||||
await currentSession.clearStorageData({
|
||||
storages: ["cookies", "indexdb", "localstorage", "serviceworkers", "cachestorage"]
|
||||
});
|
||||
try {
|
||||
await currentSession.clearCache();
|
||||
} catch {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,266 +1,266 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { ARCHIVE_TEMP_EXTENSIONS, LINK_ARTIFACT_EXTENSIONS, MAX_LINK_ARTIFACT_BYTES, RAR_SPLIT_RE, SAMPLE_DIR_NAMES, SAMPLE_TOKEN_RE, SAMPLE_VIDEO_EXTENSIONS } from "./constants";
|
||||
|
||||
async function yieldToLoop(): Promise<void> {
|
||||
await new Promise<void>((resolve) => {
|
||||
setTimeout(resolve, 0);
|
||||
});
|
||||
}
|
||||
|
||||
export function isArchiveOrTempFile(filePath: string): boolean {
|
||||
const lowerName = path.basename(filePath).toLowerCase();
|
||||
const ext = path.extname(lowerName);
|
||||
if (ARCHIVE_TEMP_EXTENSIONS.has(ext)) {
|
||||
return true;
|
||||
}
|
||||
if (lowerName.includes(".part") && lowerName.endsWith(".rar")) {
|
||||
return true;
|
||||
}
|
||||
return RAR_SPLIT_RE.test(lowerName);
|
||||
}
|
||||
|
||||
export function cleanupCancelledPackageArtifacts(packageDir: string): number {
|
||||
if (!fs.existsSync(packageDir)) {
|
||||
return 0;
|
||||
}
|
||||
let removed = 0;
|
||||
const stack = [packageDir];
|
||||
while (stack.length > 0) {
|
||||
const current = stack.pop() as string;
|
||||
let entries: fs.Dirent[] = [];
|
||||
try { entries = fs.readdirSync(current, { withFileTypes: true }); } catch { continue; }
|
||||
for (const entry of entries) {
|
||||
const full = path.join(current, entry.name);
|
||||
if (entry.isDirectory() && !entry.isSymbolicLink()) {
|
||||
stack.push(full);
|
||||
} else if (entry.isFile() && isArchiveOrTempFile(full)) {
|
||||
try {
|
||||
fs.rmSync(full, { force: true });
|
||||
removed += 1;
|
||||
} catch {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return removed;
|
||||
}
|
||||
|
||||
export async function cleanupCancelledPackageArtifactsAsync(
|
||||
packageDir: string,
|
||||
options: { shouldAbort?: () => boolean } = {}
|
||||
): Promise<number> {
|
||||
try {
|
||||
await fs.promises.access(packageDir, fs.constants.F_OK);
|
||||
} catch {
|
||||
return 0;
|
||||
}
|
||||
|
||||
let removed = 0;
|
||||
let touched = 0;
|
||||
const stack = [packageDir];
|
||||
while (stack.length > 0) {
|
||||
if (options.shouldAbort?.()) {
|
||||
return removed;
|
||||
}
|
||||
const current = stack.pop() as string;
|
||||
let entries: fs.Dirent[] = [];
|
||||
try {
|
||||
entries = await fs.promises.readdir(current, { withFileTypes: true });
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const entry of entries) {
|
||||
if (options.shouldAbort?.()) {
|
||||
return removed;
|
||||
}
|
||||
const full = path.join(current, entry.name);
|
||||
if (entry.isDirectory() && !entry.isSymbolicLink()) {
|
||||
stack.push(full);
|
||||
} else if (entry.isFile() && isArchiveOrTempFile(full)) {
|
||||
try {
|
||||
await fs.promises.rm(full, { force: true });
|
||||
removed += 1;
|
||||
} catch {
|
||||
}
|
||||
}
|
||||
|
||||
touched += 1;
|
||||
if (touched % 80 === 0) {
|
||||
await yieldToLoop();
|
||||
}
|
||||
}
|
||||
}
|
||||
return removed;
|
||||
}
|
||||
|
||||
export async function removeDownloadLinkArtifacts(
|
||||
extractDir: string,
|
||||
options: { shouldAbort?: () => boolean } = {}
|
||||
): Promise<number> {
|
||||
try {
|
||||
await fs.promises.access(extractDir);
|
||||
} catch {
|
||||
return 0;
|
||||
}
|
||||
let removed = 0;
|
||||
const stack = [extractDir];
|
||||
while (stack.length > 0) {
|
||||
if (options.shouldAbort?.()) {
|
||||
return removed;
|
||||
}
|
||||
const current = stack.pop() as string;
|
||||
let entries: fs.Dirent[] = [];
|
||||
try { entries = await fs.promises.readdir(current, { withFileTypes: true }); } catch { continue; }
|
||||
for (const entry of entries) {
|
||||
if (options.shouldAbort?.()) {
|
||||
return removed;
|
||||
}
|
||||
const full = path.join(current, entry.name);
|
||||
if (entry.isDirectory() && !entry.isSymbolicLink()) {
|
||||
stack.push(full);
|
||||
continue;
|
||||
}
|
||||
if (!entry.isFile()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const ext = path.extname(entry.name).toLowerCase();
|
||||
const name = entry.name.toLowerCase();
|
||||
let shouldDelete = LINK_ARTIFACT_EXTENSIONS.has(ext);
|
||||
if (!shouldDelete && [".txt", ".html", ".htm", ".nfo"].includes(ext)) {
|
||||
if (/[._\- ](links?|downloads?|urls?|dlc)([._\- ]|$)/i.test(name)) {
|
||||
try {
|
||||
const stat = await fs.promises.stat(full);
|
||||
if (stat.size <= MAX_LINK_ARTIFACT_BYTES) {
|
||||
const text = await fs.promises.readFile(full, "utf8");
|
||||
shouldDelete = /https?:\/\//i.test(text);
|
||||
}
|
||||
} catch {
|
||||
shouldDelete = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (shouldDelete) {
|
||||
try {
|
||||
await fs.promises.rm(full, { force: true });
|
||||
removed += 1;
|
||||
} catch {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return removed;
|
||||
}
|
||||
|
||||
export async function removeSampleArtifacts(
|
||||
extractDir: string,
|
||||
options: { shouldAbort?: () => boolean } = {}
|
||||
): Promise<{ files: number; dirs: number }> {
|
||||
try {
|
||||
await fs.promises.access(extractDir);
|
||||
} catch {
|
||||
return { files: 0, dirs: 0 };
|
||||
}
|
||||
|
||||
let removedFiles = 0;
|
||||
let removedDirs = 0;
|
||||
const sampleDirs: string[] = [];
|
||||
const stack = [extractDir];
|
||||
|
||||
const countFilesRecursive = async (rootDir: string): Promise<number> => {
|
||||
let count = 0;
|
||||
const dirs = [rootDir];
|
||||
while (dirs.length > 0) {
|
||||
const current = dirs.pop() as string;
|
||||
let entries: fs.Dirent[] = [];
|
||||
try {
|
||||
entries = await fs.promises.readdir(current, { withFileTypes: true });
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
for (const entry of entries) {
|
||||
const full = path.join(current, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
try {
|
||||
const stat = await fs.promises.lstat(full);
|
||||
if (stat.isSymbolicLink()) {
|
||||
continue;
|
||||
}
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
dirs.push(full);
|
||||
} else if (entry.isFile()) {
|
||||
count += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
return count;
|
||||
};
|
||||
|
||||
while (stack.length > 0) {
|
||||
if (options.shouldAbort?.()) {
|
||||
return { files: removedFiles, dirs: removedDirs };
|
||||
}
|
||||
const current = stack.pop() as string;
|
||||
let entries: fs.Dirent[] = [];
|
||||
try { entries = await fs.promises.readdir(current, { withFileTypes: true }); } catch { continue; }
|
||||
for (const entry of entries) {
|
||||
if (options.shouldAbort?.()) {
|
||||
return { files: removedFiles, dirs: removedDirs };
|
||||
}
|
||||
const full = path.join(current, entry.name);
|
||||
if (entry.isDirectory() || entry.isSymbolicLink()) {
|
||||
const base = entry.name.toLowerCase();
|
||||
if (SAMPLE_DIR_NAMES.has(base)) {
|
||||
sampleDirs.push(full);
|
||||
continue;
|
||||
}
|
||||
if (entry.isDirectory()) {
|
||||
stack.push(full);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (!entry.isFile()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const stem = path.parse(entry.name).name.toLowerCase();
|
||||
const ext = path.extname(entry.name).toLowerCase();
|
||||
const isSampleVideo = SAMPLE_VIDEO_EXTENSIONS.has(ext) && SAMPLE_TOKEN_RE.test(stem);
|
||||
|
||||
if (isSampleVideo) {
|
||||
try {
|
||||
await fs.promises.rm(full, { force: true });
|
||||
removedFiles += 1;
|
||||
} catch {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
sampleDirs.sort((a, b) => b.length - a.length);
|
||||
for (const dir of sampleDirs) {
|
||||
if (options.shouldAbort?.()) {
|
||||
return { files: removedFiles, dirs: removedDirs };
|
||||
}
|
||||
try {
|
||||
const stat = await fs.promises.lstat(dir);
|
||||
if (stat.isSymbolicLink()) {
|
||||
await fs.promises.rm(dir, { force: true });
|
||||
removedDirs += 1;
|
||||
continue;
|
||||
}
|
||||
const filesInDir = await countFilesRecursive(dir);
|
||||
await fs.promises.rm(dir, { recursive: true, force: true });
|
||||
removedFiles += filesInDir;
|
||||
removedDirs += 1;
|
||||
} catch {
|
||||
}
|
||||
}
|
||||
|
||||
return { files: removedFiles, dirs: removedDirs };
|
||||
}
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { ARCHIVE_TEMP_EXTENSIONS, LINK_ARTIFACT_EXTENSIONS, MAX_LINK_ARTIFACT_BYTES, RAR_SPLIT_RE, SAMPLE_DIR_NAMES, SAMPLE_TOKEN_RE, SAMPLE_VIDEO_EXTENSIONS } from "./constants";
|
||||
|
||||
async function yieldToLoop(): Promise<void> {
|
||||
await new Promise<void>((resolve) => {
|
||||
setTimeout(resolve, 0);
|
||||
});
|
||||
}
|
||||
|
||||
export function isArchiveOrTempFile(filePath: string): boolean {
|
||||
const lowerName = path.basename(filePath).toLowerCase();
|
||||
const ext = path.extname(lowerName);
|
||||
if (ARCHIVE_TEMP_EXTENSIONS.has(ext)) {
|
||||
return true;
|
||||
}
|
||||
if (lowerName.includes(".part") && lowerName.endsWith(".rar")) {
|
||||
return true;
|
||||
}
|
||||
return RAR_SPLIT_RE.test(lowerName);
|
||||
}
|
||||
|
||||
export function cleanupCancelledPackageArtifacts(packageDir: string): number {
|
||||
if (!fs.existsSync(packageDir)) {
|
||||
return 0;
|
||||
}
|
||||
let removed = 0;
|
||||
const stack = [packageDir];
|
||||
while (stack.length > 0) {
|
||||
const current = stack.pop() as string;
|
||||
let entries: fs.Dirent[] = [];
|
||||
try { entries = fs.readdirSync(current, { withFileTypes: true }); } catch { continue; }
|
||||
for (const entry of entries) {
|
||||
const full = path.join(current, entry.name);
|
||||
if (entry.isDirectory() && !entry.isSymbolicLink()) {
|
||||
stack.push(full);
|
||||
} else if (entry.isFile() && isArchiveOrTempFile(full)) {
|
||||
try {
|
||||
fs.rmSync(full, { force: true });
|
||||
removed += 1;
|
||||
} catch {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return removed;
|
||||
}
|
||||
|
||||
export async function cleanupCancelledPackageArtifactsAsync(
|
||||
packageDir: string,
|
||||
options: { shouldAbort?: () => boolean } = {}
|
||||
): Promise<number> {
|
||||
try {
|
||||
await fs.promises.access(packageDir, fs.constants.F_OK);
|
||||
} catch {
|
||||
return 0;
|
||||
}
|
||||
|
||||
let removed = 0;
|
||||
let touched = 0;
|
||||
const stack = [packageDir];
|
||||
while (stack.length > 0) {
|
||||
if (options.shouldAbort?.()) {
|
||||
return removed;
|
||||
}
|
||||
const current = stack.pop() as string;
|
||||
let entries: fs.Dirent[] = [];
|
||||
try {
|
||||
entries = await fs.promises.readdir(current, { withFileTypes: true });
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const entry of entries) {
|
||||
if (options.shouldAbort?.()) {
|
||||
return removed;
|
||||
}
|
||||
const full = path.join(current, entry.name);
|
||||
if (entry.isDirectory() && !entry.isSymbolicLink()) {
|
||||
stack.push(full);
|
||||
} else if (entry.isFile() && isArchiveOrTempFile(full)) {
|
||||
try {
|
||||
await fs.promises.rm(full, { force: true });
|
||||
removed += 1;
|
||||
} catch {
|
||||
}
|
||||
}
|
||||
|
||||
touched += 1;
|
||||
if (touched % 80 === 0) {
|
||||
await yieldToLoop();
|
||||
}
|
||||
}
|
||||
}
|
||||
return removed;
|
||||
}
|
||||
|
||||
export async function removeDownloadLinkArtifacts(
|
||||
extractDir: string,
|
||||
options: { shouldAbort?: () => boolean } = {}
|
||||
): Promise<number> {
|
||||
try {
|
||||
await fs.promises.access(extractDir);
|
||||
} catch {
|
||||
return 0;
|
||||
}
|
||||
let removed = 0;
|
||||
const stack = [extractDir];
|
||||
while (stack.length > 0) {
|
||||
if (options.shouldAbort?.()) {
|
||||
return removed;
|
||||
}
|
||||
const current = stack.pop() as string;
|
||||
let entries: fs.Dirent[] = [];
|
||||
try { entries = await fs.promises.readdir(current, { withFileTypes: true }); } catch { continue; }
|
||||
for (const entry of entries) {
|
||||
if (options.shouldAbort?.()) {
|
||||
return removed;
|
||||
}
|
||||
const full = path.join(current, entry.name);
|
||||
if (entry.isDirectory() && !entry.isSymbolicLink()) {
|
||||
stack.push(full);
|
||||
continue;
|
||||
}
|
||||
if (!entry.isFile()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const ext = path.extname(entry.name).toLowerCase();
|
||||
const name = entry.name.toLowerCase();
|
||||
let shouldDelete = LINK_ARTIFACT_EXTENSIONS.has(ext);
|
||||
if (!shouldDelete && [".txt", ".html", ".htm", ".nfo"].includes(ext)) {
|
||||
if (/[._\- ](links?|downloads?|urls?|dlc)([._\- ]|$)/i.test(name)) {
|
||||
try {
|
||||
const stat = await fs.promises.stat(full);
|
||||
if (stat.size <= MAX_LINK_ARTIFACT_BYTES) {
|
||||
const text = await fs.promises.readFile(full, "utf8");
|
||||
shouldDelete = /https?:\/\//i.test(text);
|
||||
}
|
||||
} catch {
|
||||
shouldDelete = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (shouldDelete) {
|
||||
try {
|
||||
await fs.promises.rm(full, { force: true });
|
||||
removed += 1;
|
||||
} catch {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return removed;
|
||||
}
|
||||
|
||||
export async function removeSampleArtifacts(
|
||||
extractDir: string,
|
||||
options: { shouldAbort?: () => boolean } = {}
|
||||
): Promise<{ files: number; dirs: number }> {
|
||||
try {
|
||||
await fs.promises.access(extractDir);
|
||||
} catch {
|
||||
return { files: 0, dirs: 0 };
|
||||
}
|
||||
|
||||
let removedFiles = 0;
|
||||
let removedDirs = 0;
|
||||
const sampleDirs: string[] = [];
|
||||
const stack = [extractDir];
|
||||
|
||||
const countFilesRecursive = async (rootDir: string): Promise<number> => {
|
||||
let count = 0;
|
||||
const dirs = [rootDir];
|
||||
while (dirs.length > 0) {
|
||||
const current = dirs.pop() as string;
|
||||
let entries: fs.Dirent[] = [];
|
||||
try {
|
||||
entries = await fs.promises.readdir(current, { withFileTypes: true });
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
for (const entry of entries) {
|
||||
const full = path.join(current, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
try {
|
||||
const stat = await fs.promises.lstat(full);
|
||||
if (stat.isSymbolicLink()) {
|
||||
continue;
|
||||
}
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
dirs.push(full);
|
||||
} else if (entry.isFile()) {
|
||||
count += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
return count;
|
||||
};
|
||||
|
||||
while (stack.length > 0) {
|
||||
if (options.shouldAbort?.()) {
|
||||
return { files: removedFiles, dirs: removedDirs };
|
||||
}
|
||||
const current = stack.pop() as string;
|
||||
let entries: fs.Dirent[] = [];
|
||||
try { entries = await fs.promises.readdir(current, { withFileTypes: true }); } catch { continue; }
|
||||
for (const entry of entries) {
|
||||
if (options.shouldAbort?.()) {
|
||||
return { files: removedFiles, dirs: removedDirs };
|
||||
}
|
||||
const full = path.join(current, entry.name);
|
||||
if (entry.isDirectory() || entry.isSymbolicLink()) {
|
||||
const base = entry.name.toLowerCase();
|
||||
if (SAMPLE_DIR_NAMES.has(base)) {
|
||||
sampleDirs.push(full);
|
||||
continue;
|
||||
}
|
||||
if (entry.isDirectory()) {
|
||||
stack.push(full);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (!entry.isFile()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const stem = path.parse(entry.name).name.toLowerCase();
|
||||
const ext = path.extname(entry.name).toLowerCase();
|
||||
const isSampleVideo = SAMPLE_VIDEO_EXTENSIONS.has(ext) && SAMPLE_TOKEN_RE.test(stem);
|
||||
|
||||
if (isSampleVideo) {
|
||||
try {
|
||||
await fs.promises.rm(full, { force: true });
|
||||
removedFiles += 1;
|
||||
} catch {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
sampleDirs.sort((a, b) => b.length - a.length);
|
||||
for (const dir of sampleDirs) {
|
||||
if (options.shouldAbort?.()) {
|
||||
return { files: removedFiles, dirs: removedDirs };
|
||||
}
|
||||
try {
|
||||
const stat = await fs.promises.lstat(dir);
|
||||
if (stat.isSymbolicLink()) {
|
||||
await fs.promises.rm(dir, { force: true });
|
||||
removedDirs += 1;
|
||||
continue;
|
||||
}
|
||||
const filesInDir = await countFilesRecursive(dir);
|
||||
await fs.promises.rm(dir, { recursive: true, force: true });
|
||||
removedFiles += filesInDir;
|
||||
removedDirs += 1;
|
||||
} catch {
|
||||
}
|
||||
}
|
||||
|
||||
return { files: removedFiles, dirs: removedDirs };
|
||||
}
|
||||
|
||||
@ -1,59 +1,59 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
const PREFIX = "rddiag:v1:";
|
||||
|
||||
function base64urlEncode(value: string): string {
|
||||
return Buffer.from(value, "utf8")
|
||||
.toString("base64")
|
||||
.replace(/\+/g, "-")
|
||||
.replace(/\//g, "_")
|
||||
.replace(/=+$/, "");
|
||||
}
|
||||
|
||||
export interface ConnectionCodeInput {
|
||||
host: string;
|
||||
port: number;
|
||||
token: string;
|
||||
name?: string;
|
||||
scheme?: "http" | "https";
|
||||
fingerprint?: string;
|
||||
}
|
||||
|
||||
export function encodeConnectionCode(input: ConnectionCodeInput): string {
|
||||
const host = String(input.host || "").trim();
|
||||
if (!host) throw new Error("Host fehlt fuer Verbindungscode");
|
||||
const port = Number(input.port);
|
||||
if (!Number.isInteger(port) || port < 1 || port > 65535) throw new Error("Port ungueltig fuer Verbindungscode");
|
||||
if (!input.token) throw new Error("Token fehlt fuer Verbindungscode");
|
||||
const payload: Record<string, unknown> = { v: 1, h: host, p: port, t: input.token };
|
||||
if (input.name) payload.n = String(input.name);
|
||||
if (input.fingerprint) payload.fp = String(input.fingerprint);
|
||||
if (input.scheme && input.scheme !== "http") payload.s = String(input.scheme);
|
||||
return PREFIX + base64urlEncode(JSON.stringify(payload));
|
||||
}
|
||||
|
||||
export interface RemoteMeta {
|
||||
publicHost: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
function remoteMetaPath(baseDir: string): string {
|
||||
return path.join(baseDir, "debug_remote.json");
|
||||
}
|
||||
|
||||
export function loadRemoteMeta(baseDir: string): RemoteMeta {
|
||||
try {
|
||||
const parsed = JSON.parse(fs.readFileSync(remoteMetaPath(baseDir), "utf8"));
|
||||
return {
|
||||
publicHost: String(parsed.publicHost || ""),
|
||||
name: String(parsed.name || "")
|
||||
};
|
||||
} catch {
|
||||
return { publicHost: "", name: "" };
|
||||
}
|
||||
}
|
||||
|
||||
export function saveRemoteMeta(baseDir: string, meta: RemoteMeta): void {
|
||||
fs.writeFileSync(remoteMetaPath(baseDir), JSON.stringify({ publicHost: meta.publicHost, name: meta.name }, null, 2), "utf8");
|
||||
}
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
const PREFIX = "rddiag:v1:";
|
||||
|
||||
function base64urlEncode(value: string): string {
|
||||
return Buffer.from(value, "utf8")
|
||||
.toString("base64")
|
||||
.replace(/\+/g, "-")
|
||||
.replace(/\//g, "_")
|
||||
.replace(/=+$/, "");
|
||||
}
|
||||
|
||||
export interface ConnectionCodeInput {
|
||||
host: string;
|
||||
port: number;
|
||||
token: string;
|
||||
name?: string;
|
||||
scheme?: "http" | "https";
|
||||
fingerprint?: string;
|
||||
}
|
||||
|
||||
export function encodeConnectionCode(input: ConnectionCodeInput): string {
|
||||
const host = String(input.host || "").trim();
|
||||
if (!host) throw new Error("Host fehlt fuer Verbindungscode");
|
||||
const port = Number(input.port);
|
||||
if (!Number.isInteger(port) || port < 1 || port > 65535) throw new Error("Port ungueltig fuer Verbindungscode");
|
||||
if (!input.token) throw new Error("Token fehlt fuer Verbindungscode");
|
||||
const payload: Record<string, unknown> = { v: 1, h: host, p: port, t: input.token };
|
||||
if (input.name) payload.n = String(input.name);
|
||||
if (input.fingerprint) payload.fp = String(input.fingerprint);
|
||||
if (input.scheme && input.scheme !== "http") payload.s = String(input.scheme);
|
||||
return PREFIX + base64urlEncode(JSON.stringify(payload));
|
||||
}
|
||||
|
||||
export interface RemoteMeta {
|
||||
publicHost: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
function remoteMetaPath(baseDir: string): string {
|
||||
return path.join(baseDir, "debug_remote.json");
|
||||
}
|
||||
|
||||
export function loadRemoteMeta(baseDir: string): RemoteMeta {
|
||||
try {
|
||||
const parsed = JSON.parse(fs.readFileSync(remoteMetaPath(baseDir), "utf8"));
|
||||
return {
|
||||
publicHost: String(parsed.publicHost || ""),
|
||||
name: String(parsed.name || "")
|
||||
};
|
||||
} catch {
|
||||
return { publicHost: "", name: "" };
|
||||
}
|
||||
}
|
||||
|
||||
export function saveRemoteMeta(baseDir: string, meta: RemoteMeta): void {
|
||||
fs.writeFileSync(remoteMetaPath(baseDir), JSON.stringify({ publicHost: meta.publicHost, name: meta.name }, null, 2), "utf8");
|
||||
}
|
||||
|
||||
@ -1,142 +1,141 @@
|
||||
import path from "node:path";
|
||||
import os from "node:os";
|
||||
import { AppSettings } from "../shared/types";
|
||||
import { getProviderUsageDayKey } from "../shared/provider-daily-limits";
|
||||
import packageJson from "../../package.json";
|
||||
|
||||
export const APP_NAME = "Multi Debrid Downloader";
|
||||
export const APP_VERSION: string = packageJson.version;
|
||||
export const API_BASE_URL = "https://api.real-debrid.com/rest/1.0";
|
||||
|
||||
export const DCRYPT_UPLOAD_URL = "https://dcrypt.it/decrypt/upload";
|
||||
export const DCRYPT_PASTE_URL = "https://dcrypt.it/decrypt/paste";
|
||||
export const DLC_SERVICE_URL = "https://service.jdownloader.org/dlcrypt/service.php?srcType=dlc&destType=pylo&data={KEY}";
|
||||
export const DLC_AES_KEY = Buffer.from("cb99b5cbc24db398", "utf8");
|
||||
export const DLC_AES_IV = Buffer.from("9bc24cb995cb8db3", "utf8");
|
||||
|
||||
export const REQUEST_RETRIES = 3;
|
||||
export const CHUNK_SIZE = 512 * 1024;
|
||||
|
||||
export const WRITE_BUFFER_SIZE = 512 * 1024;
|
||||
export const WRITE_FLUSH_TIMEOUT_MS = 2000;
|
||||
export const ALLOCATION_UNIT_SIZE = 4096;
|
||||
export const STREAM_HIGH_WATER_MARK = 512 * 1024;
|
||||
export const DISK_BUSY_THRESHOLD_MS = 300;
|
||||
export const DISK_BUSY_STATUS_THRESHOLD_MS = 500;
|
||||
|
||||
export const SAMPLE_DIR_NAMES = new Set(["sample", "samples"]);
|
||||
export const SAMPLE_VIDEO_EXTENSIONS = new Set([".mkv", ".mp4", ".avi", ".mov", ".wmv", ".m4v", ".ts", ".m2ts", ".webm"]);
|
||||
export const LINK_ARTIFACT_EXTENSIONS = new Set([".url", ".webloc", ".dlc", ".rsdf", ".ccf"]);
|
||||
export const SAMPLE_TOKEN_RE = /(^|[._\-\s])sample([._\-\s]|$)/i;
|
||||
|
||||
export const ARCHIVE_TEMP_EXTENSIONS = new Set([".rar", ".zip", ".7z", ".tmp", ".part", ".tar", ".gz", ".bz2", ".xz", ".rev"]);
|
||||
export const RAR_SPLIT_RE = /\.r\d{2,3}$/i;
|
||||
|
||||
export const MAX_MANIFEST_FILE_BYTES = 5 * 1024 * 1024;
|
||||
export const MAX_LINK_ARTIFACT_BYTES = 256 * 1024;
|
||||
export const SPEED_WINDOW_SECONDS = 1;
|
||||
export const CLIPBOARD_POLL_INTERVAL_MS = 2000;
|
||||
|
||||
export const DEFAULT_UPDATE_REPO = "Sucukdeluxe/multi-debrid-downloader";
|
||||
export const ONLINE_BACKUP_API_URL = "https://downloader.24-music.de/backup-api";
|
||||
|
||||
export function defaultSettings(): AppSettings {
|
||||
const baseDir = path.join(os.homedir(), "Downloads", "RealDebrid");
|
||||
return {
|
||||
token: "",
|
||||
realDebridUseWebLogin: false,
|
||||
megaLogin: "",
|
||||
megaPassword: "",
|
||||
megaCredentials: "",
|
||||
megaDebridApiEnabled: false,
|
||||
megaDebridWebEnabled: false,
|
||||
megaDebridPreferApi: true,
|
||||
bestToken: "",
|
||||
bestDebridUseWebLogin: false,
|
||||
allDebridToken: "",
|
||||
allDebridUseWebLogin: false,
|
||||
ddownloadLogin: "",
|
||||
ddownloadPassword: "",
|
||||
oneFichierApiKey: "",
|
||||
debridLinkApiKeys: "",
|
||||
debridLinkDisabledKeyIds: [],
|
||||
linkSnappyLogin: "",
|
||||
linkSnappyPassword: "",
|
||||
archivePasswordList: "",
|
||||
rememberToken: true,
|
||||
providerOrder: ["realdebrid", "megadebrid-api", "bestdebrid"],
|
||||
providerPrimary: "realdebrid",
|
||||
providerSecondary: "megadebrid-api",
|
||||
providerTertiary: "bestdebrid",
|
||||
autoProviderFallback: true,
|
||||
outputDir: baseDir,
|
||||
packageName: "",
|
||||
autoExtract: true,
|
||||
autoRename4sf4sj: false,
|
||||
keepGermanAudioOnly: false,
|
||||
germanAudioMode: "tag",
|
||||
extractDir: path.join(baseDir, "_entpackt"),
|
||||
collectMkvToLibrary: false,
|
||||
mkvLibraryDir: path.join(baseDir, "_mkv"),
|
||||
createExtractSubfolder: true,
|
||||
hybridExtract: true,
|
||||
cleanupMode: "none",
|
||||
extractConflictMode: "overwrite",
|
||||
removeLinkFilesAfterExtract: false,
|
||||
removeSamplesAfterExtract: false,
|
||||
enableIntegrityCheck: true,
|
||||
autoResumeOnStart: true,
|
||||
autoReconnect: false,
|
||||
reconnectWaitSeconds: 45,
|
||||
completedCleanupPolicy: "never",
|
||||
maxParallel: 4,
|
||||
maxParallelExtract: 2,
|
||||
retryLimit: 0,
|
||||
speedLimitEnabled: false,
|
||||
speedLimitKbps: 0,
|
||||
speedLimitMode: "global",
|
||||
updateRepo: DEFAULT_UPDATE_REPO,
|
||||
autoUpdateCheck: true,
|
||||
clipboardWatch: false,
|
||||
minimizeToTray: false,
|
||||
theme: "dark" as const,
|
||||
collapseNewPackages: true,
|
||||
historyRetentionMode: "permanent",
|
||||
historyMaxEntries: 500,
|
||||
historyMaxAgeDays: 0,
|
||||
accountListShowDetailedDebridLinkKeys: false,
|
||||
autoSortPackagesByProgress: true,
|
||||
autoSkipExtracted: false,
|
||||
hideExtractedItems: true,
|
||||
confirmDeleteSelection: true,
|
||||
backupIncludeDownloads: false,
|
||||
backupIncludeRemoteDiagnostics: false,
|
||||
notifyUrl: "",
|
||||
notifyMention: "",
|
||||
notifyOnPackageCompleted: false,
|
||||
notifyOnPackageFailed: false,
|
||||
notifyOnRunFinished: false,
|
||||
totalDownloadedAllTime: 0,
|
||||
totalCompletedFilesAllTime: 0,
|
||||
totalRuntimeAllTimeMs: 0,
|
||||
bandwidthSchedules: [],
|
||||
columnOrder: ["name", "size", "progress", "hoster", "account", "prio", "status", "speed"],
|
||||
extractCpuPriority: "high",
|
||||
autoExtractWhenStopped: true,
|
||||
disabledProviders: [],
|
||||
hosterRouting: {},
|
||||
providerDailyLimitBytes: {},
|
||||
providerDailyUsageBytes: {},
|
||||
providerTotalUsageBytes: {},
|
||||
debridLinkApiKeyDailyLimitBytes: {},
|
||||
debridLinkApiKeyDailyUsageBytes: {},
|
||||
debridLinkApiKeyTotalUsageBytes: {},
|
||||
megaDebridDisabledAccountIds: [],
|
||||
megaDebridAccountDailyLimitBytes: {},
|
||||
megaDebridAccountDailyUsageBytes: {},
|
||||
megaDebridAccountTotalUsageBytes: {},
|
||||
debridAccountStatuses: {},
|
||||
providerDailyUsageDay: getProviderUsageDayKey(),
|
||||
scheduledStartEpochMs: 0
|
||||
};
|
||||
}
|
||||
import path from "node:path";
|
||||
import os from "node:os";
|
||||
import { AppSettings } from "../shared/types";
|
||||
import { getProviderUsageDayKey } from "../shared/provider-daily-limits";
|
||||
import packageJson from "../../package.json";
|
||||
|
||||
export const APP_NAME = "Multi Debrid Downloader";
|
||||
export const APP_VERSION: string = packageJson.version;
|
||||
export const API_BASE_URL = "https://api.real-debrid.com/rest/1.0";
|
||||
|
||||
export const DCRYPT_UPLOAD_URL = "https://dcrypt.it/decrypt/upload";
|
||||
export const DCRYPT_PASTE_URL = "https://dcrypt.it/decrypt/paste";
|
||||
export const DLC_SERVICE_URL = "https://service.jdownloader.org/dlcrypt/service.php?srcType=dlc&destType=pylo&data={KEY}";
|
||||
export const DLC_AES_KEY = Buffer.from("cb99b5cbc24db398", "utf8");
|
||||
export const DLC_AES_IV = Buffer.from("9bc24cb995cb8db3", "utf8");
|
||||
|
||||
export const REQUEST_RETRIES = 3;
|
||||
export const CHUNK_SIZE = 512 * 1024;
|
||||
|
||||
export const WRITE_BUFFER_SIZE = 512 * 1024;
|
||||
export const WRITE_FLUSH_TIMEOUT_MS = 2000;
|
||||
export const ALLOCATION_UNIT_SIZE = 4096;
|
||||
export const STREAM_HIGH_WATER_MARK = 512 * 1024;
|
||||
export const DISK_BUSY_THRESHOLD_MS = 300;
|
||||
export const DISK_BUSY_STATUS_THRESHOLD_MS = 500;
|
||||
|
||||
export const SAMPLE_DIR_NAMES = new Set(["sample", "samples"]);
|
||||
export const SAMPLE_VIDEO_EXTENSIONS = new Set([".mkv", ".mp4", ".avi", ".mov", ".wmv", ".m4v", ".ts", ".m2ts", ".webm"]);
|
||||
export const LINK_ARTIFACT_EXTENSIONS = new Set([".url", ".webloc", ".dlc", ".rsdf", ".ccf"]);
|
||||
export const SAMPLE_TOKEN_RE = /(^|[._\-\s])sample([._\-\s]|$)/i;
|
||||
|
||||
export const ARCHIVE_TEMP_EXTENSIONS = new Set([".rar", ".zip", ".7z", ".tmp", ".part", ".tar", ".gz", ".bz2", ".xz", ".rev"]);
|
||||
export const RAR_SPLIT_RE = /\.r\d{2,3}$/i;
|
||||
|
||||
export const MAX_MANIFEST_FILE_BYTES = 5 * 1024 * 1024;
|
||||
export const MAX_LINK_ARTIFACT_BYTES = 256 * 1024;
|
||||
export const SPEED_WINDOW_SECONDS = 1;
|
||||
export const CLIPBOARD_POLL_INTERVAL_MS = 2000;
|
||||
|
||||
export const DEFAULT_UPDATE_REPO = "Administrator/real-debrid-downloader";
|
||||
|
||||
export function defaultSettings(): AppSettings {
|
||||
const baseDir = path.join(os.homedir(), "Downloads", "RealDebrid");
|
||||
return {
|
||||
token: "",
|
||||
realDebridUseWebLogin: false,
|
||||
megaLogin: "",
|
||||
megaPassword: "",
|
||||
megaCredentials: "",
|
||||
megaDebridApiEnabled: false,
|
||||
megaDebridWebEnabled: false,
|
||||
megaDebridPreferApi: true,
|
||||
bestToken: "",
|
||||
bestDebridUseWebLogin: false,
|
||||
allDebridToken: "",
|
||||
allDebridUseWebLogin: false,
|
||||
ddownloadLogin: "",
|
||||
ddownloadPassword: "",
|
||||
oneFichierApiKey: "",
|
||||
debridLinkApiKeys: "",
|
||||
debridLinkDisabledKeyIds: [],
|
||||
linkSnappyLogin: "",
|
||||
linkSnappyPassword: "",
|
||||
archivePasswordList: "",
|
||||
rememberToken: true,
|
||||
providerOrder: ["realdebrid", "megadebrid-api", "bestdebrid"],
|
||||
providerPrimary: "realdebrid",
|
||||
providerSecondary: "megadebrid-api",
|
||||
providerTertiary: "bestdebrid",
|
||||
autoProviderFallback: true,
|
||||
outputDir: baseDir,
|
||||
packageName: "",
|
||||
autoExtract: true,
|
||||
autoRename4sf4sj: false,
|
||||
keepGermanAudioOnly: false,
|
||||
germanAudioMode: "tag",
|
||||
extractDir: path.join(baseDir, "_entpackt"),
|
||||
collectMkvToLibrary: false,
|
||||
mkvLibraryDir: path.join(baseDir, "_mkv"),
|
||||
createExtractSubfolder: true,
|
||||
hybridExtract: true,
|
||||
cleanupMode: "none",
|
||||
extractConflictMode: "overwrite",
|
||||
removeLinkFilesAfterExtract: false,
|
||||
removeSamplesAfterExtract: false,
|
||||
enableIntegrityCheck: true,
|
||||
autoResumeOnStart: true,
|
||||
autoReconnect: false,
|
||||
reconnectWaitSeconds: 45,
|
||||
completedCleanupPolicy: "never",
|
||||
maxParallel: 4,
|
||||
maxParallelExtract: 2,
|
||||
retryLimit: 0,
|
||||
speedLimitEnabled: false,
|
||||
speedLimitKbps: 0,
|
||||
speedLimitMode: "global",
|
||||
updateRepo: DEFAULT_UPDATE_REPO,
|
||||
autoUpdateCheck: true,
|
||||
clipboardWatch: false,
|
||||
minimizeToTray: false,
|
||||
theme: "dark" as const,
|
||||
collapseNewPackages: true,
|
||||
historyRetentionMode: "permanent",
|
||||
historyMaxEntries: 500,
|
||||
historyMaxAgeDays: 0,
|
||||
accountListShowDetailedDebridLinkKeys: false,
|
||||
autoSortPackagesByProgress: true,
|
||||
autoSkipExtracted: false,
|
||||
hideExtractedItems: true,
|
||||
confirmDeleteSelection: true,
|
||||
backupIncludeDownloads: false,
|
||||
backupIncludeMcp: false,
|
||||
notifyUrl: "",
|
||||
notifyMention: "",
|
||||
notifyOnPackageCompleted: false,
|
||||
notifyOnPackageFailed: false,
|
||||
notifyOnRunFinished: false,
|
||||
totalDownloadedAllTime: 0,
|
||||
totalCompletedFilesAllTime: 0,
|
||||
totalRuntimeAllTimeMs: 0,
|
||||
bandwidthSchedules: [],
|
||||
columnOrder: ["name", "size", "progress", "hoster", "account", "prio", "status", "speed"],
|
||||
extractCpuPriority: "high",
|
||||
autoExtractWhenStopped: true,
|
||||
disabledProviders: [],
|
||||
hosterRouting: {},
|
||||
providerDailyLimitBytes: {},
|
||||
providerDailyUsageBytes: {},
|
||||
providerTotalUsageBytes: {},
|
||||
debridLinkApiKeyDailyLimitBytes: {},
|
||||
debridLinkApiKeyDailyUsageBytes: {},
|
||||
debridLinkApiKeyTotalUsageBytes: {},
|
||||
megaDebridDisabledAccountIds: [],
|
||||
megaDebridAccountDailyLimitBytes: {},
|
||||
megaDebridAccountDailyUsageBytes: {},
|
||||
megaDebridAccountTotalUsageBytes: {},
|
||||
debridAccountStatuses: {},
|
||||
providerDailyUsageDay: getProviderUsageDayKey(),
|
||||
scheduledStartEpochMs: 0
|
||||
};
|
||||
}
|
||||
|
||||
@ -1,316 +1,316 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import crypto from "node:crypto";
|
||||
import { DCRYPT_PASTE_URL, DCRYPT_UPLOAD_URL, DLC_AES_IV, DLC_AES_KEY, DLC_SERVICE_URL } from "./constants";
|
||||
import { compactErrorText, inferPackageNameFromLinks, isHttpLink, sanitizeFilename, uniquePreserveOrder } from "./utils";
|
||||
import { ParsedPackageInput } from "../shared/types";
|
||||
|
||||
const MAX_DLC_FILE_BYTES = 8 * 1024 * 1024;
|
||||
|
||||
function isContainerSizeValidationError(error: unknown): boolean {
|
||||
const text = compactErrorText(error);
|
||||
return /zu groß/i.test(text) || /DLC-Datei ungültig oder zu groß/i.test(text);
|
||||
}
|
||||
|
||||
function decodeDcryptPayload(responseText: string): unknown {
|
||||
let text = String(responseText || "").trim();
|
||||
const m = text.match(/<textarea[^>]*>([\s\S]*?)<\/textarea>/i);
|
||||
if (m) {
|
||||
text = m[1].replace(/"/g, '"').replace(/&/g, "&").trim();
|
||||
}
|
||||
if (!text) {
|
||||
return "";
|
||||
}
|
||||
try {
|
||||
return JSON.parse(text);
|
||||
} catch {
|
||||
return text;
|
||||
}
|
||||
}
|
||||
|
||||
function extractUrlsRecursive(data: unknown): string[] {
|
||||
if (typeof data === "string") {
|
||||
const found = data.match(/https?:\/\/[^\s"'<>]+/gi) ?? [];
|
||||
return uniquePreserveOrder(found.filter((url) => isHttpLink(url)));
|
||||
}
|
||||
if (Array.isArray(data)) {
|
||||
return uniquePreserveOrder(data.flatMap((item) => extractUrlsRecursive(item)));
|
||||
}
|
||||
if (data && typeof data === "object") {
|
||||
return uniquePreserveOrder(Object.values(data as Record<string, unknown>).flatMap((value) => extractUrlsRecursive(value)));
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
function groupLinksByName(links: string[]): ParsedPackageInput[] {
|
||||
const unique = uniquePreserveOrder(links.filter((link) => isHttpLink(link)));
|
||||
const grouped = new Map<string, string[]>();
|
||||
for (const link of unique) {
|
||||
const name = sanitizeFilename(inferPackageNameFromLinks([link]) || "Paket");
|
||||
const current = grouped.get(name) ?? [];
|
||||
current.push(link);
|
||||
grouped.set(name, current);
|
||||
}
|
||||
return Array.from(grouped.entries()).map(([name, packageLinks]) => ({ name, links: packageLinks }));
|
||||
}
|
||||
|
||||
function extractPackagesFromPayload(payload: unknown): ParsedPackageInput[] {
|
||||
const urls = extractUrlsRecursive(payload);
|
||||
if (urls.length === 0) {
|
||||
return [];
|
||||
}
|
||||
return groupLinksByName(urls);
|
||||
}
|
||||
|
||||
function decryptRcPayload(base64Rc: string): Buffer {
|
||||
const rcBytes = Buffer.from(base64Rc, "base64");
|
||||
const decipher = crypto.createDecipheriv("aes-128-cbc", DLC_AES_KEY, DLC_AES_IV);
|
||||
decipher.setAutoPadding(false);
|
||||
return Buffer.concat([decipher.update(rcBytes), decipher.final()]);
|
||||
}
|
||||
|
||||
function readDlcFileWithLimit(filePath: string): Buffer {
|
||||
const stat = fs.statSync(filePath);
|
||||
if (stat.size <= 0 || stat.size > MAX_DLC_FILE_BYTES) {
|
||||
throw new Error(`DLC-Datei ungültig oder zu groß (${Math.floor(stat.size)} B)`);
|
||||
}
|
||||
return fs.readFileSync(filePath);
|
||||
}
|
||||
|
||||
function parsePackagesFromDlcXml(xml: string): ParsedPackageInput[] {
|
||||
const packages: ParsedPackageInput[] = [];
|
||||
const packageRegex = /<package\s+[^>]*name="([^"]*)"[^>]*>([\s\S]*?)<\/package>/gi;
|
||||
|
||||
for (let m = packageRegex.exec(xml); m; m = packageRegex.exec(xml)) {
|
||||
const encodedName = m[1] || "";
|
||||
const packageBody = m[2] || "";
|
||||
let packageName = "";
|
||||
if (encodedName) {
|
||||
try {
|
||||
packageName = Buffer.from(encodedName, "base64").toString("utf8");
|
||||
} catch {
|
||||
packageName = encodedName;
|
||||
}
|
||||
}
|
||||
|
||||
const links: string[] = [];
|
||||
const fileNames: string[] = [];
|
||||
const fileRegex = /<file>([\s\S]*?)<\/file>/gi;
|
||||
for (let fm = fileRegex.exec(packageBody); fm; fm = fileRegex.exec(packageBody)) {
|
||||
const fileBody = fm[1] || "";
|
||||
const urlMatch = fileBody.match(/<url>(.*?)<\/url>/i);
|
||||
if (!urlMatch) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
const url = Buffer.from((urlMatch[1] || "").trim(), "base64").toString("utf8").trim();
|
||||
if (!isHttpLink(url)) {
|
||||
continue;
|
||||
}
|
||||
let fileName = "";
|
||||
const fnMatch = fileBody.match(/<filename>(.*?)<\/filename>/i);
|
||||
if (fnMatch?.[1]) {
|
||||
try {
|
||||
fileName = Buffer.from(fnMatch[1].trim(), "base64").toString("utf8").trim();
|
||||
} catch {
|
||||
}
|
||||
}
|
||||
links.push(url);
|
||||
fileNames.push(sanitizeFilename(fileName));
|
||||
} catch {
|
||||
}
|
||||
}
|
||||
|
||||
if (links.length === 0) {
|
||||
const urlRegex = /<url>(.*?)<\/url>/gi;
|
||||
for (let um = urlRegex.exec(packageBody); um; um = urlRegex.exec(packageBody)) {
|
||||
try {
|
||||
const url = Buffer.from((um[1] || "").trim(), "base64").toString("utf8").trim();
|
||||
if (isHttpLink(url)) {
|
||||
links.push(url);
|
||||
}
|
||||
} catch {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const uniqueLinks = uniquePreserveOrder(links);
|
||||
const hasFileNames = fileNames.some((fn) => fn.length > 0);
|
||||
if (uniqueLinks.length > 0) {
|
||||
const pkg: ParsedPackageInput = {
|
||||
name: sanitizeFilename(packageName || inferPackageNameFromLinks(uniqueLinks) || `Paket-${packages.length + 1}`),
|
||||
links: uniqueLinks
|
||||
};
|
||||
if (hasFileNames) {
|
||||
pkg.fileNames = fileNames;
|
||||
}
|
||||
packages.push(pkg);
|
||||
}
|
||||
}
|
||||
|
||||
return packages;
|
||||
}
|
||||
|
||||
async function decryptDlcLocal(filePath: string): Promise<ParsedPackageInput[]> {
|
||||
const content = readDlcFileWithLimit(filePath).toString("ascii").trim();
|
||||
if (content.length < 89) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const dlcKey = content.slice(-88);
|
||||
const dlcData = content.slice(0, -88);
|
||||
|
||||
const rcUrl = DLC_SERVICE_URL.replace("{KEY}", encodeURIComponent(dlcKey));
|
||||
const rcResponse = await fetch(rcUrl, { method: "GET", signal: AbortSignal.timeout(30000) });
|
||||
if (!rcResponse.ok) {
|
||||
return [];
|
||||
}
|
||||
const rcText = await rcResponse.text();
|
||||
const rcMatch = rcText.match(/<rc>(.*?)<\/rc>/i);
|
||||
if (!rcMatch) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const realKey = decryptRcPayload(rcMatch[1]).subarray(0, 16);
|
||||
const encrypted = Buffer.from(dlcData, "base64");
|
||||
const decipher = crypto.createDecipheriv("aes-128-cbc", realKey, realKey);
|
||||
decipher.setAutoPadding(false);
|
||||
let decrypted = Buffer.concat([decipher.update(encrypted), decipher.final()]);
|
||||
|
||||
if (decrypted.length === 0) {
|
||||
return [];
|
||||
}
|
||||
const pad = decrypted[decrypted.length - 1];
|
||||
if (pad > 0 && pad <= 16 && pad <= decrypted.length) {
|
||||
let validPad = true;
|
||||
for (let index = 1; index <= pad; index += 1) {
|
||||
if (decrypted[decrypted.length - index] !== pad) {
|
||||
validPad = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (validPad) {
|
||||
decrypted = decrypted.subarray(0, decrypted.length - pad);
|
||||
}
|
||||
}
|
||||
|
||||
const xmlData = Buffer.from(decrypted.toString("utf8"), "base64").toString("utf8");
|
||||
return parsePackagesFromDlcXml(xmlData);
|
||||
}
|
||||
|
||||
function extractLinksFromResponse(text: string): string[] {
|
||||
const payload = decodeDcryptPayload(text);
|
||||
let links = extractUrlsRecursive(payload);
|
||||
if (links.length === 0) {
|
||||
links = extractUrlsRecursive(text);
|
||||
}
|
||||
return uniquePreserveOrder(links.filter((l) => isHttpLink(l)));
|
||||
}
|
||||
|
||||
async function tryDcryptUpload(fileContent: Buffer, fileName: string): Promise<string[] | null> {
|
||||
const blob = new Blob([new Uint8Array(fileContent)]);
|
||||
const form = new FormData();
|
||||
form.set("dlcfile", blob, fileName);
|
||||
|
||||
const response = await fetch(DCRYPT_UPLOAD_URL, {
|
||||
method: "POST",
|
||||
body: form,
|
||||
signal: AbortSignal.timeout(30000)
|
||||
});
|
||||
if (response.status === 413) {
|
||||
return null;
|
||||
}
|
||||
const text = await response.text();
|
||||
if (!response.ok) {
|
||||
throw new Error(compactErrorText(text));
|
||||
}
|
||||
return extractLinksFromResponse(text);
|
||||
}
|
||||
|
||||
async function tryDcryptPaste(fileContent: Buffer): Promise<string[] | null> {
|
||||
const form = new FormData();
|
||||
form.set("content", fileContent.toString("ascii"));
|
||||
|
||||
const response = await fetch(DCRYPT_PASTE_URL, {
|
||||
method: "POST",
|
||||
body: form,
|
||||
signal: AbortSignal.timeout(30000)
|
||||
});
|
||||
if (response.status === 413) {
|
||||
return null;
|
||||
}
|
||||
const text = await response.text();
|
||||
if (!response.ok) {
|
||||
throw new Error(compactErrorText(text));
|
||||
}
|
||||
return extractLinksFromResponse(text);
|
||||
}
|
||||
|
||||
async function decryptDlcViaDcrypt(filePath: string): Promise<ParsedPackageInput[]> {
|
||||
const fileContent = readDlcFileWithLimit(filePath);
|
||||
const fileName = path.basename(filePath);
|
||||
const packageName = sanitizeFilename(path.basename(filePath, ".dlc")) || "Paket";
|
||||
|
||||
let links = await tryDcryptUpload(fileContent, fileName);
|
||||
if (links === null) {
|
||||
links = await tryDcryptPaste(fileContent);
|
||||
}
|
||||
if (links === null) {
|
||||
throw new Error("DLC-Datei zu groß für dcrypt.it");
|
||||
}
|
||||
if (links.length === 0) {
|
||||
return [];
|
||||
}
|
||||
return [{ name: packageName, links }];
|
||||
}
|
||||
|
||||
export async function importDlcContainers(filePaths: string[]): Promise<ParsedPackageInput[]> {
|
||||
const out: ParsedPackageInput[] = [];
|
||||
const failures: string[] = [];
|
||||
let sawDlc = false;
|
||||
for (const filePath of filePaths) {
|
||||
if (path.extname(filePath).toLowerCase() !== ".dlc") {
|
||||
continue;
|
||||
}
|
||||
sawDlc = true;
|
||||
let packages: ParsedPackageInput[] = [];
|
||||
let fileFailed = false;
|
||||
let fileFailureReasons: string[] = [];
|
||||
try {
|
||||
packages = await decryptDlcLocal(filePath);
|
||||
} catch (error) {
|
||||
if (isContainerSizeValidationError(error)) {
|
||||
failures.push(`${path.basename(filePath)}: ${compactErrorText(error)}`);
|
||||
continue;
|
||||
}
|
||||
fileFailed = true;
|
||||
fileFailureReasons.push(`lokal: ${compactErrorText(error)}`);
|
||||
packages = [];
|
||||
}
|
||||
if (packages.length === 0) {
|
||||
try {
|
||||
packages = await decryptDlcViaDcrypt(filePath);
|
||||
} catch (error) {
|
||||
if (isContainerSizeValidationError(error)) {
|
||||
failures.push(`${path.basename(filePath)}: ${compactErrorText(error)}`);
|
||||
continue;
|
||||
}
|
||||
fileFailed = true;
|
||||
fileFailureReasons.push(`dcrypt: ${compactErrorText(error)}`);
|
||||
packages = [];
|
||||
}
|
||||
}
|
||||
if (packages.length === 0 && fileFailed) {
|
||||
failures.push(`${path.basename(filePath)}: ${fileFailureReasons.join("; ")}`);
|
||||
}
|
||||
out.push(...packages);
|
||||
}
|
||||
|
||||
if (out.length === 0 && sawDlc && failures.length > 0) {
|
||||
const details = failures.slice(0, 2).join(" | ");
|
||||
const suffix = failures.length > 2 ? ` (+${failures.length - 2} weitere)` : "";
|
||||
throw new Error(`DLC konnte nicht importiert werden: ${details}${suffix}`);
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import crypto from "node:crypto";
|
||||
import { DCRYPT_PASTE_URL, DCRYPT_UPLOAD_URL, DLC_AES_IV, DLC_AES_KEY, DLC_SERVICE_URL } from "./constants";
|
||||
import { compactErrorText, inferPackageNameFromLinks, isHttpLink, sanitizeFilename, uniquePreserveOrder } from "./utils";
|
||||
import { ParsedPackageInput } from "../shared/types";
|
||||
|
||||
const MAX_DLC_FILE_BYTES = 8 * 1024 * 1024;
|
||||
|
||||
function isContainerSizeValidationError(error: unknown): boolean {
|
||||
const text = compactErrorText(error);
|
||||
return /zu groß/i.test(text) || /DLC-Datei ungültig oder zu groß/i.test(text);
|
||||
}
|
||||
|
||||
function decodeDcryptPayload(responseText: string): unknown {
|
||||
let text = String(responseText || "").trim();
|
||||
const m = text.match(/<textarea[^>]*>([\s\S]*?)<\/textarea>/i);
|
||||
if (m) {
|
||||
text = m[1].replace(/"/g, '"').replace(/&/g, "&").trim();
|
||||
}
|
||||
if (!text) {
|
||||
return "";
|
||||
}
|
||||
try {
|
||||
return JSON.parse(text);
|
||||
} catch {
|
||||
return text;
|
||||
}
|
||||
}
|
||||
|
||||
function extractUrlsRecursive(data: unknown): string[] {
|
||||
if (typeof data === "string") {
|
||||
const found = data.match(/https?:\/\/[^\s"'<>]+/gi) ?? [];
|
||||
return uniquePreserveOrder(found.filter((url) => isHttpLink(url)));
|
||||
}
|
||||
if (Array.isArray(data)) {
|
||||
return uniquePreserveOrder(data.flatMap((item) => extractUrlsRecursive(item)));
|
||||
}
|
||||
if (data && typeof data === "object") {
|
||||
return uniquePreserveOrder(Object.values(data as Record<string, unknown>).flatMap((value) => extractUrlsRecursive(value)));
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
function groupLinksByName(links: string[]): ParsedPackageInput[] {
|
||||
const unique = uniquePreserveOrder(links.filter((link) => isHttpLink(link)));
|
||||
const grouped = new Map<string, string[]>();
|
||||
for (const link of unique) {
|
||||
const name = sanitizeFilename(inferPackageNameFromLinks([link]) || "Paket");
|
||||
const current = grouped.get(name) ?? [];
|
||||
current.push(link);
|
||||
grouped.set(name, current);
|
||||
}
|
||||
return Array.from(grouped.entries()).map(([name, packageLinks]) => ({ name, links: packageLinks }));
|
||||
}
|
||||
|
||||
function extractPackagesFromPayload(payload: unknown): ParsedPackageInput[] {
|
||||
const urls = extractUrlsRecursive(payload);
|
||||
if (urls.length === 0) {
|
||||
return [];
|
||||
}
|
||||
return groupLinksByName(urls);
|
||||
}
|
||||
|
||||
function decryptRcPayload(base64Rc: string): Buffer {
|
||||
const rcBytes = Buffer.from(base64Rc, "base64");
|
||||
const decipher = crypto.createDecipheriv("aes-128-cbc", DLC_AES_KEY, DLC_AES_IV);
|
||||
decipher.setAutoPadding(false);
|
||||
return Buffer.concat([decipher.update(rcBytes), decipher.final()]);
|
||||
}
|
||||
|
||||
function readDlcFileWithLimit(filePath: string): Buffer {
|
||||
const stat = fs.statSync(filePath);
|
||||
if (stat.size <= 0 || stat.size > MAX_DLC_FILE_BYTES) {
|
||||
throw new Error(`DLC-Datei ungültig oder zu groß (${Math.floor(stat.size)} B)`);
|
||||
}
|
||||
return fs.readFileSync(filePath);
|
||||
}
|
||||
|
||||
function parsePackagesFromDlcXml(xml: string): ParsedPackageInput[] {
|
||||
const packages: ParsedPackageInput[] = [];
|
||||
const packageRegex = /<package\s+[^>]*name="([^"]*)"[^>]*>([\s\S]*?)<\/package>/gi;
|
||||
|
||||
for (let m = packageRegex.exec(xml); m; m = packageRegex.exec(xml)) {
|
||||
const encodedName = m[1] || "";
|
||||
const packageBody = m[2] || "";
|
||||
let packageName = "";
|
||||
if (encodedName) {
|
||||
try {
|
||||
packageName = Buffer.from(encodedName, "base64").toString("utf8");
|
||||
} catch {
|
||||
packageName = encodedName;
|
||||
}
|
||||
}
|
||||
|
||||
const links: string[] = [];
|
||||
const fileNames: string[] = [];
|
||||
const fileRegex = /<file>([\s\S]*?)<\/file>/gi;
|
||||
for (let fm = fileRegex.exec(packageBody); fm; fm = fileRegex.exec(packageBody)) {
|
||||
const fileBody = fm[1] || "";
|
||||
const urlMatch = fileBody.match(/<url>(.*?)<\/url>/i);
|
||||
if (!urlMatch) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
const url = Buffer.from((urlMatch[1] || "").trim(), "base64").toString("utf8").trim();
|
||||
if (!isHttpLink(url)) {
|
||||
continue;
|
||||
}
|
||||
let fileName = "";
|
||||
const fnMatch = fileBody.match(/<filename>(.*?)<\/filename>/i);
|
||||
if (fnMatch?.[1]) {
|
||||
try {
|
||||
fileName = Buffer.from(fnMatch[1].trim(), "base64").toString("utf8").trim();
|
||||
} catch {
|
||||
}
|
||||
}
|
||||
links.push(url);
|
||||
fileNames.push(sanitizeFilename(fileName));
|
||||
} catch {
|
||||
}
|
||||
}
|
||||
|
||||
if (links.length === 0) {
|
||||
const urlRegex = /<url>(.*?)<\/url>/gi;
|
||||
for (let um = urlRegex.exec(packageBody); um; um = urlRegex.exec(packageBody)) {
|
||||
try {
|
||||
const url = Buffer.from((um[1] || "").trim(), "base64").toString("utf8").trim();
|
||||
if (isHttpLink(url)) {
|
||||
links.push(url);
|
||||
}
|
||||
} catch {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const uniqueLinks = uniquePreserveOrder(links);
|
||||
const hasFileNames = fileNames.some((fn) => fn.length > 0);
|
||||
if (uniqueLinks.length > 0) {
|
||||
const pkg: ParsedPackageInput = {
|
||||
name: sanitizeFilename(packageName || inferPackageNameFromLinks(uniqueLinks) || `Paket-${packages.length + 1}`),
|
||||
links: uniqueLinks
|
||||
};
|
||||
if (hasFileNames) {
|
||||
pkg.fileNames = fileNames;
|
||||
}
|
||||
packages.push(pkg);
|
||||
}
|
||||
}
|
||||
|
||||
return packages;
|
||||
}
|
||||
|
||||
async function decryptDlcLocal(filePath: string): Promise<ParsedPackageInput[]> {
|
||||
const content = readDlcFileWithLimit(filePath).toString("ascii").trim();
|
||||
if (content.length < 89) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const dlcKey = content.slice(-88);
|
||||
const dlcData = content.slice(0, -88);
|
||||
|
||||
const rcUrl = DLC_SERVICE_URL.replace("{KEY}", encodeURIComponent(dlcKey));
|
||||
const rcResponse = await fetch(rcUrl, { method: "GET", signal: AbortSignal.timeout(30000) });
|
||||
if (!rcResponse.ok) {
|
||||
return [];
|
||||
}
|
||||
const rcText = await rcResponse.text();
|
||||
const rcMatch = rcText.match(/<rc>(.*?)<\/rc>/i);
|
||||
if (!rcMatch) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const realKey = decryptRcPayload(rcMatch[1]).subarray(0, 16);
|
||||
const encrypted = Buffer.from(dlcData, "base64");
|
||||
const decipher = crypto.createDecipheriv("aes-128-cbc", realKey, realKey);
|
||||
decipher.setAutoPadding(false);
|
||||
let decrypted = Buffer.concat([decipher.update(encrypted), decipher.final()]);
|
||||
|
||||
if (decrypted.length === 0) {
|
||||
return [];
|
||||
}
|
||||
const pad = decrypted[decrypted.length - 1];
|
||||
if (pad > 0 && pad <= 16 && pad <= decrypted.length) {
|
||||
let validPad = true;
|
||||
for (let index = 1; index <= pad; index += 1) {
|
||||
if (decrypted[decrypted.length - index] !== pad) {
|
||||
validPad = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (validPad) {
|
||||
decrypted = decrypted.subarray(0, decrypted.length - pad);
|
||||
}
|
||||
}
|
||||
|
||||
const xmlData = Buffer.from(decrypted.toString("utf8"), "base64").toString("utf8");
|
||||
return parsePackagesFromDlcXml(xmlData);
|
||||
}
|
||||
|
||||
function extractLinksFromResponse(text: string): string[] {
|
||||
const payload = decodeDcryptPayload(text);
|
||||
let links = extractUrlsRecursive(payload);
|
||||
if (links.length === 0) {
|
||||
links = extractUrlsRecursive(text);
|
||||
}
|
||||
return uniquePreserveOrder(links.filter((l) => isHttpLink(l)));
|
||||
}
|
||||
|
||||
async function tryDcryptUpload(fileContent: Buffer, fileName: string): Promise<string[] | null> {
|
||||
const blob = new Blob([new Uint8Array(fileContent)]);
|
||||
const form = new FormData();
|
||||
form.set("dlcfile", blob, fileName);
|
||||
|
||||
const response = await fetch(DCRYPT_UPLOAD_URL, {
|
||||
method: "POST",
|
||||
body: form,
|
||||
signal: AbortSignal.timeout(30000)
|
||||
});
|
||||
if (response.status === 413) {
|
||||
return null;
|
||||
}
|
||||
const text = await response.text();
|
||||
if (!response.ok) {
|
||||
throw new Error(compactErrorText(text));
|
||||
}
|
||||
return extractLinksFromResponse(text);
|
||||
}
|
||||
|
||||
async function tryDcryptPaste(fileContent: Buffer): Promise<string[] | null> {
|
||||
const form = new FormData();
|
||||
form.set("content", fileContent.toString("ascii"));
|
||||
|
||||
const response = await fetch(DCRYPT_PASTE_URL, {
|
||||
method: "POST",
|
||||
body: form,
|
||||
signal: AbortSignal.timeout(30000)
|
||||
});
|
||||
if (response.status === 413) {
|
||||
return null;
|
||||
}
|
||||
const text = await response.text();
|
||||
if (!response.ok) {
|
||||
throw new Error(compactErrorText(text));
|
||||
}
|
||||
return extractLinksFromResponse(text);
|
||||
}
|
||||
|
||||
async function decryptDlcViaDcrypt(filePath: string): Promise<ParsedPackageInput[]> {
|
||||
const fileContent = readDlcFileWithLimit(filePath);
|
||||
const fileName = path.basename(filePath);
|
||||
const packageName = sanitizeFilename(path.basename(filePath, ".dlc")) || "Paket";
|
||||
|
||||
let links = await tryDcryptUpload(fileContent, fileName);
|
||||
if (links === null) {
|
||||
links = await tryDcryptPaste(fileContent);
|
||||
}
|
||||
if (links === null) {
|
||||
throw new Error("DLC-Datei zu groß für dcrypt.it");
|
||||
}
|
||||
if (links.length === 0) {
|
||||
return [];
|
||||
}
|
||||
return [{ name: packageName, links }];
|
||||
}
|
||||
|
||||
export async function importDlcContainers(filePaths: string[]): Promise<ParsedPackageInput[]> {
|
||||
const out: ParsedPackageInput[] = [];
|
||||
const failures: string[] = [];
|
||||
let sawDlc = false;
|
||||
for (const filePath of filePaths) {
|
||||
if (path.extname(filePath).toLowerCase() !== ".dlc") {
|
||||
continue;
|
||||
}
|
||||
sawDlc = true;
|
||||
let packages: ParsedPackageInput[] = [];
|
||||
let fileFailed = false;
|
||||
let fileFailureReasons: string[] = [];
|
||||
try {
|
||||
packages = await decryptDlcLocal(filePath);
|
||||
} catch (error) {
|
||||
if (isContainerSizeValidationError(error)) {
|
||||
failures.push(`${path.basename(filePath)}: ${compactErrorText(error)}`);
|
||||
continue;
|
||||
}
|
||||
fileFailed = true;
|
||||
fileFailureReasons.push(`lokal: ${compactErrorText(error)}`);
|
||||
packages = [];
|
||||
}
|
||||
if (packages.length === 0) {
|
||||
try {
|
||||
packages = await decryptDlcViaDcrypt(filePath);
|
||||
} catch (error) {
|
||||
if (isContainerSizeValidationError(error)) {
|
||||
failures.push(`${path.basename(filePath)}: ${compactErrorText(error)}`);
|
||||
continue;
|
||||
}
|
||||
fileFailed = true;
|
||||
fileFailureReasons.push(`dcrypt: ${compactErrorText(error)}`);
|
||||
packages = [];
|
||||
}
|
||||
}
|
||||
if (packages.length === 0 && fileFailed) {
|
||||
failures.push(`${path.basename(filePath)}: ${fileFailureReasons.join("; ")}`);
|
||||
}
|
||||
out.push(...packages);
|
||||
}
|
||||
|
||||
if (out.length === 0 && sawDlc && failures.length > 0) {
|
||||
const details = failures.slice(0, 2).join(" | ");
|
||||
const suffix = failures.length > 2 ? ` (+${failures.length - 2} weitere)` : "";
|
||||
throw new Error(`DLC konnte nicht importiert werden: ${details}${suffix}`);
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
@ -1,190 +1,190 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { AsyncLocalStorage } from "node:async_hooks";
|
||||
import { logTimestamp } from "./log-timestamp";
|
||||
|
||||
export interface ConversionPhase {
|
||||
atMs: number;
|
||||
phase: string;
|
||||
provider?: string;
|
||||
account?: string;
|
||||
tokenState?: string;
|
||||
queueWaitMs?: number;
|
||||
workMs?: number;
|
||||
outcome?: string;
|
||||
detail?: string;
|
||||
}
|
||||
|
||||
export interface ConversionTrace {
|
||||
startedAt: number;
|
||||
itemId: string;
|
||||
itemName: string;
|
||||
link: string;
|
||||
providerOrder: string;
|
||||
notes: Record<string, string | number>;
|
||||
phases: ConversionPhase[];
|
||||
}
|
||||
|
||||
const conversionContext = new AsyncLocalStorage<ConversionTrace>();
|
||||
|
||||
function shortLink(link: string): string {
|
||||
const raw = String(link || "").trim();
|
||||
return raw.length > 90 ? `${raw.slice(0, 90)}…` : raw;
|
||||
}
|
||||
|
||||
export function traceConversionPhase(phase: Omit<ConversionPhase, "atMs">): void {
|
||||
const trace = conversionContext.getStore();
|
||||
if (!trace) {
|
||||
return;
|
||||
}
|
||||
trace.phases.push({ ...phase, atMs: Date.now() - trace.startedAt });
|
||||
}
|
||||
|
||||
export function traceConversionNote(key: string, value: string | number): void {
|
||||
const trace = conversionContext.getStore();
|
||||
if (!trace) {
|
||||
return;
|
||||
}
|
||||
trace.notes[key] = value;
|
||||
}
|
||||
|
||||
export function hasActiveConversionTrace(): boolean {
|
||||
return conversionContext.getStore() !== undefined;
|
||||
}
|
||||
|
||||
export function formatConversionBlock(
|
||||
trace: ConversionTrace,
|
||||
outcome: string,
|
||||
detail: string,
|
||||
totalMs: number
|
||||
): string {
|
||||
const noteParts = Object.entries(trace.notes)
|
||||
.map(([key, value]) => `${key}=${value}`)
|
||||
.join(" ");
|
||||
const header = `${logTimestamp()} [CONV] item=${trace.itemName || trace.itemId} | order=${trace.providerOrder || "?"}`
|
||||
+ ` | result=${outcome}${detail ? ` (${detail})` : ""} | total=${totalMs}ms${noteParts ? ` | ${noteParts}` : ""}`
|
||||
+ ` | link=${shortLink(trace.link)}`;
|
||||
const lines = trace.phases.map((p) => {
|
||||
const parts: string[] = [];
|
||||
if (p.provider) parts.push(`provider=${p.provider}`);
|
||||
if (p.account) parts.push(`account=${p.account}`);
|
||||
if (p.tokenState) parts.push(`token=${p.tokenState}`);
|
||||
if (typeof p.queueWaitMs === "number") parts.push(`queueWaitMs=${p.queueWaitMs}`);
|
||||
if (typeof p.workMs === "number") parts.push(`workMs=${p.workMs}`);
|
||||
if (p.outcome) parts.push(`outcome=${p.outcome}`);
|
||||
if (p.detail) parts.push(`detail=${String(p.detail).replace(/\r?\n/g, "\\n")}`);
|
||||
return ` +${p.atMs}ms ${p.phase}${parts.length ? ` | ${parts.join(" | ")}` : ""}`;
|
||||
});
|
||||
return [header, ...lines].join("\n");
|
||||
}
|
||||
|
||||
const CONVERSION_LOG_MAX_FILE_BYTES = Number(process.env.RD_CONVERSION_LOG_MAX_BYTES || 5 * 1024 * 1024);
|
||||
const CONVERSION_LOG_RETENTION_DAYS = Number(process.env.RD_CONVERSION_LOG_RETENTION_DAYS || 14);
|
||||
|
||||
let conversionLogPath: string | null = null;
|
||||
|
||||
function rotateIfNeeded(filePath: string): void {
|
||||
try {
|
||||
const stat = fs.statSync(filePath);
|
||||
if (stat.size < CONVERSION_LOG_MAX_FILE_BYTES) {
|
||||
return;
|
||||
}
|
||||
const backup = `${filePath}.old`;
|
||||
try {
|
||||
fs.rmSync(backup, { force: true });
|
||||
} catch {
|
||||
}
|
||||
fs.renameSync(filePath, backup);
|
||||
} catch {
|
||||
}
|
||||
}
|
||||
|
||||
function cleanupOldBackup(filePath: string): void {
|
||||
const backup = `${filePath}.old`;
|
||||
try {
|
||||
const stat = fs.statSync(backup);
|
||||
const cutoff = Date.now() - CONVERSION_LOG_RETENTION_DAYS * 24 * 60 * 60 * 1000;
|
||||
if (stat.mtimeMs < cutoff) {
|
||||
fs.rmSync(backup, { force: true });
|
||||
}
|
||||
} catch {
|
||||
}
|
||||
}
|
||||
|
||||
export function initConversionLog(baseDir: string): void {
|
||||
conversionLogPath = path.join(baseDir, "conversion.log");
|
||||
try {
|
||||
fs.mkdirSync(path.dirname(conversionLogPath), { recursive: true });
|
||||
cleanupOldBackup(conversionLogPath);
|
||||
if (!fs.existsSync(conversionLogPath)) {
|
||||
fs.writeFileSync(conversionLogPath, "", "utf8");
|
||||
}
|
||||
rotateIfNeeded(conversionLogPath);
|
||||
if (!fs.existsSync(conversionLogPath)) {
|
||||
fs.writeFileSync(conversionLogPath, "", "utf8");
|
||||
}
|
||||
fs.appendFileSync(conversionLogPath, `=== Conversion Log Start: ${logTimestamp()} ===\n`, "utf8");
|
||||
} catch {
|
||||
conversionLogPath = null;
|
||||
}
|
||||
}
|
||||
|
||||
export function getConversionLogPath(): string | null {
|
||||
if (!conversionLogPath) {
|
||||
return null;
|
||||
}
|
||||
return fs.existsSync(conversionLogPath) ? conversionLogPath : null;
|
||||
}
|
||||
|
||||
export function shutdownConversionLog(): void {
|
||||
if (!conversionLogPath) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
fs.appendFileSync(conversionLogPath, `=== Conversion Log Ende: ${logTimestamp()} ===\n`, "utf8");
|
||||
} catch {
|
||||
}
|
||||
conversionLogPath = null;
|
||||
}
|
||||
|
||||
function writeConversionBlock(block: string): void {
|
||||
if (!conversionLogPath) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
rotateIfNeeded(conversionLogPath);
|
||||
if (!fs.existsSync(conversionLogPath)) {
|
||||
fs.writeFileSync(conversionLogPath, "", "utf8");
|
||||
}
|
||||
fs.appendFileSync(conversionLogPath, `${block}\n`, "utf8");
|
||||
} catch {
|
||||
}
|
||||
}
|
||||
|
||||
export async function runWithConversionTrace<T>(
|
||||
meta: { itemId: string; itemName: string; link: string; providerOrder: string },
|
||||
fn: () => Promise<T>
|
||||
): Promise<T> {
|
||||
const trace: ConversionTrace = {
|
||||
startedAt: Date.now(),
|
||||
itemId: meta.itemId,
|
||||
itemName: meta.itemName,
|
||||
link: meta.link,
|
||||
providerOrder: meta.providerOrder,
|
||||
notes: {},
|
||||
phases: []
|
||||
};
|
||||
let outcome = "OK";
|
||||
let detail = "";
|
||||
try {
|
||||
const result = await conversionContext.run(trace, fn);
|
||||
return result;
|
||||
} catch (error) {
|
||||
outcome = "FAIL";
|
||||
detail = String((error as { message?: string })?.message || error || "").replace(/^Error:\s*/i, "").slice(0, 160);
|
||||
throw error;
|
||||
} finally {
|
||||
const totalMs = Date.now() - trace.startedAt;
|
||||
writeConversionBlock(formatConversionBlock(trace, outcome, detail, totalMs));
|
||||
}
|
||||
}
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { AsyncLocalStorage } from "node:async_hooks";
|
||||
import { logTimestamp } from "./log-timestamp";
|
||||
|
||||
export interface ConversionPhase {
|
||||
atMs: number;
|
||||
phase: string;
|
||||
provider?: string;
|
||||
account?: string;
|
||||
tokenState?: string;
|
||||
queueWaitMs?: number;
|
||||
workMs?: number;
|
||||
outcome?: string;
|
||||
detail?: string;
|
||||
}
|
||||
|
||||
export interface ConversionTrace {
|
||||
startedAt: number;
|
||||
itemId: string;
|
||||
itemName: string;
|
||||
link: string;
|
||||
providerOrder: string;
|
||||
notes: Record<string, string | number>;
|
||||
phases: ConversionPhase[];
|
||||
}
|
||||
|
||||
const conversionContext = new AsyncLocalStorage<ConversionTrace>();
|
||||
|
||||
function shortLink(link: string): string {
|
||||
const raw = String(link || "").trim();
|
||||
return raw.length > 90 ? `${raw.slice(0, 90)}…` : raw;
|
||||
}
|
||||
|
||||
export function traceConversionPhase(phase: Omit<ConversionPhase, "atMs">): void {
|
||||
const trace = conversionContext.getStore();
|
||||
if (!trace) {
|
||||
return;
|
||||
}
|
||||
trace.phases.push({ ...phase, atMs: Date.now() - trace.startedAt });
|
||||
}
|
||||
|
||||
export function traceConversionNote(key: string, value: string | number): void {
|
||||
const trace = conversionContext.getStore();
|
||||
if (!trace) {
|
||||
return;
|
||||
}
|
||||
trace.notes[key] = value;
|
||||
}
|
||||
|
||||
export function hasActiveConversionTrace(): boolean {
|
||||
return conversionContext.getStore() !== undefined;
|
||||
}
|
||||
|
||||
export function formatConversionBlock(
|
||||
trace: ConversionTrace,
|
||||
outcome: string,
|
||||
detail: string,
|
||||
totalMs: number
|
||||
): string {
|
||||
const noteParts = Object.entries(trace.notes)
|
||||
.map(([key, value]) => `${key}=${value}`)
|
||||
.join(" ");
|
||||
const header = `${logTimestamp()} [CONV] item=${trace.itemName || trace.itemId} | order=${trace.providerOrder || "?"}`
|
||||
+ ` | result=${outcome}${detail ? ` (${detail})` : ""} | total=${totalMs}ms${noteParts ? ` | ${noteParts}` : ""}`
|
||||
+ ` | link=${shortLink(trace.link)}`;
|
||||
const lines = trace.phases.map((p) => {
|
||||
const parts: string[] = [];
|
||||
if (p.provider) parts.push(`provider=${p.provider}`);
|
||||
if (p.account) parts.push(`account=${p.account}`);
|
||||
if (p.tokenState) parts.push(`token=${p.tokenState}`);
|
||||
if (typeof p.queueWaitMs === "number") parts.push(`queueWaitMs=${p.queueWaitMs}`);
|
||||
if (typeof p.workMs === "number") parts.push(`workMs=${p.workMs}`);
|
||||
if (p.outcome) parts.push(`outcome=${p.outcome}`);
|
||||
if (p.detail) parts.push(`detail=${String(p.detail).replace(/\r?\n/g, "\\n")}`);
|
||||
return ` +${p.atMs}ms ${p.phase}${parts.length ? ` | ${parts.join(" | ")}` : ""}`;
|
||||
});
|
||||
return [header, ...lines].join("\n");
|
||||
}
|
||||
|
||||
const CONVERSION_LOG_MAX_FILE_BYTES = Number(process.env.RD_CONVERSION_LOG_MAX_BYTES || 5 * 1024 * 1024);
|
||||
const CONVERSION_LOG_RETENTION_DAYS = Number(process.env.RD_CONVERSION_LOG_RETENTION_DAYS || 14);
|
||||
|
||||
let conversionLogPath: string | null = null;
|
||||
|
||||
function rotateIfNeeded(filePath: string): void {
|
||||
try {
|
||||
const stat = fs.statSync(filePath);
|
||||
if (stat.size < CONVERSION_LOG_MAX_FILE_BYTES) {
|
||||
return;
|
||||
}
|
||||
const backup = `${filePath}.old`;
|
||||
try {
|
||||
fs.rmSync(backup, { force: true });
|
||||
} catch {
|
||||
}
|
||||
fs.renameSync(filePath, backup);
|
||||
} catch {
|
||||
}
|
||||
}
|
||||
|
||||
function cleanupOldBackup(filePath: string): void {
|
||||
const backup = `${filePath}.old`;
|
||||
try {
|
||||
const stat = fs.statSync(backup);
|
||||
const cutoff = Date.now() - CONVERSION_LOG_RETENTION_DAYS * 24 * 60 * 60 * 1000;
|
||||
if (stat.mtimeMs < cutoff) {
|
||||
fs.rmSync(backup, { force: true });
|
||||
}
|
||||
} catch {
|
||||
}
|
||||
}
|
||||
|
||||
export function initConversionLog(baseDir: string): void {
|
||||
conversionLogPath = path.join(baseDir, "conversion.log");
|
||||
try {
|
||||
fs.mkdirSync(path.dirname(conversionLogPath), { recursive: true });
|
||||
cleanupOldBackup(conversionLogPath);
|
||||
if (!fs.existsSync(conversionLogPath)) {
|
||||
fs.writeFileSync(conversionLogPath, "", "utf8");
|
||||
}
|
||||
rotateIfNeeded(conversionLogPath);
|
||||
if (!fs.existsSync(conversionLogPath)) {
|
||||
fs.writeFileSync(conversionLogPath, "", "utf8");
|
||||
}
|
||||
fs.appendFileSync(conversionLogPath, `=== Conversion Log Start: ${logTimestamp()} ===\n`, "utf8");
|
||||
} catch {
|
||||
conversionLogPath = null;
|
||||
}
|
||||
}
|
||||
|
||||
export function getConversionLogPath(): string | null {
|
||||
if (!conversionLogPath) {
|
||||
return null;
|
||||
}
|
||||
return fs.existsSync(conversionLogPath) ? conversionLogPath : null;
|
||||
}
|
||||
|
||||
export function shutdownConversionLog(): void {
|
||||
if (!conversionLogPath) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
fs.appendFileSync(conversionLogPath, `=== Conversion Log Ende: ${logTimestamp()} ===\n`, "utf8");
|
||||
} catch {
|
||||
}
|
||||
conversionLogPath = null;
|
||||
}
|
||||
|
||||
function writeConversionBlock(block: string): void {
|
||||
if (!conversionLogPath) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
rotateIfNeeded(conversionLogPath);
|
||||
if (!fs.existsSync(conversionLogPath)) {
|
||||
fs.writeFileSync(conversionLogPath, "", "utf8");
|
||||
}
|
||||
fs.appendFileSync(conversionLogPath, `${block}\n`, "utf8");
|
||||
} catch {
|
||||
}
|
||||
}
|
||||
|
||||
export async function runWithConversionTrace<T>(
|
||||
meta: { itemId: string; itemName: string; link: string; providerOrder: string },
|
||||
fn: () => Promise<T>
|
||||
): Promise<T> {
|
||||
const trace: ConversionTrace = {
|
||||
startedAt: Date.now(),
|
||||
itemId: meta.itemId,
|
||||
itemName: meta.itemName,
|
||||
link: meta.link,
|
||||
providerOrder: meta.providerOrder,
|
||||
notes: {},
|
||||
phases: []
|
||||
};
|
||||
let outcome = "OK";
|
||||
let detail = "";
|
||||
try {
|
||||
const result = await conversionContext.run(trace, fn);
|
||||
return result;
|
||||
} catch (error) {
|
||||
outcome = "FAIL";
|
||||
detail = String((error as { message?: string })?.message || error || "").replace(/^Error:\s*/i, "").slice(0, 160);
|
||||
throw error;
|
||||
} finally {
|
||||
const totalMs = Date.now() - trace.startedAt;
|
||||
writeConversionBlock(formatConversionBlock(trace, outcome, detail, totalMs));
|
||||
}
|
||||
}
|
||||
|
||||
8312
src/main/debrid.ts
8312
src/main/debrid.ts
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@ -1,435 +1,435 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { getSessionLogPath } from "./session-log";
|
||||
import { createStoragePaths, loadSettings } from "./storage";
|
||||
import type {
|
||||
DebugSetupCheckResult,
|
||||
SupportBundleEstimate,
|
||||
SupportDirectorySizeInfo,
|
||||
SupportDiskSpaceInfo,
|
||||
SupportFileSizeInfo,
|
||||
SupportTraceConfig
|
||||
} from "../shared/types";
|
||||
|
||||
const DEFAULT_PORT = 9868;
|
||||
const DEFAULT_HOST = "127.0.0.1";
|
||||
const SUPPORT_MANIFEST_FILE = "debug_support_manifest.json";
|
||||
const LOW_FREE_BYTES_THRESHOLD = Number(process.env.RD_SELF_CHECK_LOW_FREE_BYTES || 20 * 1024 * 1024 * 1024);
|
||||
const LOW_FREE_PERCENT_THRESHOLD = Number(process.env.RD_SELF_CHECK_LOW_FREE_PERCENT || 5);
|
||||
const LOW_FREE_PERCENT_BYTES_GUARD = Number(process.env.RD_SELF_CHECK_LOW_FREE_PERCENT_BYTES_GUARD || 50 * 1024 * 1024 * 1024);
|
||||
const LARGE_LOG_BYTES_THRESHOLD = Number(process.env.RD_SELF_CHECK_LARGE_LOG_BYTES || 250 * 1024 * 1024);
|
||||
const LARGE_BUNDLE_BYTES_THRESHOLD = Number(process.env.RD_SELF_CHECK_LARGE_BUNDLE_BYTES || 150 * 1024 * 1024);
|
||||
const BUNDLE_OVERVIEW_SLACK_BYTES = 256 * 1024;
|
||||
|
||||
function formatByteCount(bytes: number): string {
|
||||
if (!Number.isFinite(bytes) || bytes < 0) {
|
||||
return "0 B";
|
||||
}
|
||||
if (bytes < 1024) {
|
||||
return `${bytes} B`;
|
||||
}
|
||||
if (bytes < 1024 * 1024) {
|
||||
return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
}
|
||||
if (bytes < 1024 * 1024 * 1024) {
|
||||
return `${(bytes / (1024 * 1024)).toFixed(2)} MB`;
|
||||
}
|
||||
return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`;
|
||||
}
|
||||
|
||||
function readToken(baseDir: string): string {
|
||||
try {
|
||||
return fs.readFileSync(path.join(baseDir, "debug_token.txt"), "utf8").trim();
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
function readPort(baseDir: string): number {
|
||||
try {
|
||||
const raw = Number(fs.readFileSync(path.join(baseDir, "debug_port.txt"), "utf8").trim());
|
||||
if (Number.isFinite(raw) && raw >= 1024 && raw <= 65535) {
|
||||
return raw;
|
||||
}
|
||||
} catch {
|
||||
}
|
||||
return DEFAULT_PORT;
|
||||
}
|
||||
|
||||
function readHost(baseDir: string): string {
|
||||
try {
|
||||
const raw = fs.readFileSync(path.join(baseDir, "debug_host.txt"), "utf8").trim();
|
||||
if (!raw) {
|
||||
return DEFAULT_HOST;
|
||||
}
|
||||
if (/^(localhost|0\.0\.0\.0|127\.0\.0\.1|::1)$/i.test(raw)) {
|
||||
return raw;
|
||||
}
|
||||
if (/^[a-z0-9.-]+$/i.test(raw)) {
|
||||
return raw;
|
||||
}
|
||||
} catch {
|
||||
}
|
||||
return DEFAULT_HOST;
|
||||
}
|
||||
|
||||
function readTraceConfig(baseDir: string): SupportTraceConfig {
|
||||
const fallback: SupportTraceConfig = {
|
||||
enabled: false,
|
||||
includeMainLog: true,
|
||||
includeAudit: true,
|
||||
logDebugRequests: true,
|
||||
autoDisableAt: null,
|
||||
updatedAt: new Date(0).toISOString()
|
||||
};
|
||||
try {
|
||||
const filePath = path.join(baseDir, "trace_config.json");
|
||||
const parsed = JSON.parse(fs.readFileSync(filePath, "utf8")) as Partial<SupportTraceConfig>;
|
||||
return {
|
||||
enabled: Boolean(parsed.enabled),
|
||||
includeMainLog: parsed.includeMainLog === undefined ? true : Boolean(parsed.includeMainLog),
|
||||
includeAudit: parsed.includeAudit === undefined ? true : Boolean(parsed.includeAudit),
|
||||
logDebugRequests: parsed.logDebugRequests === undefined ? true : Boolean(parsed.logDebugRequests),
|
||||
autoDisableAt: typeof parsed.autoDisableAt === "string" && parsed.autoDisableAt.trim() ? parsed.autoDisableAt : null,
|
||||
updatedAt: typeof parsed.updatedAt === "string" && parsed.updatedAt.trim() ? parsed.updatedAt : fallback.updatedAt
|
||||
};
|
||||
} catch {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
function getFileSizeInfo(filePath: string | null): SupportFileSizeInfo {
|
||||
if (!filePath) {
|
||||
return { path: null, exists: false, bytes: 0 };
|
||||
}
|
||||
try {
|
||||
const stat = fs.statSync(filePath);
|
||||
return {
|
||||
path: filePath,
|
||||
exists: true,
|
||||
bytes: stat.size
|
||||
};
|
||||
} catch {
|
||||
return {
|
||||
path: filePath,
|
||||
exists: false,
|
||||
bytes: 0
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function getDirectorySizeInfo(dirPath: string, skipPath?: string | null): SupportDirectorySizeInfo {
|
||||
if (!fs.existsSync(dirPath)) {
|
||||
return {
|
||||
path: dirPath,
|
||||
exists: false,
|
||||
fileCount: 0,
|
||||
bytes: 0
|
||||
};
|
||||
}
|
||||
|
||||
let bytes = 0;
|
||||
let fileCount = 0;
|
||||
const queue = [dirPath];
|
||||
while (queue.length > 0) {
|
||||
const current = queue.pop();
|
||||
if (!current) {
|
||||
continue;
|
||||
}
|
||||
let entries: fs.Dirent[];
|
||||
try {
|
||||
entries = fs.readdirSync(current, { withFileTypes: true });
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
for (const entry of entries) {
|
||||
const fullPath = path.join(current, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
queue.push(fullPath);
|
||||
continue;
|
||||
}
|
||||
if (skipPath && path.resolve(fullPath) === path.resolve(skipPath)) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
bytes += fs.statSync(fullPath).size;
|
||||
fileCount += 1;
|
||||
} catch {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
path: dirPath,
|
||||
exists: true,
|
||||
fileCount,
|
||||
bytes
|
||||
};
|
||||
}
|
||||
|
||||
function resolveExistingPath(targetPath: string): string {
|
||||
let current = path.resolve(targetPath);
|
||||
while (!fs.existsSync(current)) {
|
||||
const parent = path.dirname(current);
|
||||
if (parent === current) {
|
||||
break;
|
||||
}
|
||||
current = parent;
|
||||
}
|
||||
return current;
|
||||
}
|
||||
|
||||
function getWindowsDiskSpaceInfo(existingPath: string): SupportDiskSpaceInfo | null {
|
||||
if (process.platform !== "win32") {
|
||||
return null;
|
||||
}
|
||||
const root = path.parse(existingPath).root.replace(/[\\/]+$/g, "");
|
||||
const driveName = root.replace(":", "");
|
||||
if (!/^[A-Za-z]$/.test(driveName)) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
const raw = execFileSync(
|
||||
"powershell",
|
||||
[
|
||||
"-NoProfile",
|
||||
"-Command",
|
||||
`$drive = Get-PSDrive -Name '${driveName}'; if ($drive) { [pscustomobject]@{ FreeSpace = [int64]$drive.Free; Size = [int64]($drive.Used + $drive.Free) } | ConvertTo-Json -Compress }`
|
||||
],
|
||||
{
|
||||
encoding: "utf8",
|
||||
windowsHide: true,
|
||||
stdio: ["ignore", "pipe", "ignore"],
|
||||
timeout: 3000
|
||||
}
|
||||
).trim();
|
||||
if (!raw) {
|
||||
return null;
|
||||
}
|
||||
const parsed = JSON.parse(raw) as { FreeSpace?: number | string; Size?: number | string };
|
||||
const totalBytes = Number(parsed.Size);
|
||||
const freeBytes = Number(parsed.FreeSpace);
|
||||
const freePercent = Number.isFinite(totalBytes) && totalBytes > 0
|
||||
? Math.round((freeBytes / totalBytes) * 1000) / 10
|
||||
: null;
|
||||
return {
|
||||
path: existingPath,
|
||||
totalBytes: Number.isFinite(totalBytes) ? totalBytes : null,
|
||||
freeBytes: Number.isFinite(freeBytes) ? freeBytes : null,
|
||||
freePercent
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function getDiskSpaceInfo(targetPath: string): SupportDiskSpaceInfo {
|
||||
const existingPath = resolveExistingPath(targetPath);
|
||||
try {
|
||||
const stat = fs.statfsSync(existingPath);
|
||||
const totalBytes = Number(stat.blocks) * Number(stat.bsize);
|
||||
const freeBytes = Number(stat.bavail) * Number(stat.bsize);
|
||||
const freePercent = totalBytes > 0
|
||||
? Math.round((freeBytes / totalBytes) * 1000) / 10
|
||||
: null;
|
||||
return {
|
||||
path: existingPath,
|
||||
totalBytes,
|
||||
freeBytes,
|
||||
freePercent
|
||||
};
|
||||
} catch {
|
||||
const windowsFallback = getWindowsDiskSpaceInfo(existingPath);
|
||||
if (windowsFallback) {
|
||||
return windowsFallback;
|
||||
}
|
||||
return {
|
||||
path: existingPath,
|
||||
totalBytes: null,
|
||||
freeBytes: null,
|
||||
freePercent: null
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function getSupportBundleEstimate(
|
||||
baseDir: string,
|
||||
logSummary: DebugSetupCheckResult["logSummary"]
|
||||
): SupportBundleEstimate {
|
||||
const storagePaths = createStoragePaths(baseDir);
|
||||
const staticFiles = [
|
||||
path.join(baseDir, SUPPORT_MANIFEST_FILE),
|
||||
path.join(baseDir, "debug_host.txt"),
|
||||
path.join(baseDir, "debug_port.txt"),
|
||||
storagePaths.configFile,
|
||||
storagePaths.sessionFile,
|
||||
storagePaths.historyFile,
|
||||
path.join(baseDir, "trace_config.json")
|
||||
].map((filePath) => getFileSizeInfo(filePath));
|
||||
|
||||
const staticBytes = staticFiles.reduce((sum, entry) => sum + entry.bytes, 0);
|
||||
const duplicatedLiveLogBytes = logSummary.session.bytes + logSummary.packageLogs.bytes + logSummary.itemLogs.bytes;
|
||||
const estimatedEntries = 10
|
||||
+ staticFiles.filter((entry) => entry.exists).length
|
||||
+ Number(logSummary.main.exists)
|
||||
+ Number(logSummary.mainBackup.exists)
|
||||
+ Number(logSummary.audit.exists)
|
||||
+ Number(logSummary.auditBackup.exists)
|
||||
+ Number(logSummary.rename.exists)
|
||||
+ Number(logSummary.renameBackup.exists)
|
||||
+ Number(logSummary.session.exists)
|
||||
+ Number(logSummary.trace.exists)
|
||||
+ Number(logSummary.traceBackup.exists)
|
||||
+ logSummary.sessionLogs.fileCount
|
||||
+ logSummary.packageLogs.fileCount
|
||||
+ logSummary.itemLogs.fileCount
|
||||
+ logSummary.packageLogs.fileCount
|
||||
+ logSummary.itemLogs.fileCount;
|
||||
|
||||
return {
|
||||
estimatedBytes: staticBytes + logSummary.totalBytes + duplicatedLiveLogBytes + BUNDLE_OVERVIEW_SLACK_BYTES,
|
||||
estimatedEntries,
|
||||
duplicatedLiveLogBytes,
|
||||
note: "Schätzwert vor ZIP-Komprimierung; aktueller Session-Log sowie Live-Paket-/Item-Logs werden im Bundle zusätzlich gespiegelt."
|
||||
};
|
||||
}
|
||||
|
||||
export function getDebugSetupCheck(baseDir: string): DebugSetupCheckResult {
|
||||
const host = readHost(baseDir);
|
||||
const port = readPort(baseDir);
|
||||
const token = readToken(baseDir);
|
||||
const storagePaths = createStoragePaths(baseDir);
|
||||
const settings = loadSettings(storagePaths);
|
||||
const tokenPath = path.join(baseDir, "debug_token.txt");
|
||||
const supportManifestPath = path.join(baseDir, SUPPORT_MANIFEST_FILE);
|
||||
const traceConfigPath = path.join(baseDir, "trace_config.json");
|
||||
const traceLogPath = path.join(baseDir, "trace.log");
|
||||
const traceConfig = readTraceConfig(baseDir);
|
||||
const sessionLogPath = getSessionLogPath();
|
||||
const localOnly = /^(127\.0\.0\.1|localhost|::1)$/i.test(host);
|
||||
const warnings: string[] = [];
|
||||
const notes: string[] = [];
|
||||
|
||||
const logSummary: DebugSetupCheckResult["logSummary"] = {
|
||||
main: getFileSizeInfo(path.join(baseDir, "rd_downloader.log")),
|
||||
mainBackup: getFileSizeInfo(path.join(baseDir, "rd_downloader.log.old")),
|
||||
audit: getFileSizeInfo(path.join(baseDir, "audit.log")),
|
||||
auditBackup: getFileSizeInfo(path.join(baseDir, "audit.log.old")),
|
||||
rename: getFileSizeInfo(path.join(baseDir, "rename.log")),
|
||||
renameBackup: getFileSizeInfo(path.join(baseDir, "rename.log.old")),
|
||||
session: getFileSizeInfo(sessionLogPath),
|
||||
trace: getFileSizeInfo(traceLogPath),
|
||||
traceBackup: getFileSizeInfo(path.join(baseDir, "trace.log.old")),
|
||||
sessionLogs: getDirectorySizeInfo(path.join(baseDir, "session-logs"), sessionLogPath),
|
||||
packageLogs: getDirectorySizeInfo(path.join(baseDir, "package-logs")),
|
||||
itemLogs: getDirectorySizeInfo(path.join(baseDir, "item-logs")),
|
||||
totalBytes: 0
|
||||
};
|
||||
logSummary.totalBytes = [
|
||||
logSummary.main.bytes,
|
||||
logSummary.mainBackup.bytes,
|
||||
logSummary.audit.bytes,
|
||||
logSummary.auditBackup.bytes,
|
||||
logSummary.rename.bytes,
|
||||
logSummary.renameBackup.bytes,
|
||||
logSummary.session.bytes,
|
||||
logSummary.trace.bytes,
|
||||
logSummary.traceBackup.bytes,
|
||||
logSummary.sessionLogs.bytes,
|
||||
logSummary.packageLogs.bytes,
|
||||
logSummary.itemLogs.bytes
|
||||
].reduce((sum, value) => sum + value, 0);
|
||||
|
||||
const diskSpace: DebugSetupCheckResult["diskSpace"] = {
|
||||
runtime: getDiskSpaceInfo(baseDir),
|
||||
output: getDiskSpaceInfo(settings.outputDir),
|
||||
extract: getDiskSpaceInfo(settings.extractDir)
|
||||
};
|
||||
const supportBundle = getSupportBundleEstimate(baseDir, logSummary);
|
||||
|
||||
if (!token) {
|
||||
warnings.push("debug_token.txt fehlt oder ist leer. Der Debug-Server startet dann nicht.");
|
||||
}
|
||||
if (localOnly) {
|
||||
warnings.push("Der Debug-Server ist aktuell nur lokal erreichbar. Für Remote-Support debug_host.txt auf 0.0.0.0 setzen.");
|
||||
} else {
|
||||
notes.push("Der Debug-Server ist für Remote-Zugriff konfiguriert. Firewall oder Provider-Regeln müssen separat offen sein.");
|
||||
}
|
||||
if (!fs.existsSync(supportManifestPath)) {
|
||||
warnings.push("debug_support_manifest.json fehlt. App einmal neu starten, damit das Support-Manifest neu geschrieben wird.");
|
||||
}
|
||||
if (!fs.existsSync(traceConfigPath)) {
|
||||
warnings.push("trace_config.json fehlt. Trace-Funktionen sind lokal noch nicht initialisiert.");
|
||||
}
|
||||
if (traceConfig.enabled && !traceConfig.autoDisableAt) {
|
||||
warnings.push("Support-Trace ist aktiv ohne automatische Abschaltzeit. Einmal neu aktivieren, damit die 2-Stunden-Begrenzung gesetzt wird.");
|
||||
}
|
||||
if (traceConfig.enabled && traceConfig.autoDisableAt) {
|
||||
notes.push(`Support-Trace aktiv bis ${traceConfig.autoDisableAt}.`);
|
||||
}
|
||||
|
||||
for (const entry of [
|
||||
{ label: "Runtime", info: diskSpace.runtime },
|
||||
{ label: "Download-Ziel", info: diskSpace.output },
|
||||
{ label: "Entpack-Ziel", info: diskSpace.extract }
|
||||
]) {
|
||||
if (entry.info.freeBytes === null || entry.info.totalBytes === null) {
|
||||
warnings.push(`${entry.label}: Freier Speicherplatz konnte nicht gelesen werden (${entry.info.path}).`);
|
||||
continue;
|
||||
}
|
||||
const lowByAbsolute = entry.info.freeBytes < LOW_FREE_BYTES_THRESHOLD;
|
||||
const lowByPercent = entry.info.freePercent !== null
|
||||
&& entry.info.freePercent < LOW_FREE_PERCENT_THRESHOLD
|
||||
&& entry.info.freeBytes < LOW_FREE_PERCENT_BYTES_GUARD;
|
||||
if (lowByAbsolute || lowByPercent) {
|
||||
warnings.push(`${entry.label}: wenig freier Speicherplatz (${formatByteCount(entry.info.freeBytes)} frei auf ${entry.info.path}).`);
|
||||
}
|
||||
}
|
||||
|
||||
if (logSummary.totalBytes >= LARGE_LOG_BYTES_THRESHOLD) {
|
||||
warnings.push(`Support-Logs sind bereits recht groß (${formatByteCount(logSummary.totalBytes)}). Rotation greift, aber ein Bundle wird entsprechend umfangreicher.`);
|
||||
} else {
|
||||
notes.push(`Aktuelle Support-Logmenge: ${formatByteCount(logSummary.totalBytes)}.`);
|
||||
}
|
||||
|
||||
if (supportBundle.estimatedBytes >= LARGE_BUNDLE_BYTES_THRESHOLD) {
|
||||
warnings.push(`Support-Bundle wird voraussichtlich groß (${formatByteCount(supportBundle.estimatedBytes)} vor ZIP-Komprimierung).`);
|
||||
} else {
|
||||
notes.push(`Support-Bundle-Schätzung: etwa ${formatByteCount(supportBundle.estimatedBytes)}.`);
|
||||
}
|
||||
|
||||
notes.push("Die App kann Netzwerk-Firewalls oder Provider-Sicherheitsgruppen nicht direkt prüfen.");
|
||||
|
||||
return {
|
||||
status: warnings.length > 0 ? "warn" : "ok",
|
||||
enabled: Boolean(token),
|
||||
runtimeBaseDir: baseDir,
|
||||
host,
|
||||
port,
|
||||
localOnly,
|
||||
tokenConfigured: Boolean(token),
|
||||
tokenPath,
|
||||
supportManifestPath,
|
||||
supportManifestPresent: fs.existsSync(supportManifestPath),
|
||||
traceConfigPath: fs.existsSync(traceConfigPath) ? traceConfigPath : null,
|
||||
traceLogPath: fs.existsSync(traceLogPath) ? traceLogPath : null,
|
||||
traceEnabled: traceConfig.enabled,
|
||||
traceAutoDisableAt: traceConfig.autoDisableAt,
|
||||
diskSpace,
|
||||
logSummary,
|
||||
supportBundle,
|
||||
warnings,
|
||||
notes,
|
||||
localUrls: {
|
||||
health: `http://127.0.0.1:${port}/health?token=${token || "<TOKEN>"}`,
|
||||
meta: `http://127.0.0.1:${port}/meta?token=${token || "<TOKEN>"}`,
|
||||
diagnostics: `http://127.0.0.1:${port}/diagnostics?token=${token || "<TOKEN>"}`
|
||||
},
|
||||
remoteUrlTemplates: {
|
||||
health: `http://<SERVER_IP_OR_DNS>:${port}/health?token=${token || "<TOKEN>"}`,
|
||||
meta: `http://<SERVER_IP_OR_DNS>:${port}/meta?token=${token || "<TOKEN>"}`,
|
||||
diagnostics: `http://<SERVER_IP_OR_DNS>:${port}/diagnostics?token=${token || "<TOKEN>"}`
|
||||
}
|
||||
};
|
||||
}
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { getSessionLogPath } from "./session-log";
|
||||
import { createStoragePaths, loadSettings } from "./storage";
|
||||
import type {
|
||||
DebugSetupCheckResult,
|
||||
SupportBundleEstimate,
|
||||
SupportDirectorySizeInfo,
|
||||
SupportDiskSpaceInfo,
|
||||
SupportFileSizeInfo,
|
||||
SupportTraceConfig
|
||||
} from "../shared/types";
|
||||
|
||||
const DEFAULT_PORT = 9868;
|
||||
const DEFAULT_HOST = "127.0.0.1";
|
||||
const 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_PERCENT_THRESHOLD = Number(process.env.RD_SELF_CHECK_LOW_FREE_PERCENT || 5);
|
||||
const LOW_FREE_PERCENT_BYTES_GUARD = Number(process.env.RD_SELF_CHECK_LOW_FREE_PERCENT_BYTES_GUARD || 50 * 1024 * 1024 * 1024);
|
||||
const LARGE_LOG_BYTES_THRESHOLD = Number(process.env.RD_SELF_CHECK_LARGE_LOG_BYTES || 250 * 1024 * 1024);
|
||||
const LARGE_BUNDLE_BYTES_THRESHOLD = Number(process.env.RD_SELF_CHECK_LARGE_BUNDLE_BYTES || 150 * 1024 * 1024);
|
||||
const BUNDLE_OVERVIEW_SLACK_BYTES = 256 * 1024;
|
||||
|
||||
function formatByteCount(bytes: number): string {
|
||||
if (!Number.isFinite(bytes) || bytes < 0) {
|
||||
return "0 B";
|
||||
}
|
||||
if (bytes < 1024) {
|
||||
return `${bytes} B`;
|
||||
}
|
||||
if (bytes < 1024 * 1024) {
|
||||
return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
}
|
||||
if (bytes < 1024 * 1024 * 1024) {
|
||||
return `${(bytes / (1024 * 1024)).toFixed(2)} MB`;
|
||||
}
|
||||
return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`;
|
||||
}
|
||||
|
||||
function readToken(baseDir: string): string {
|
||||
try {
|
||||
return fs.readFileSync(path.join(baseDir, "debug_token.txt"), "utf8").trim();
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
function readPort(baseDir: string): number {
|
||||
try {
|
||||
const raw = Number(fs.readFileSync(path.join(baseDir, "debug_port.txt"), "utf8").trim());
|
||||
if (Number.isFinite(raw) && raw >= 1024 && raw <= 65535) {
|
||||
return raw;
|
||||
}
|
||||
} catch {
|
||||
}
|
||||
return DEFAULT_PORT;
|
||||
}
|
||||
|
||||
function readHost(baseDir: string): string {
|
||||
try {
|
||||
const raw = fs.readFileSync(path.join(baseDir, "debug_host.txt"), "utf8").trim();
|
||||
if (!raw) {
|
||||
return DEFAULT_HOST;
|
||||
}
|
||||
if (/^(localhost|0\.0\.0\.0|127\.0\.0\.1|::1)$/i.test(raw)) {
|
||||
return raw;
|
||||
}
|
||||
if (/^[a-z0-9.-]+$/i.test(raw)) {
|
||||
return raw;
|
||||
}
|
||||
} catch {
|
||||
}
|
||||
return DEFAULT_HOST;
|
||||
}
|
||||
|
||||
function readTraceConfig(baseDir: string): SupportTraceConfig {
|
||||
const fallback: SupportTraceConfig = {
|
||||
enabled: false,
|
||||
includeMainLog: true,
|
||||
includeAudit: true,
|
||||
logDebugRequests: true,
|
||||
autoDisableAt: null,
|
||||
updatedAt: new Date(0).toISOString()
|
||||
};
|
||||
try {
|
||||
const filePath = path.join(baseDir, "trace_config.json");
|
||||
const parsed = JSON.parse(fs.readFileSync(filePath, "utf8")) as Partial<SupportTraceConfig>;
|
||||
return {
|
||||
enabled: Boolean(parsed.enabled),
|
||||
includeMainLog: parsed.includeMainLog === undefined ? true : Boolean(parsed.includeMainLog),
|
||||
includeAudit: parsed.includeAudit === undefined ? true : Boolean(parsed.includeAudit),
|
||||
logDebugRequests: parsed.logDebugRequests === undefined ? true : Boolean(parsed.logDebugRequests),
|
||||
autoDisableAt: typeof parsed.autoDisableAt === "string" && parsed.autoDisableAt.trim() ? parsed.autoDisableAt : null,
|
||||
updatedAt: typeof parsed.updatedAt === "string" && parsed.updatedAt.trim() ? parsed.updatedAt : fallback.updatedAt
|
||||
};
|
||||
} catch {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
function getFileSizeInfo(filePath: string | null): SupportFileSizeInfo {
|
||||
if (!filePath) {
|
||||
return { path: null, exists: false, bytes: 0 };
|
||||
}
|
||||
try {
|
||||
const stat = fs.statSync(filePath);
|
||||
return {
|
||||
path: filePath,
|
||||
exists: true,
|
||||
bytes: stat.size
|
||||
};
|
||||
} catch {
|
||||
return {
|
||||
path: filePath,
|
||||
exists: false,
|
||||
bytes: 0
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function getDirectorySizeInfo(dirPath: string, skipPath?: string | null): SupportDirectorySizeInfo {
|
||||
if (!fs.existsSync(dirPath)) {
|
||||
return {
|
||||
path: dirPath,
|
||||
exists: false,
|
||||
fileCount: 0,
|
||||
bytes: 0
|
||||
};
|
||||
}
|
||||
|
||||
let bytes = 0;
|
||||
let fileCount = 0;
|
||||
const queue = [dirPath];
|
||||
while (queue.length > 0) {
|
||||
const current = queue.pop();
|
||||
if (!current) {
|
||||
continue;
|
||||
}
|
||||
let entries: fs.Dirent[];
|
||||
try {
|
||||
entries = fs.readdirSync(current, { withFileTypes: true });
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
for (const entry of entries) {
|
||||
const fullPath = path.join(current, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
queue.push(fullPath);
|
||||
continue;
|
||||
}
|
||||
if (skipPath && path.resolve(fullPath) === path.resolve(skipPath)) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
bytes += fs.statSync(fullPath).size;
|
||||
fileCount += 1;
|
||||
} catch {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
path: dirPath,
|
||||
exists: true,
|
||||
fileCount,
|
||||
bytes
|
||||
};
|
||||
}
|
||||
|
||||
function resolveExistingPath(targetPath: string): string {
|
||||
let current = path.resolve(targetPath);
|
||||
while (!fs.existsSync(current)) {
|
||||
const parent = path.dirname(current);
|
||||
if (parent === current) {
|
||||
break;
|
||||
}
|
||||
current = parent;
|
||||
}
|
||||
return current;
|
||||
}
|
||||
|
||||
function getWindowsDiskSpaceInfo(existingPath: string): SupportDiskSpaceInfo | null {
|
||||
if (process.platform !== "win32") {
|
||||
return null;
|
||||
}
|
||||
const root = path.parse(existingPath).root.replace(/[\\/]+$/g, "");
|
||||
const driveName = root.replace(":", "");
|
||||
if (!/^[A-Za-z]$/.test(driveName)) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
const raw = execFileSync(
|
||||
"powershell",
|
||||
[
|
||||
"-NoProfile",
|
||||
"-Command",
|
||||
`$drive = Get-PSDrive -Name '${driveName}'; if ($drive) { [pscustomobject]@{ FreeSpace = [int64]$drive.Free; Size = [int64]($drive.Used + $drive.Free) } | ConvertTo-Json -Compress }`
|
||||
],
|
||||
{
|
||||
encoding: "utf8",
|
||||
windowsHide: true,
|
||||
stdio: ["ignore", "pipe", "ignore"],
|
||||
timeout: 3000
|
||||
}
|
||||
).trim();
|
||||
if (!raw) {
|
||||
return null;
|
||||
}
|
||||
const parsed = JSON.parse(raw) as { FreeSpace?: number | string; Size?: number | string };
|
||||
const totalBytes = Number(parsed.Size);
|
||||
const freeBytes = Number(parsed.FreeSpace);
|
||||
const freePercent = Number.isFinite(totalBytes) && totalBytes > 0
|
||||
? Math.round((freeBytes / totalBytes) * 1000) / 10
|
||||
: null;
|
||||
return {
|
||||
path: existingPath,
|
||||
totalBytes: Number.isFinite(totalBytes) ? totalBytes : null,
|
||||
freeBytes: Number.isFinite(freeBytes) ? freeBytes : null,
|
||||
freePercent
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function getDiskSpaceInfo(targetPath: string): SupportDiskSpaceInfo {
|
||||
const existingPath = resolveExistingPath(targetPath);
|
||||
try {
|
||||
const stat = fs.statfsSync(existingPath);
|
||||
const totalBytes = Number(stat.blocks) * Number(stat.bsize);
|
||||
const freeBytes = Number(stat.bavail) * Number(stat.bsize);
|
||||
const freePercent = totalBytes > 0
|
||||
? Math.round((freeBytes / totalBytes) * 1000) / 10
|
||||
: null;
|
||||
return {
|
||||
path: existingPath,
|
||||
totalBytes,
|
||||
freeBytes,
|
||||
freePercent
|
||||
};
|
||||
} catch {
|
||||
const windowsFallback = getWindowsDiskSpaceInfo(existingPath);
|
||||
if (windowsFallback) {
|
||||
return windowsFallback;
|
||||
}
|
||||
return {
|
||||
path: existingPath,
|
||||
totalBytes: null,
|
||||
freeBytes: null,
|
||||
freePercent: null
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function getSupportBundleEstimate(
|
||||
baseDir: string,
|
||||
logSummary: DebugSetupCheckResult["logSummary"]
|
||||
): SupportBundleEstimate {
|
||||
const storagePaths = createStoragePaths(baseDir);
|
||||
const staticFiles = [
|
||||
path.join(baseDir, AI_MANIFEST_FILE),
|
||||
path.join(baseDir, "debug_host.txt"),
|
||||
path.join(baseDir, "debug_port.txt"),
|
||||
storagePaths.configFile,
|
||||
storagePaths.sessionFile,
|
||||
storagePaths.historyFile,
|
||||
path.join(baseDir, "trace_config.json")
|
||||
].map((filePath) => getFileSizeInfo(filePath));
|
||||
|
||||
const staticBytes = staticFiles.reduce((sum, entry) => sum + entry.bytes, 0);
|
||||
const duplicatedLiveLogBytes = logSummary.session.bytes + logSummary.packageLogs.bytes + logSummary.itemLogs.bytes;
|
||||
const estimatedEntries = 10
|
||||
+ staticFiles.filter((entry) => entry.exists).length
|
||||
+ Number(logSummary.main.exists)
|
||||
+ Number(logSummary.mainBackup.exists)
|
||||
+ Number(logSummary.audit.exists)
|
||||
+ Number(logSummary.auditBackup.exists)
|
||||
+ Number(logSummary.rename.exists)
|
||||
+ Number(logSummary.renameBackup.exists)
|
||||
+ Number(logSummary.session.exists)
|
||||
+ Number(logSummary.trace.exists)
|
||||
+ Number(logSummary.traceBackup.exists)
|
||||
+ logSummary.sessionLogs.fileCount
|
||||
+ logSummary.packageLogs.fileCount
|
||||
+ logSummary.itemLogs.fileCount
|
||||
+ logSummary.packageLogs.fileCount
|
||||
+ logSummary.itemLogs.fileCount;
|
||||
|
||||
return {
|
||||
estimatedBytes: staticBytes + logSummary.totalBytes + duplicatedLiveLogBytes + BUNDLE_OVERVIEW_SLACK_BYTES,
|
||||
estimatedEntries,
|
||||
duplicatedLiveLogBytes,
|
||||
note: "Schätzwert vor ZIP-Komprimierung; aktueller Session-Log sowie Live-Paket-/Item-Logs werden im Bundle zusätzlich gespiegelt."
|
||||
};
|
||||
}
|
||||
|
||||
export function getDebugSetupCheck(baseDir: string): DebugSetupCheckResult {
|
||||
const host = readHost(baseDir);
|
||||
const port = readPort(baseDir);
|
||||
const token = readToken(baseDir);
|
||||
const storagePaths = createStoragePaths(baseDir);
|
||||
const settings = loadSettings(storagePaths);
|
||||
const tokenPath = path.join(baseDir, "debug_token.txt");
|
||||
const aiManifestPath = path.join(baseDir, AI_MANIFEST_FILE);
|
||||
const traceConfigPath = path.join(baseDir, "trace_config.json");
|
||||
const traceLogPath = path.join(baseDir, "trace.log");
|
||||
const traceConfig = readTraceConfig(baseDir);
|
||||
const sessionLogPath = getSessionLogPath();
|
||||
const localOnly = /^(127\.0\.0\.1|localhost|::1)$/i.test(host);
|
||||
const warnings: string[] = [];
|
||||
const notes: string[] = [];
|
||||
|
||||
const logSummary: DebugSetupCheckResult["logSummary"] = {
|
||||
main: getFileSizeInfo(path.join(baseDir, "rd_downloader.log")),
|
||||
mainBackup: getFileSizeInfo(path.join(baseDir, "rd_downloader.log.old")),
|
||||
audit: getFileSizeInfo(path.join(baseDir, "audit.log")),
|
||||
auditBackup: getFileSizeInfo(path.join(baseDir, "audit.log.old")),
|
||||
rename: getFileSizeInfo(path.join(baseDir, "rename.log")),
|
||||
renameBackup: getFileSizeInfo(path.join(baseDir, "rename.log.old")),
|
||||
session: getFileSizeInfo(sessionLogPath),
|
||||
trace: getFileSizeInfo(traceLogPath),
|
||||
traceBackup: getFileSizeInfo(path.join(baseDir, "trace.log.old")),
|
||||
sessionLogs: getDirectorySizeInfo(path.join(baseDir, "session-logs"), sessionLogPath),
|
||||
packageLogs: getDirectorySizeInfo(path.join(baseDir, "package-logs")),
|
||||
itemLogs: getDirectorySizeInfo(path.join(baseDir, "item-logs")),
|
||||
totalBytes: 0
|
||||
};
|
||||
logSummary.totalBytes = [
|
||||
logSummary.main.bytes,
|
||||
logSummary.mainBackup.bytes,
|
||||
logSummary.audit.bytes,
|
||||
logSummary.auditBackup.bytes,
|
||||
logSummary.rename.bytes,
|
||||
logSummary.renameBackup.bytes,
|
||||
logSummary.session.bytes,
|
||||
logSummary.trace.bytes,
|
||||
logSummary.traceBackup.bytes,
|
||||
logSummary.sessionLogs.bytes,
|
||||
logSummary.packageLogs.bytes,
|
||||
logSummary.itemLogs.bytes
|
||||
].reduce((sum, value) => sum + value, 0);
|
||||
|
||||
const diskSpace: DebugSetupCheckResult["diskSpace"] = {
|
||||
runtime: getDiskSpaceInfo(baseDir),
|
||||
output: getDiskSpaceInfo(settings.outputDir),
|
||||
extract: getDiskSpaceInfo(settings.extractDir)
|
||||
};
|
||||
const supportBundle = getSupportBundleEstimate(baseDir, logSummary);
|
||||
|
||||
if (!token) {
|
||||
warnings.push("debug_token.txt fehlt oder ist leer. Der Debug-Server startet dann nicht.");
|
||||
}
|
||||
if (localOnly) {
|
||||
warnings.push("Der Debug-Server ist aktuell nur lokal erreichbar. Für Remote-Support debug_host.txt auf 0.0.0.0 setzen.");
|
||||
} else {
|
||||
notes.push("Der Debug-Server ist für Remote-Zugriff konfiguriert. Firewall oder Provider-Regeln müssen separat offen sein.");
|
||||
}
|
||||
if (!fs.existsSync(aiManifestPath)) {
|
||||
warnings.push("debug_ai_manifest.json fehlt. App einmal neu starten, damit die KI-Support-Datei neu geschrieben wird.");
|
||||
}
|
||||
if (!fs.existsSync(traceConfigPath)) {
|
||||
warnings.push("trace_config.json fehlt. Trace-Funktionen sind lokal noch nicht initialisiert.");
|
||||
}
|
||||
if (traceConfig.enabled && !traceConfig.autoDisableAt) {
|
||||
warnings.push("Support-Trace ist aktiv ohne automatische Abschaltzeit. Einmal neu aktivieren, damit die 2-Stunden-Begrenzung gesetzt wird.");
|
||||
}
|
||||
if (traceConfig.enabled && traceConfig.autoDisableAt) {
|
||||
notes.push(`Support-Trace aktiv bis ${traceConfig.autoDisableAt}.`);
|
||||
}
|
||||
|
||||
for (const entry of [
|
||||
{ label: "Runtime", info: diskSpace.runtime },
|
||||
{ label: "Download-Ziel", info: diskSpace.output },
|
||||
{ label: "Entpack-Ziel", info: diskSpace.extract }
|
||||
]) {
|
||||
if (entry.info.freeBytes === null || entry.info.totalBytes === null) {
|
||||
warnings.push(`${entry.label}: Freier Speicherplatz konnte nicht gelesen werden (${entry.info.path}).`);
|
||||
continue;
|
||||
}
|
||||
const lowByAbsolute = entry.info.freeBytes < LOW_FREE_BYTES_THRESHOLD;
|
||||
const lowByPercent = entry.info.freePercent !== null
|
||||
&& entry.info.freePercent < LOW_FREE_PERCENT_THRESHOLD
|
||||
&& entry.info.freeBytes < LOW_FREE_PERCENT_BYTES_GUARD;
|
||||
if (lowByAbsolute || lowByPercent) {
|
||||
warnings.push(`${entry.label}: wenig freier Speicherplatz (${formatByteCount(entry.info.freeBytes)} frei auf ${entry.info.path}).`);
|
||||
}
|
||||
}
|
||||
|
||||
if (logSummary.totalBytes >= LARGE_LOG_BYTES_THRESHOLD) {
|
||||
warnings.push(`Support-Logs sind bereits recht groß (${formatByteCount(logSummary.totalBytes)}). Rotation greift, aber ein Bundle wird entsprechend umfangreicher.`);
|
||||
} else {
|
||||
notes.push(`Aktuelle Support-Logmenge: ${formatByteCount(logSummary.totalBytes)}.`);
|
||||
}
|
||||
|
||||
if (supportBundle.estimatedBytes >= LARGE_BUNDLE_BYTES_THRESHOLD) {
|
||||
warnings.push(`Support-Bundle wird voraussichtlich groß (${formatByteCount(supportBundle.estimatedBytes)} vor ZIP-Komprimierung).`);
|
||||
} else {
|
||||
notes.push(`Support-Bundle-Schätzung: etwa ${formatByteCount(supportBundle.estimatedBytes)}.`);
|
||||
}
|
||||
|
||||
notes.push("Die App kann Netzwerk-Firewalls oder Provider-Sicherheitsgruppen nicht direkt prüfen.");
|
||||
|
||||
return {
|
||||
status: warnings.length > 0 ? "warn" : "ok",
|
||||
enabled: Boolean(token),
|
||||
runtimeBaseDir: baseDir,
|
||||
host,
|
||||
port,
|
||||
localOnly,
|
||||
tokenConfigured: Boolean(token),
|
||||
tokenPath,
|
||||
aiManifestPath,
|
||||
aiManifestPresent: fs.existsSync(aiManifestPath),
|
||||
traceConfigPath: fs.existsSync(traceConfigPath) ? traceConfigPath : null,
|
||||
traceLogPath: fs.existsSync(traceLogPath) ? traceLogPath : null,
|
||||
traceEnabled: traceConfig.enabled,
|
||||
traceAutoDisableAt: traceConfig.autoDisableAt,
|
||||
diskSpace,
|
||||
logSummary,
|
||||
supportBundle,
|
||||
warnings,
|
||||
notes,
|
||||
localUrls: {
|
||||
health: `http://127.0.0.1:${port}/health?token=${token || "<TOKEN>"}`,
|
||||
meta: `http://127.0.0.1:${port}/meta?token=${token || "<TOKEN>"}`,
|
||||
diagnostics: `http://127.0.0.1:${port}/diagnostics?token=${token || "<TOKEN>"}`
|
||||
},
|
||||
remoteUrlTemplates: {
|
||||
health: `http://<SERVER_IP_OR_DNS>:${port}/health?token=${token || "<TOKEN>"}`,
|
||||
meta: `http://<SERVER_IP_OR_DNS>:${port}/meta?token=${token || "<TOKEN>"}`,
|
||||
diagnostics: `http://<SERVER_IP_OR_DNS>:${port}/diagnostics?token=${token || "<TOKEN>"}`
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@ -1,252 +1,252 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { logTimestamp } from "./log-timestamp";
|
||||
|
||||
type DesktopRenameLevel = "INFO" | "WARN" | "ERROR";
|
||||
|
||||
const FOLDER_NAME = "Downloader-Log";
|
||||
|
||||
let logDir: string | null = null;
|
||||
let logFilePath: string | null = null;
|
||||
let sessionHeader = "";
|
||||
|
||||
function fileTimestamp(date: Date = new Date()): string {
|
||||
const pad = (value: number): string => String(value).padStart(2, "0");
|
||||
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}_`
|
||||
+ `${pad(date.getHours())}-${pad(date.getMinutes())}-${pad(date.getSeconds())}`;
|
||||
}
|
||||
|
||||
function sanitizeFieldValue(value: unknown): string {
|
||||
if (value === undefined || value === null) {
|
||||
return "";
|
||||
}
|
||||
if (typeof value === "string") {
|
||||
return value.replace(/\r?\n/g, "\\n");
|
||||
}
|
||||
if (typeof value === "number" || typeof value === "boolean") {
|
||||
return String(value);
|
||||
}
|
||||
try {
|
||||
return JSON.stringify(value).replace(/\r?\n/g, "\\n");
|
||||
} catch {
|
||||
return String(value);
|
||||
}
|
||||
}
|
||||
|
||||
function formatFields(fields?: Record<string, unknown>): string {
|
||||
if (!fields) {
|
||||
return "";
|
||||
}
|
||||
const parts = Object.entries(fields)
|
||||
.filter(([, value]) => value !== undefined && value !== null && sanitizeFieldValue(value) !== "")
|
||||
.map(([key, value]) => `${key}=${sanitizeFieldValue(value)}`);
|
||||
return parts.length > 0 ? ` | ${parts.join(" | ")}` : "";
|
||||
}
|
||||
|
||||
function ensureWritable(): boolean {
|
||||
if (!logDir || !logFilePath) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
fs.mkdirSync(logDir, { recursive: true });
|
||||
if (!fs.existsSync(logFilePath)) {
|
||||
fs.writeFileSync(logFilePath, sessionHeader, "utf8");
|
||||
}
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function initDesktopRenameLog(desktopDir: string | null | undefined): void {
|
||||
try {
|
||||
const base = String(desktopDir || "").trim();
|
||||
if (!base) {
|
||||
logDir = null;
|
||||
logFilePath = null;
|
||||
return;
|
||||
}
|
||||
logDir = path.join(base, FOLDER_NAME);
|
||||
logFilePath = path.join(logDir, `rename-session_${fileTimestamp()}.txt`);
|
||||
sessionHeader = `=== Rename-Session gestartet: ${logTimestamp()} ===\n`
|
||||
+ "Diese Datei protokolliert JEDEN Umbenenn-/Verschiebevorgang dieser Programm-Sitzung\n"
|
||||
+ "und verifiziert nach jedem Vorgang, ob die Datei wirklich unter dem Zielnamen auf der\n"
|
||||
+ "Platte liegt (und die Quelle verschwunden ist). [INFO]=ok, [ERROR]=Verifikation gescheitert.\n\n";
|
||||
fs.mkdirSync(logDir, { recursive: true });
|
||||
fs.writeFileSync(logFilePath, sessionHeader, "utf8");
|
||||
} catch {
|
||||
logDir = null;
|
||||
logFilePath = null;
|
||||
}
|
||||
}
|
||||
|
||||
export function logDesktopRename(level: DesktopRenameLevel, message: string, fields?: Record<string, unknown>): void {
|
||||
if (!ensureWritable() || !logFilePath) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
fs.appendFileSync(logFilePath, `${logTimestamp()} [${level}] ${message}${formatFields(fields)}\n`, "utf8");
|
||||
} catch {
|
||||
}
|
||||
}
|
||||
|
||||
export function getDesktopRenameLogPath(): string | null {
|
||||
if (!logFilePath) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return fs.existsSync(logFilePath) ? logFilePath : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function shutdownDesktopRenameLog(): void {
|
||||
if (ensureWritable() && logFilePath) {
|
||||
try {
|
||||
fs.appendFileSync(logFilePath, `=== Rename-Session beendet: ${logTimestamp()} ===\n`, "utf8");
|
||||
} catch {
|
||||
}
|
||||
}
|
||||
logDir = null;
|
||||
logFilePath = null;
|
||||
}
|
||||
|
||||
export interface RenameVerification {
|
||||
ok: boolean;
|
||||
level: "INFO" | "WARN" | "ERROR";
|
||||
targetExists: boolean;
|
||||
onDiskName: string | null;
|
||||
nameMatches: boolean;
|
||||
sourceGone: boolean;
|
||||
targetSize: number | null;
|
||||
reason: string;
|
||||
}
|
||||
|
||||
function toLongPath(filePath: string): string {
|
||||
const absolute = path.resolve(String(filePath || ""));
|
||||
if (process.platform !== "win32") {
|
||||
return absolute;
|
||||
}
|
||||
if (!absolute || absolute.startsWith("\\\\?\\")) {
|
||||
return absolute;
|
||||
}
|
||||
if (absolute.length < 248) {
|
||||
return absolute;
|
||||
}
|
||||
if (absolute.startsWith("\\\\")) {
|
||||
return `\\\\?\\UNC\\${absolute.slice(2)}`;
|
||||
}
|
||||
return `\\\\?\\${absolute}`;
|
||||
}
|
||||
|
||||
function resolveOnDiskName(requested: string, entries: string[] | null): string | null {
|
||||
if (entries === null) {
|
||||
return null;
|
||||
}
|
||||
const requestedLower = requested.toLowerCase();
|
||||
return entries.find((entry) => entry === requested)
|
||||
|| entries.find((entry) => entry.toLowerCase() === requestedLower)
|
||||
|| requested;
|
||||
}
|
||||
|
||||
function buildVerification(
|
||||
sourcePath: string,
|
||||
targetPath: string,
|
||||
facts: { targetExists: boolean; targetSize: number | null; dirEntries: string[] | null; sourceExists: boolean }
|
||||
): RenameVerification {
|
||||
const requested = path.basename(targetPath);
|
||||
const dirReadFailed = facts.targetExists && facts.dirEntries === null;
|
||||
const onDiskName = facts.targetExists ? resolveOnDiskName(requested, facts.dirEntries) : null;
|
||||
|
||||
const samePath = path.resolve(sourcePath).toLowerCase() === path.resolve(targetPath).toLowerCase();
|
||||
const sourceGone = samePath ? true : !facts.sourceExists;
|
||||
const nameMatches = facts.targetExists && !dirReadFailed && onDiskName === requested;
|
||||
|
||||
const problems: string[] = [];
|
||||
let level: "INFO" | "WARN" | "ERROR" = "INFO";
|
||||
if (!facts.targetExists) {
|
||||
problems.push("Zieldatei nach Rename NICHT gefunden");
|
||||
level = "ERROR";
|
||||
} else if (!dirReadFailed && !nameMatches) {
|
||||
problems.push(`On-Disk-Name weicht ab (ist "${onDiskName}", erwartet "${requested}")`);
|
||||
level = "ERROR";
|
||||
}
|
||||
if (!samePath && facts.targetExists && !sourceGone) {
|
||||
problems.push("Quelldatei existiert noch (moeglicher halb-fertiger Verschiebevorgang)");
|
||||
level = "ERROR";
|
||||
}
|
||||
if (level === "INFO" && dirReadFailed) {
|
||||
problems.push("Zielverzeichnis nicht lesbar — Schreibweise nicht verifiziert");
|
||||
level = "WARN";
|
||||
}
|
||||
|
||||
return {
|
||||
ok: level === "INFO",
|
||||
level,
|
||||
targetExists: facts.targetExists,
|
||||
onDiskName,
|
||||
nameMatches,
|
||||
sourceGone,
|
||||
targetSize: facts.targetSize,
|
||||
reason: problems.join("; ")
|
||||
};
|
||||
}
|
||||
|
||||
export function verifyRename(sourcePath: string, targetPath: string): RenameVerification {
|
||||
const longTarget = toLongPath(targetPath);
|
||||
let targetExists = false;
|
||||
let targetSize: number | null = null;
|
||||
try {
|
||||
const stat = fs.statSync(longTarget);
|
||||
targetExists = true;
|
||||
targetSize = stat.size;
|
||||
} catch {
|
||||
targetExists = false;
|
||||
}
|
||||
let dirEntries: string[] | null = null;
|
||||
if (targetExists) {
|
||||
try {
|
||||
dirEntries = fs.readdirSync(path.dirname(longTarget));
|
||||
} catch {
|
||||
dirEntries = null;
|
||||
}
|
||||
}
|
||||
let sourceExists = false;
|
||||
try {
|
||||
fs.statSync(toLongPath(sourcePath));
|
||||
sourceExists = true;
|
||||
} catch {
|
||||
sourceExists = false;
|
||||
}
|
||||
return buildVerification(sourcePath, targetPath, { targetExists, targetSize, dirEntries, sourceExists });
|
||||
}
|
||||
|
||||
export async function verifyRenameAsync(sourcePath: string, targetPath: string): Promise<RenameVerification> {
|
||||
const longTarget = toLongPath(targetPath);
|
||||
let targetExists = false;
|
||||
let targetSize: number | null = null;
|
||||
try {
|
||||
const stat = await fs.promises.stat(longTarget);
|
||||
targetExists = true;
|
||||
targetSize = stat.size;
|
||||
} catch {
|
||||
targetExists = false;
|
||||
}
|
||||
let dirEntries: string[] | null = null;
|
||||
if (targetExists) {
|
||||
try {
|
||||
dirEntries = await fs.promises.readdir(path.dirname(longTarget));
|
||||
} catch {
|
||||
dirEntries = null;
|
||||
}
|
||||
}
|
||||
let sourceExists = false;
|
||||
try {
|
||||
await fs.promises.stat(toLongPath(sourcePath));
|
||||
sourceExists = true;
|
||||
} catch {
|
||||
sourceExists = false;
|
||||
}
|
||||
return buildVerification(sourcePath, targetPath, { targetExists, targetSize, dirEntries, sourceExists });
|
||||
}
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { logTimestamp } from "./log-timestamp";
|
||||
|
||||
type DesktopRenameLevel = "INFO" | "WARN" | "ERROR";
|
||||
|
||||
const FOLDER_NAME = "Downloader-Log";
|
||||
|
||||
let logDir: string | null = null;
|
||||
let logFilePath: string | null = null;
|
||||
let sessionHeader = "";
|
||||
|
||||
function fileTimestamp(date: Date = new Date()): string {
|
||||
const pad = (value: number): string => String(value).padStart(2, "0");
|
||||
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}_`
|
||||
+ `${pad(date.getHours())}-${pad(date.getMinutes())}-${pad(date.getSeconds())}`;
|
||||
}
|
||||
|
||||
function sanitizeFieldValue(value: unknown): string {
|
||||
if (value === undefined || value === null) {
|
||||
return "";
|
||||
}
|
||||
if (typeof value === "string") {
|
||||
return value.replace(/\r?\n/g, "\\n");
|
||||
}
|
||||
if (typeof value === "number" || typeof value === "boolean") {
|
||||
return String(value);
|
||||
}
|
||||
try {
|
||||
return JSON.stringify(value).replace(/\r?\n/g, "\\n");
|
||||
} catch {
|
||||
return String(value);
|
||||
}
|
||||
}
|
||||
|
||||
function formatFields(fields?: Record<string, unknown>): string {
|
||||
if (!fields) {
|
||||
return "";
|
||||
}
|
||||
const parts = Object.entries(fields)
|
||||
.filter(([, value]) => value !== undefined && value !== null && sanitizeFieldValue(value) !== "")
|
||||
.map(([key, value]) => `${key}=${sanitizeFieldValue(value)}`);
|
||||
return parts.length > 0 ? ` | ${parts.join(" | ")}` : "";
|
||||
}
|
||||
|
||||
function ensureWritable(): boolean {
|
||||
if (!logDir || !logFilePath) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
fs.mkdirSync(logDir, { recursive: true });
|
||||
if (!fs.existsSync(logFilePath)) {
|
||||
fs.writeFileSync(logFilePath, sessionHeader, "utf8");
|
||||
}
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function initDesktopRenameLog(desktopDir: string | null | undefined): void {
|
||||
try {
|
||||
const base = String(desktopDir || "").trim();
|
||||
if (!base) {
|
||||
logDir = null;
|
||||
logFilePath = null;
|
||||
return;
|
||||
}
|
||||
logDir = path.join(base, FOLDER_NAME);
|
||||
logFilePath = path.join(logDir, `rename-session_${fileTimestamp()}.txt`);
|
||||
sessionHeader = `=== Rename-Session gestartet: ${logTimestamp()} ===\n`
|
||||
+ "Diese Datei protokolliert JEDEN Umbenenn-/Verschiebevorgang dieser Programm-Sitzung\n"
|
||||
+ "und verifiziert nach jedem Vorgang, ob die Datei wirklich unter dem Zielnamen auf der\n"
|
||||
+ "Platte liegt (und die Quelle verschwunden ist). [INFO]=ok, [ERROR]=Verifikation gescheitert.\n\n";
|
||||
fs.mkdirSync(logDir, { recursive: true });
|
||||
fs.writeFileSync(logFilePath, sessionHeader, "utf8");
|
||||
} catch {
|
||||
logDir = null;
|
||||
logFilePath = null;
|
||||
}
|
||||
}
|
||||
|
||||
export function logDesktopRename(level: DesktopRenameLevel, message: string, fields?: Record<string, unknown>): void {
|
||||
if (!ensureWritable() || !logFilePath) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
fs.appendFileSync(logFilePath, `${logTimestamp()} [${level}] ${message}${formatFields(fields)}\n`, "utf8");
|
||||
} catch {
|
||||
}
|
||||
}
|
||||
|
||||
export function getDesktopRenameLogPath(): string | null {
|
||||
if (!logFilePath) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return fs.existsSync(logFilePath) ? logFilePath : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function shutdownDesktopRenameLog(): void {
|
||||
if (ensureWritable() && logFilePath) {
|
||||
try {
|
||||
fs.appendFileSync(logFilePath, `=== Rename-Session beendet: ${logTimestamp()} ===\n`, "utf8");
|
||||
} catch {
|
||||
}
|
||||
}
|
||||
logDir = null;
|
||||
logFilePath = null;
|
||||
}
|
||||
|
||||
export interface RenameVerification {
|
||||
ok: boolean;
|
||||
level: "INFO" | "WARN" | "ERROR";
|
||||
targetExists: boolean;
|
||||
onDiskName: string | null;
|
||||
nameMatches: boolean;
|
||||
sourceGone: boolean;
|
||||
targetSize: number | null;
|
||||
reason: string;
|
||||
}
|
||||
|
||||
function toLongPath(filePath: string): string {
|
||||
const absolute = path.resolve(String(filePath || ""));
|
||||
if (process.platform !== "win32") {
|
||||
return absolute;
|
||||
}
|
||||
if (!absolute || absolute.startsWith("\\\\?\\")) {
|
||||
return absolute;
|
||||
}
|
||||
if (absolute.length < 248) {
|
||||
return absolute;
|
||||
}
|
||||
if (absolute.startsWith("\\\\")) {
|
||||
return `\\\\?\\UNC\\${absolute.slice(2)}`;
|
||||
}
|
||||
return `\\\\?\\${absolute}`;
|
||||
}
|
||||
|
||||
function resolveOnDiskName(requested: string, entries: string[] | null): string | null {
|
||||
if (entries === null) {
|
||||
return null;
|
||||
}
|
||||
const requestedLower = requested.toLowerCase();
|
||||
return entries.find((entry) => entry === requested)
|
||||
|| entries.find((entry) => entry.toLowerCase() === requestedLower)
|
||||
|| requested;
|
||||
}
|
||||
|
||||
function buildVerification(
|
||||
sourcePath: string,
|
||||
targetPath: string,
|
||||
facts: { targetExists: boolean; targetSize: number | null; dirEntries: string[] | null; sourceExists: boolean }
|
||||
): RenameVerification {
|
||||
const requested = path.basename(targetPath);
|
||||
const dirReadFailed = facts.targetExists && facts.dirEntries === null;
|
||||
const onDiskName = facts.targetExists ? resolveOnDiskName(requested, facts.dirEntries) : null;
|
||||
|
||||
const samePath = path.resolve(sourcePath).toLowerCase() === path.resolve(targetPath).toLowerCase();
|
||||
const sourceGone = samePath ? true : !facts.sourceExists;
|
||||
const nameMatches = facts.targetExists && !dirReadFailed && onDiskName === requested;
|
||||
|
||||
const problems: string[] = [];
|
||||
let level: "INFO" | "WARN" | "ERROR" = "INFO";
|
||||
if (!facts.targetExists) {
|
||||
problems.push("Zieldatei nach Rename NICHT gefunden");
|
||||
level = "ERROR";
|
||||
} else if (!dirReadFailed && !nameMatches) {
|
||||
problems.push(`On-Disk-Name weicht ab (ist "${onDiskName}", erwartet "${requested}")`);
|
||||
level = "ERROR";
|
||||
}
|
||||
if (!samePath && facts.targetExists && !sourceGone) {
|
||||
problems.push("Quelldatei existiert noch (moeglicher halb-fertiger Verschiebevorgang)");
|
||||
level = "ERROR";
|
||||
}
|
||||
if (level === "INFO" && dirReadFailed) {
|
||||
problems.push("Zielverzeichnis nicht lesbar — Schreibweise nicht verifiziert");
|
||||
level = "WARN";
|
||||
}
|
||||
|
||||
return {
|
||||
ok: level === "INFO",
|
||||
level,
|
||||
targetExists: facts.targetExists,
|
||||
onDiskName,
|
||||
nameMatches,
|
||||
sourceGone,
|
||||
targetSize: facts.targetSize,
|
||||
reason: problems.join("; ")
|
||||
};
|
||||
}
|
||||
|
||||
export function verifyRename(sourcePath: string, targetPath: string): RenameVerification {
|
||||
const longTarget = toLongPath(targetPath);
|
||||
let targetExists = false;
|
||||
let targetSize: number | null = null;
|
||||
try {
|
||||
const stat = fs.statSync(longTarget);
|
||||
targetExists = true;
|
||||
targetSize = stat.size;
|
||||
} catch {
|
||||
targetExists = false;
|
||||
}
|
||||
let dirEntries: string[] | null = null;
|
||||
if (targetExists) {
|
||||
try {
|
||||
dirEntries = fs.readdirSync(path.dirname(longTarget));
|
||||
} catch {
|
||||
dirEntries = null;
|
||||
}
|
||||
}
|
||||
let sourceExists = false;
|
||||
try {
|
||||
fs.statSync(toLongPath(sourcePath));
|
||||
sourceExists = true;
|
||||
} catch {
|
||||
sourceExists = false;
|
||||
}
|
||||
return buildVerification(sourcePath, targetPath, { targetExists, targetSize, dirEntries, sourceExists });
|
||||
}
|
||||
|
||||
export async function verifyRenameAsync(sourcePath: string, targetPath: string): Promise<RenameVerification> {
|
||||
const longTarget = toLongPath(targetPath);
|
||||
let targetExists = false;
|
||||
let targetSize: number | null = null;
|
||||
try {
|
||||
const stat = await fs.promises.stat(longTarget);
|
||||
targetExists = true;
|
||||
targetSize = stat.size;
|
||||
} catch {
|
||||
targetExists = false;
|
||||
}
|
||||
let dirEntries: string[] | null = null;
|
||||
if (targetExists) {
|
||||
try {
|
||||
dirEntries = await fs.promises.readdir(path.dirname(longTarget));
|
||||
} catch {
|
||||
dirEntries = null;
|
||||
}
|
||||
}
|
||||
let sourceExists = false;
|
||||
try {
|
||||
await fs.promises.stat(toLongPath(sourcePath));
|
||||
sourceExists = true;
|
||||
} catch {
|
||||
sourceExists = false;
|
||||
}
|
||||
return buildVerification(sourcePath, targetPath, { targetExists, targetSize, dirEntries, sourceExists });
|
||||
}
|
||||
|
||||
@ -1,166 +1,166 @@
|
||||
import { ALLOCATION_UNIT_SIZE } from "./constants";
|
||||
|
||||
export type DownloadCompletionSource =
|
||||
| "content-range"
|
||||
| "content-length"
|
||||
| "provider-metadata"
|
||||
| "stream-end";
|
||||
|
||||
export type DownloadCompletionPlan = {
|
||||
expectedTotal: number | null;
|
||||
source: DownloadCompletionSource;
|
||||
canFinishEarly: boolean;
|
||||
};
|
||||
|
||||
export function planDownloadCompletion(args: {
|
||||
existingBytes: number;
|
||||
responseStatus: number;
|
||||
contentLength: number;
|
||||
totalFromRange: number | null;
|
||||
knownTotal: number | null;
|
||||
correctedTotal: number | null;
|
||||
}): DownloadCompletionPlan {
|
||||
const existingBytes = Math.max(0, Math.floor(Number(args.existingBytes) || 0));
|
||||
const responseStatus = Math.floor(Number(args.responseStatus) || 0);
|
||||
const contentLength = Math.max(0, Math.floor(Number(args.contentLength) || 0));
|
||||
const totalFromRange = Number.isFinite(args.totalFromRange || NaN)
|
||||
? Math.max(0, Math.floor(args.totalFromRange || 0))
|
||||
: 0;
|
||||
const correctedTotal = Number.isFinite(args.correctedTotal || NaN)
|
||||
? Math.max(0, Math.floor(args.correctedTotal || 0))
|
||||
: 0;
|
||||
const knownTotal = Number.isFinite(args.knownTotal || NaN)
|
||||
? Math.max(0, Math.floor(args.knownTotal || 0))
|
||||
: 0;
|
||||
|
||||
if (correctedTotal > 0) {
|
||||
return {
|
||||
expectedTotal: correctedTotal,
|
||||
source: totalFromRange > 0 ? "content-range" : "content-length",
|
||||
canFinishEarly: true
|
||||
};
|
||||
}
|
||||
|
||||
if (totalFromRange > 0) {
|
||||
return {
|
||||
expectedTotal: totalFromRange,
|
||||
source: "content-range",
|
||||
canFinishEarly: true
|
||||
};
|
||||
}
|
||||
|
||||
if (contentLength > 0) {
|
||||
return {
|
||||
expectedTotal: responseStatus === 206 ? existingBytes + contentLength : contentLength,
|
||||
source: "content-length",
|
||||
canFinishEarly: true
|
||||
};
|
||||
}
|
||||
|
||||
if (knownTotal > 0) {
|
||||
return {
|
||||
expectedTotal: knownTotal,
|
||||
source: "provider-metadata",
|
||||
canFinishEarly: false
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
expectedTotal: null,
|
||||
source: "stream-end",
|
||||
canFinishEarly: false
|
||||
};
|
||||
}
|
||||
|
||||
export function reconcileFinalizedSize(
|
||||
streamedBytes: number,
|
||||
statSize: number,
|
||||
preAllocated: boolean
|
||||
): number {
|
||||
const streamed = Math.max(0, Math.floor(Number(streamedBytes) || 0));
|
||||
if (!Number.isFinite(statSize) || statSize < 0) {
|
||||
return streamed;
|
||||
}
|
||||
const onDisk = Math.floor(statSize);
|
||||
if (preAllocated && onDisk > streamed) {
|
||||
return streamed;
|
||||
}
|
||||
return onDisk;
|
||||
}
|
||||
|
||||
export function validateDownloadedFileCompletion(args: {
|
||||
actualBytes: number;
|
||||
plan: DownloadCompletionPlan;
|
||||
toleranceBytes?: number;
|
||||
}): {
|
||||
ok: boolean;
|
||||
totalBytes: number;
|
||||
acceptedMetadataMismatch: boolean;
|
||||
error?: string;
|
||||
} {
|
||||
const actualBytes = Math.max(0, Math.floor(Number(args.actualBytes) || 0));
|
||||
const expectedTotal = Number.isFinite(args.plan.expectedTotal || NaN)
|
||||
? Math.max(0, Math.floor(args.plan.expectedTotal || 0))
|
||||
: 0;
|
||||
const toleranceBytes = Math.max(0, Math.floor(Number(args.toleranceBytes ?? ALLOCATION_UNIT_SIZE) || 0));
|
||||
|
||||
if (
|
||||
expectedTotal > 0 &&
|
||||
(args.plan.source === "content-range" || args.plan.source === "content-length") &&
|
||||
actualBytes + toleranceBytes < expectedTotal
|
||||
) {
|
||||
return {
|
||||
ok: false,
|
||||
totalBytes: expectedTotal,
|
||||
acceptedMetadataMismatch: false,
|
||||
error: `download_underflow:${actualBytes}/${expectedTotal}`
|
||||
};
|
||||
}
|
||||
|
||||
if (actualBytes <= 0 && expectedTotal > 0) {
|
||||
return {
|
||||
ok: false,
|
||||
totalBytes: expectedTotal,
|
||||
acceptedMetadataMismatch: false,
|
||||
error: `download_underflow:${actualBytes}/${expectedTotal}`
|
||||
};
|
||||
}
|
||||
|
||||
if (args.plan.source === "provider-metadata") {
|
||||
if (expectedTotal > 0 && actualBytes + toleranceBytes < expectedTotal) {
|
||||
return {
|
||||
ok: false,
|
||||
totalBytes: expectedTotal,
|
||||
acceptedMetadataMismatch: false,
|
||||
error: `download_underflow:${actualBytes}/${expectedTotal}`
|
||||
};
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
totalBytes: actualBytes,
|
||||
acceptedMetadataMismatch: expectedTotal > 0 && Math.abs(actualBytes - expectedTotal) > toleranceBytes
|
||||
};
|
||||
}
|
||||
|
||||
if (args.plan.source === "stream-end") {
|
||||
if (actualBytes <= 0) {
|
||||
return {
|
||||
ok: false,
|
||||
totalBytes: 0,
|
||||
acceptedMetadataMismatch: false,
|
||||
error: "download_underflow:0/0"
|
||||
};
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
totalBytes: actualBytes,
|
||||
acceptedMetadataMismatch: false
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
totalBytes: Math.max(actualBytes, expectedTotal),
|
||||
acceptedMetadataMismatch: false
|
||||
};
|
||||
}
|
||||
import { ALLOCATION_UNIT_SIZE } from "./constants";
|
||||
|
||||
export type DownloadCompletionSource =
|
||||
| "content-range"
|
||||
| "content-length"
|
||||
| "provider-metadata"
|
||||
| "stream-end";
|
||||
|
||||
export type DownloadCompletionPlan = {
|
||||
expectedTotal: number | null;
|
||||
source: DownloadCompletionSource;
|
||||
canFinishEarly: boolean;
|
||||
};
|
||||
|
||||
export function planDownloadCompletion(args: {
|
||||
existingBytes: number;
|
||||
responseStatus: number;
|
||||
contentLength: number;
|
||||
totalFromRange: number | null;
|
||||
knownTotal: number | null;
|
||||
correctedTotal: number | null;
|
||||
}): DownloadCompletionPlan {
|
||||
const existingBytes = Math.max(0, Math.floor(Number(args.existingBytes) || 0));
|
||||
const responseStatus = Math.floor(Number(args.responseStatus) || 0);
|
||||
const contentLength = Math.max(0, Math.floor(Number(args.contentLength) || 0));
|
||||
const totalFromRange = Number.isFinite(args.totalFromRange || NaN)
|
||||
? Math.max(0, Math.floor(args.totalFromRange || 0))
|
||||
: 0;
|
||||
const correctedTotal = Number.isFinite(args.correctedTotal || NaN)
|
||||
? Math.max(0, Math.floor(args.correctedTotal || 0))
|
||||
: 0;
|
||||
const knownTotal = Number.isFinite(args.knownTotal || NaN)
|
||||
? Math.max(0, Math.floor(args.knownTotal || 0))
|
||||
: 0;
|
||||
|
||||
if (correctedTotal > 0) {
|
||||
return {
|
||||
expectedTotal: correctedTotal,
|
||||
source: totalFromRange > 0 ? "content-range" : "content-length",
|
||||
canFinishEarly: true
|
||||
};
|
||||
}
|
||||
|
||||
if (totalFromRange > 0) {
|
||||
return {
|
||||
expectedTotal: totalFromRange,
|
||||
source: "content-range",
|
||||
canFinishEarly: true
|
||||
};
|
||||
}
|
||||
|
||||
if (contentLength > 0) {
|
||||
return {
|
||||
expectedTotal: responseStatus === 206 ? existingBytes + contentLength : contentLength,
|
||||
source: "content-length",
|
||||
canFinishEarly: true
|
||||
};
|
||||
}
|
||||
|
||||
if (knownTotal > 0) {
|
||||
return {
|
||||
expectedTotal: knownTotal,
|
||||
source: "provider-metadata",
|
||||
canFinishEarly: false
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
expectedTotal: null,
|
||||
source: "stream-end",
|
||||
canFinishEarly: false
|
||||
};
|
||||
}
|
||||
|
||||
export function reconcileFinalizedSize(
|
||||
streamedBytes: number,
|
||||
statSize: number,
|
||||
preAllocated: boolean
|
||||
): number {
|
||||
const streamed = Math.max(0, Math.floor(Number(streamedBytes) || 0));
|
||||
if (!Number.isFinite(statSize) || statSize < 0) {
|
||||
return streamed;
|
||||
}
|
||||
const onDisk = Math.floor(statSize);
|
||||
if (preAllocated && onDisk > streamed) {
|
||||
return streamed;
|
||||
}
|
||||
return onDisk;
|
||||
}
|
||||
|
||||
export function validateDownloadedFileCompletion(args: {
|
||||
actualBytes: number;
|
||||
plan: DownloadCompletionPlan;
|
||||
toleranceBytes?: number;
|
||||
}): {
|
||||
ok: boolean;
|
||||
totalBytes: number;
|
||||
acceptedMetadataMismatch: boolean;
|
||||
error?: string;
|
||||
} {
|
||||
const actualBytes = Math.max(0, Math.floor(Number(args.actualBytes) || 0));
|
||||
const expectedTotal = Number.isFinite(args.plan.expectedTotal || NaN)
|
||||
? Math.max(0, Math.floor(args.plan.expectedTotal || 0))
|
||||
: 0;
|
||||
const toleranceBytes = Math.max(0, Math.floor(Number(args.toleranceBytes ?? ALLOCATION_UNIT_SIZE) || 0));
|
||||
|
||||
if (
|
||||
expectedTotal > 0 &&
|
||||
(args.plan.source === "content-range" || args.plan.source === "content-length") &&
|
||||
actualBytes + toleranceBytes < expectedTotal
|
||||
) {
|
||||
return {
|
||||
ok: false,
|
||||
totalBytes: expectedTotal,
|
||||
acceptedMetadataMismatch: false,
|
||||
error: `download_underflow:${actualBytes}/${expectedTotal}`
|
||||
};
|
||||
}
|
||||
|
||||
if (actualBytes <= 0 && expectedTotal > 0) {
|
||||
return {
|
||||
ok: false,
|
||||
totalBytes: expectedTotal,
|
||||
acceptedMetadataMismatch: false,
|
||||
error: `download_underflow:${actualBytes}/${expectedTotal}`
|
||||
};
|
||||
}
|
||||
|
||||
if (args.plan.source === "provider-metadata") {
|
||||
if (expectedTotal > 0 && actualBytes + toleranceBytes < expectedTotal) {
|
||||
return {
|
||||
ok: false,
|
||||
totalBytes: expectedTotal,
|
||||
acceptedMetadataMismatch: false,
|
||||
error: `download_underflow:${actualBytes}/${expectedTotal}`
|
||||
};
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
totalBytes: actualBytes,
|
||||
acceptedMetadataMismatch: expectedTotal > 0 && Math.abs(actualBytes - expectedTotal) > toleranceBytes
|
||||
};
|
||||
}
|
||||
|
||||
if (args.plan.source === "stream-end") {
|
||||
if (actualBytes <= 0) {
|
||||
return {
|
||||
ok: false,
|
||||
totalBytes: 0,
|
||||
acceptedMetadataMismatch: false,
|
||||
error: "download_underflow:0/0"
|
||||
};
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
totalBytes: actualBytes,
|
||||
acceptedMetadataMismatch: false
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
totalBytes: Math.max(actualBytes, expectedTotal),
|
||||
acceptedMetadataMismatch: false
|
||||
};
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -1,45 +1,45 @@
|
||||
export interface ErrorRingEntry {
|
||||
ts: string;
|
||||
level: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface ErrorRing {
|
||||
push: (entry: ErrorRingEntry) => void;
|
||||
snapshot: () => ErrorRingEntry[];
|
||||
clear: () => void;
|
||||
size: () => number;
|
||||
}
|
||||
|
||||
export function createErrorRing(capacity: number): ErrorRing {
|
||||
const limit = Math.max(1, Math.floor(capacity));
|
||||
const buffer: ErrorRingEntry[] = [];
|
||||
return {
|
||||
push(entry: ErrorRingEntry): void {
|
||||
buffer.push(entry);
|
||||
while (buffer.length > limit) {
|
||||
buffer.shift();
|
||||
}
|
||||
},
|
||||
snapshot(): ErrorRingEntry[] {
|
||||
return buffer.slice();
|
||||
},
|
||||
clear(): void {
|
||||
buffer.length = 0;
|
||||
},
|
||||
size(): number {
|
||||
return buffer.length;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const RECENT_ERROR_CAPACITY = 200;
|
||||
const recentErrors = createErrorRing(RECENT_ERROR_CAPACITY);
|
||||
|
||||
export function recordRecentError(level: string, message: string, ts: string): void {
|
||||
recentErrors.push({ level, message, ts });
|
||||
}
|
||||
|
||||
export function getRecentErrors(): ErrorRingEntry[] {
|
||||
return recentErrors.snapshot();
|
||||
}
|
||||
export interface ErrorRingEntry {
|
||||
ts: string;
|
||||
level: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface ErrorRing {
|
||||
push: (entry: ErrorRingEntry) => void;
|
||||
snapshot: () => ErrorRingEntry[];
|
||||
clear: () => void;
|
||||
size: () => number;
|
||||
}
|
||||
|
||||
export function createErrorRing(capacity: number): ErrorRing {
|
||||
const limit = Math.max(1, Math.floor(capacity));
|
||||
const buffer: ErrorRingEntry[] = [];
|
||||
return {
|
||||
push(entry: ErrorRingEntry): void {
|
||||
buffer.push(entry);
|
||||
while (buffer.length > limit) {
|
||||
buffer.shift();
|
||||
}
|
||||
},
|
||||
snapshot(): ErrorRingEntry[] {
|
||||
return buffer.slice();
|
||||
},
|
||||
clear(): void {
|
||||
buffer.length = 0;
|
||||
},
|
||||
size(): number {
|
||||
return buffer.length;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const RECENT_ERROR_CAPACITY = 200;
|
||||
const recentErrors = createErrorRing(RECENT_ERROR_CAPACITY);
|
||||
|
||||
export function recordRecentError(level: string, message: string, ts: string): void {
|
||||
recentErrors.push({ level, message, ts });
|
||||
}
|
||||
|
||||
export function getRecentErrors(): ErrorRingEntry[] {
|
||||
return recentErrors.snapshot();
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -1,56 +1,56 @@
|
||||
// Maps low-level filesystem/OS error codes to a human-readable cause so that a
|
||||
// generic "write failed" or "timeout" can be reported as the specific root cause
|
||||
// (disk full, permission denied, ...). Pure + side-effect-free for testing.
|
||||
|
||||
const DISK_ERROR_REASONS: Record<string, string> = {
|
||||
ENOSPC: "Festplatte voll (ENOSPC)",
|
||||
EDQUOT: "Speicher-Kontingent erschöpft (EDQUOT)",
|
||||
EROFS: "Laufwerk schreibgeschützt (EROFS)",
|
||||
EACCES: "Zugriff verweigert (EACCES)",
|
||||
EPERM: "Operation nicht erlaubt (EPERM)",
|
||||
EMFILE: "Zu viele offene Dateien (EMFILE)",
|
||||
ENFILE: "System-Limit offener Dateien erreicht (ENFILE)",
|
||||
EBUSY: "Datei/Laufwerk belegt (EBUSY)",
|
||||
ENODEV: "Gerät nicht vorhanden (ENODEV)",
|
||||
ENXIO: "Gerät getrennt (ENXIO)",
|
||||
EIO: "Ein-/Ausgabefehler des Datenträgers (EIO)"
|
||||
};
|
||||
|
||||
export function classifyDiskError(err: unknown): string | null {
|
||||
const code = extractErrorCode(err);
|
||||
if (code && DISK_ERROR_REASONS[code]) {
|
||||
return DISK_ERROR_REASONS[code];
|
||||
}
|
||||
// Some errors arrive as plain strings/messages without a `.code`; fall back to
|
||||
// scanning the text for a known code token.
|
||||
const text = errorText(err);
|
||||
for (const knownCode of Object.keys(DISK_ERROR_REASONS)) {
|
||||
if (text.includes(knownCode)) {
|
||||
return DISK_ERROR_REASONS[knownCode];
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function extractErrorCode(err: unknown): string {
|
||||
if (err && typeof err === "object") {
|
||||
const code = (err as { code?: unknown }).code;
|
||||
if (typeof code === "string") {
|
||||
return code.toUpperCase();
|
||||
}
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
function errorText(err: unknown): string {
|
||||
if (typeof err === "string") {
|
||||
return err;
|
||||
}
|
||||
if (err && typeof err === "object") {
|
||||
const message = (err as { message?: unknown }).message;
|
||||
if (typeof message === "string") {
|
||||
return message;
|
||||
}
|
||||
}
|
||||
return String(err ?? "");
|
||||
}
|
||||
// Maps low-level filesystem/OS error codes to a human-readable cause so that a
|
||||
// generic "write failed" or "timeout" can be reported as the specific root cause
|
||||
// (disk full, permission denied, ...). Pure + side-effect-free for testing.
|
||||
|
||||
const DISK_ERROR_REASONS: Record<string, string> = {
|
||||
ENOSPC: "Festplatte voll (ENOSPC)",
|
||||
EDQUOT: "Speicher-Kontingent erschöpft (EDQUOT)",
|
||||
EROFS: "Laufwerk schreibgeschützt (EROFS)",
|
||||
EACCES: "Zugriff verweigert (EACCES)",
|
||||
EPERM: "Operation nicht erlaubt (EPERM)",
|
||||
EMFILE: "Zu viele offene Dateien (EMFILE)",
|
||||
ENFILE: "System-Limit offener Dateien erreicht (ENFILE)",
|
||||
EBUSY: "Datei/Laufwerk belegt (EBUSY)",
|
||||
ENODEV: "Gerät nicht vorhanden (ENODEV)",
|
||||
ENXIO: "Gerät getrennt (ENXIO)",
|
||||
EIO: "Ein-/Ausgabefehler des Datenträgers (EIO)"
|
||||
};
|
||||
|
||||
export function classifyDiskError(err: unknown): string | null {
|
||||
const code = extractErrorCode(err);
|
||||
if (code && DISK_ERROR_REASONS[code]) {
|
||||
return DISK_ERROR_REASONS[code];
|
||||
}
|
||||
// Some errors arrive as plain strings/messages without a `.code`; fall back to
|
||||
// scanning the text for a known code token.
|
||||
const text = errorText(err);
|
||||
for (const knownCode of Object.keys(DISK_ERROR_REASONS)) {
|
||||
if (text.includes(knownCode)) {
|
||||
return DISK_ERROR_REASONS[knownCode];
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function extractErrorCode(err: unknown): string {
|
||||
if (err && typeof err === "object") {
|
||||
const code = (err as { code?: unknown }).code;
|
||||
if (typeof code === "string") {
|
||||
return code.toUpperCase();
|
||||
}
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
function errorText(err: unknown): string {
|
||||
if (typeof err === "string") {
|
||||
return err;
|
||||
}
|
||||
if (err && typeof err === "object") {
|
||||
const message = (err as { message?: unknown }).message;
|
||||
if (typeof message === "string") {
|
||||
return message;
|
||||
}
|
||||
}
|
||||
return String(err ?? "");
|
||||
}
|
||||
|
||||
@ -1,159 +1,159 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import crypto from "node:crypto";
|
||||
import { ParsedHashEntry } from "../shared/types";
|
||||
import { MAX_MANIFEST_FILE_BYTES } from "./constants";
|
||||
|
||||
const manifestCache = new Map<string, { at: number; entries: Map<string, ParsedHashEntry> }>();
|
||||
const MANIFEST_CACHE_TTL_MS = 15000;
|
||||
|
||||
function normalizeManifestKey(value: string): string {
|
||||
return String(value || "")
|
||||
.replace(/\\/g, "/")
|
||||
.replace(/^\.\//, "")
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
}
|
||||
|
||||
export function parseHashLine(line: string): ParsedHashEntry | null {
|
||||
const text = String(line || "").trim();
|
||||
if (!text || text.startsWith(";")) {
|
||||
return null;
|
||||
}
|
||||
const md = text.match(/^([0-9a-fA-F]{32}|[0-9a-fA-F]{40})\s+\*?(.+)$/);
|
||||
if (md) {
|
||||
const digest = md[1].toLowerCase();
|
||||
return {
|
||||
fileName: md[2].trim(),
|
||||
algorithm: digest.length === 32 ? "md5" : "sha1",
|
||||
digest
|
||||
};
|
||||
}
|
||||
const sfv = text.match(/^(.+?)\s+([0-9A-Fa-f]{8})$/);
|
||||
if (sfv) {
|
||||
return {
|
||||
fileName: sfv[1].trim(),
|
||||
algorithm: "crc32",
|
||||
digest: sfv[2].toLowerCase()
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function readHashManifest(packageDir: string): Map<string, ParsedHashEntry> {
|
||||
const cacheKey = path.resolve(packageDir);
|
||||
const cached = manifestCache.get(cacheKey);
|
||||
if (cached && Date.now() - cached.at <= MANIFEST_CACHE_TTL_MS) {
|
||||
return new Map(cached.entries);
|
||||
}
|
||||
|
||||
const map = new Map<string, ParsedHashEntry>();
|
||||
const patterns: Array<[string, "crc32" | "md5" | "sha1"]> = [
|
||||
[".sfv", "crc32"],
|
||||
[".md5", "md5"],
|
||||
[".sha1", "sha1"]
|
||||
];
|
||||
|
||||
if (!fs.existsSync(packageDir)) {
|
||||
return map;
|
||||
}
|
||||
|
||||
const manifestFiles = fs.readdirSync(packageDir, { withFileTypes: true })
|
||||
.filter((entry) => {
|
||||
if (!entry.isFile()) {
|
||||
return false;
|
||||
}
|
||||
const ext = path.extname(entry.name).toLowerCase();
|
||||
return patterns.some(([pattern]) => pattern === ext);
|
||||
})
|
||||
.sort((a, b) => a.name.localeCompare(b.name, undefined, { numeric: true, sensitivity: "base" }));
|
||||
|
||||
for (const entry of manifestFiles) {
|
||||
if (!entry.isFile()) {
|
||||
continue;
|
||||
}
|
||||
const ext = path.extname(entry.name).toLowerCase();
|
||||
const hit = patterns.find(([pattern]) => pattern === ext);
|
||||
if (!hit) {
|
||||
continue;
|
||||
}
|
||||
const filePath = path.join(packageDir, entry.name);
|
||||
let lines: string[];
|
||||
try {
|
||||
const stat = fs.statSync(filePath);
|
||||
if (stat.size > MAX_MANIFEST_FILE_BYTES) {
|
||||
continue;
|
||||
}
|
||||
lines = fs.readFileSync(filePath, "utf8").split(/\r?\n/);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
for (const line of lines) {
|
||||
const parsed = parseHashLine(line);
|
||||
if (!parsed) {
|
||||
continue;
|
||||
}
|
||||
const key = normalizeManifestKey(parsed.fileName);
|
||||
if (map.has(key)) {
|
||||
continue;
|
||||
}
|
||||
map.set(key, parsed);
|
||||
}
|
||||
}
|
||||
manifestCache.set(cacheKey, { at: Date.now(), entries: new Map(map) });
|
||||
return map;
|
||||
}
|
||||
|
||||
const crcTable = new Int32Array(256);
|
||||
for (let i = 0; i < 256; i++) {
|
||||
let c = i;
|
||||
for (let j = 0; j < 8; j++) c = c & 1 ? (0xedb88320 ^ (c >>> 1)) : (c >>> 1);
|
||||
crcTable[i] = c;
|
||||
}
|
||||
|
||||
function crc32Buffer(data: Buffer, seed = 0): number {
|
||||
let crc = seed ^ -1;
|
||||
for (let i = 0; i < data.length; i++) {
|
||||
crc = (crc >>> 8) ^ crcTable[(crc ^ data[i]) & 0xff];
|
||||
}
|
||||
return crc ^ -1;
|
||||
}
|
||||
|
||||
async function hashFile(filePath: string, algorithm: "crc32" | "md5" | "sha1"): Promise<string> {
|
||||
if (algorithm === "crc32") {
|
||||
const stream = fs.createReadStream(filePath, { highWaterMark: 1024 * 1024 });
|
||||
let crc = 0;
|
||||
for await (const chunk of stream) {
|
||||
crc = crc32Buffer(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk), crc);
|
||||
await new Promise(r => setImmediate(r));
|
||||
}
|
||||
return (crc >>> 0).toString(16).padStart(8, "0").toLowerCase();
|
||||
}
|
||||
|
||||
const hash = crypto.createHash(algorithm);
|
||||
const stream = fs.createReadStream(filePath, { highWaterMark: 1024 * 1024 });
|
||||
return await new Promise<string>((resolve, reject) => {
|
||||
stream.on("data", (chunk: string | Buffer) => hash.update(typeof chunk === "string" ? Buffer.from(chunk) : chunk));
|
||||
stream.on("error", reject);
|
||||
stream.on("end", () => resolve(hash.digest("hex").toLowerCase()));
|
||||
});
|
||||
}
|
||||
|
||||
export async function validateFileAgainstManifest(filePath: string, packageDir: string): Promise<{ ok: boolean; message: string }> {
|
||||
const manifest = readHashManifest(packageDir);
|
||||
if (manifest.size === 0) {
|
||||
return { ok: true, message: "Kein Hash verfügbar" };
|
||||
}
|
||||
const keyByBaseName = normalizeManifestKey(path.basename(filePath));
|
||||
const keyByRelativePath = normalizeManifestKey(path.relative(packageDir, filePath));
|
||||
const entry = manifest.get(keyByRelativePath) || manifest.get(keyByBaseName);
|
||||
if (!entry) {
|
||||
return { ok: true, message: "Kein Hash für Datei" };
|
||||
}
|
||||
|
||||
const actual = await hashFile(filePath, entry.algorithm);
|
||||
if (actual === entry.digest.toLowerCase()) {
|
||||
return { ok: true, message: `${entry.algorithm.toUpperCase()} ok` };
|
||||
}
|
||||
return { ok: false, message: `${entry.algorithm.toUpperCase()} mismatch` };
|
||||
}
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import crypto from "node:crypto";
|
||||
import { ParsedHashEntry } from "../shared/types";
|
||||
import { MAX_MANIFEST_FILE_BYTES } from "./constants";
|
||||
|
||||
const manifestCache = new Map<string, { at: number; entries: Map<string, ParsedHashEntry> }>();
|
||||
const MANIFEST_CACHE_TTL_MS = 15000;
|
||||
|
||||
function normalizeManifestKey(value: string): string {
|
||||
return String(value || "")
|
||||
.replace(/\\/g, "/")
|
||||
.replace(/^\.\//, "")
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
}
|
||||
|
||||
export function parseHashLine(line: string): ParsedHashEntry | null {
|
||||
const text = String(line || "").trim();
|
||||
if (!text || text.startsWith(";")) {
|
||||
return null;
|
||||
}
|
||||
const md = text.match(/^([0-9a-fA-F]{32}|[0-9a-fA-F]{40})\s+\*?(.+)$/);
|
||||
if (md) {
|
||||
const digest = md[1].toLowerCase();
|
||||
return {
|
||||
fileName: md[2].trim(),
|
||||
algorithm: digest.length === 32 ? "md5" : "sha1",
|
||||
digest
|
||||
};
|
||||
}
|
||||
const sfv = text.match(/^(.+?)\s+([0-9A-Fa-f]{8})$/);
|
||||
if (sfv) {
|
||||
return {
|
||||
fileName: sfv[1].trim(),
|
||||
algorithm: "crc32",
|
||||
digest: sfv[2].toLowerCase()
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function readHashManifest(packageDir: string): Map<string, ParsedHashEntry> {
|
||||
const cacheKey = path.resolve(packageDir);
|
||||
const cached = manifestCache.get(cacheKey);
|
||||
if (cached && Date.now() - cached.at <= MANIFEST_CACHE_TTL_MS) {
|
||||
return new Map(cached.entries);
|
||||
}
|
||||
|
||||
const map = new Map<string, ParsedHashEntry>();
|
||||
const patterns: Array<[string, "crc32" | "md5" | "sha1"]> = [
|
||||
[".sfv", "crc32"],
|
||||
[".md5", "md5"],
|
||||
[".sha1", "sha1"]
|
||||
];
|
||||
|
||||
if (!fs.existsSync(packageDir)) {
|
||||
return map;
|
||||
}
|
||||
|
||||
const manifestFiles = fs.readdirSync(packageDir, { withFileTypes: true })
|
||||
.filter((entry) => {
|
||||
if (!entry.isFile()) {
|
||||
return false;
|
||||
}
|
||||
const ext = path.extname(entry.name).toLowerCase();
|
||||
return patterns.some(([pattern]) => pattern === ext);
|
||||
})
|
||||
.sort((a, b) => a.name.localeCompare(b.name, undefined, { numeric: true, sensitivity: "base" }));
|
||||
|
||||
for (const entry of manifestFiles) {
|
||||
if (!entry.isFile()) {
|
||||
continue;
|
||||
}
|
||||
const ext = path.extname(entry.name).toLowerCase();
|
||||
const hit = patterns.find(([pattern]) => pattern === ext);
|
||||
if (!hit) {
|
||||
continue;
|
||||
}
|
||||
const filePath = path.join(packageDir, entry.name);
|
||||
let lines: string[];
|
||||
try {
|
||||
const stat = fs.statSync(filePath);
|
||||
if (stat.size > MAX_MANIFEST_FILE_BYTES) {
|
||||
continue;
|
||||
}
|
||||
lines = fs.readFileSync(filePath, "utf8").split(/\r?\n/);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
for (const line of lines) {
|
||||
const parsed = parseHashLine(line);
|
||||
if (!parsed) {
|
||||
continue;
|
||||
}
|
||||
const key = normalizeManifestKey(parsed.fileName);
|
||||
if (map.has(key)) {
|
||||
continue;
|
||||
}
|
||||
map.set(key, parsed);
|
||||
}
|
||||
}
|
||||
manifestCache.set(cacheKey, { at: Date.now(), entries: new Map(map) });
|
||||
return map;
|
||||
}
|
||||
|
||||
const crcTable = new Int32Array(256);
|
||||
for (let i = 0; i < 256; i++) {
|
||||
let c = i;
|
||||
for (let j = 0; j < 8; j++) c = c & 1 ? (0xedb88320 ^ (c >>> 1)) : (c >>> 1);
|
||||
crcTable[i] = c;
|
||||
}
|
||||
|
||||
function crc32Buffer(data: Buffer, seed = 0): number {
|
||||
let crc = seed ^ -1;
|
||||
for (let i = 0; i < data.length; i++) {
|
||||
crc = (crc >>> 8) ^ crcTable[(crc ^ data[i]) & 0xff];
|
||||
}
|
||||
return crc ^ -1;
|
||||
}
|
||||
|
||||
async function hashFile(filePath: string, algorithm: "crc32" | "md5" | "sha1"): Promise<string> {
|
||||
if (algorithm === "crc32") {
|
||||
const stream = fs.createReadStream(filePath, { highWaterMark: 1024 * 1024 });
|
||||
let crc = 0;
|
||||
for await (const chunk of stream) {
|
||||
crc = crc32Buffer(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk), crc);
|
||||
await new Promise(r => setImmediate(r));
|
||||
}
|
||||
return (crc >>> 0).toString(16).padStart(8, "0").toLowerCase();
|
||||
}
|
||||
|
||||
const hash = crypto.createHash(algorithm);
|
||||
const stream = fs.createReadStream(filePath, { highWaterMark: 1024 * 1024 });
|
||||
return await new Promise<string>((resolve, reject) => {
|
||||
stream.on("data", (chunk: string | Buffer) => hash.update(typeof chunk === "string" ? Buffer.from(chunk) : chunk));
|
||||
stream.on("error", reject);
|
||||
stream.on("end", () => resolve(hash.digest("hex").toLowerCase()));
|
||||
});
|
||||
}
|
||||
|
||||
export async function validateFileAgainstManifest(filePath: string, packageDir: string): Promise<{ ok: boolean; message: string }> {
|
||||
const manifest = readHashManifest(packageDir);
|
||||
if (manifest.size === 0) {
|
||||
return { ok: true, message: "Kein Hash verfügbar" };
|
||||
}
|
||||
const keyByBaseName = normalizeManifestKey(path.basename(filePath));
|
||||
const keyByRelativePath = normalizeManifestKey(path.relative(packageDir, filePath));
|
||||
const entry = manifest.get(keyByRelativePath) || manifest.get(keyByBaseName);
|
||||
if (!entry) {
|
||||
return { ok: true, message: "Kein Hash für Datei" };
|
||||
}
|
||||
|
||||
const actual = await hashFile(filePath, entry.algorithm);
|
||||
if (actual === entry.digest.toLowerCase()) {
|
||||
return { ok: true, message: `${entry.algorithm.toUpperCase()} ok` };
|
||||
}
|
||||
return { ok: false, message: `${entry.algorithm.toUpperCase()} mismatch` };
|
||||
}
|
||||
|
||||
@ -1,232 +1,232 @@
|
||||
import fs from "node:fs";
|
||||
import { logTimestamp } from "./log-timestamp";
|
||||
import path from "node:path";
|
||||
import crypto from "node:crypto";
|
||||
|
||||
const ITEM_LOG_FLUSH_INTERVAL_MS = 200;
|
||||
const ITEM_LOG_RETENTION_DAYS = 30;
|
||||
|
||||
type ItemLogLevel = "INFO" | "WARN" | "ERROR";
|
||||
|
||||
export interface ItemLogMeta {
|
||||
itemId: string;
|
||||
packageId: string;
|
||||
packageName: string;
|
||||
fileName: string;
|
||||
targetPath: string;
|
||||
}
|
||||
|
||||
let itemLogsDir: string | null = null;
|
||||
const knownLogPaths = new Map<string, string>();
|
||||
const pendingLinesByItem = new Map<string, string[]>();
|
||||
const initializedThisProcess = new Set<string>();
|
||||
let flushTimer: NodeJS.Timeout | null = null;
|
||||
|
||||
function normalizeItemId(itemId: string): string {
|
||||
const trimmed = String(itemId || "").trim();
|
||||
if (!trimmed) {
|
||||
return "";
|
||||
}
|
||||
const safePrefix = trimmed
|
||||
.replace(/[^a-zA-Z0-9._-]/g, "_")
|
||||
.replace(/_+/g, "_")
|
||||
.slice(0, 64)
|
||||
.replace(/^_+|_+$/g, "");
|
||||
const hash = crypto.createHash("sha1").update(trimmed).digest("hex").slice(0, 12);
|
||||
return `${safePrefix || "item"}_${hash}`;
|
||||
}
|
||||
|
||||
function sanitizeFieldValue(value: unknown): string {
|
||||
if (value === undefined || value === null) {
|
||||
return "";
|
||||
}
|
||||
if (typeof value === "string") {
|
||||
return value.replace(/\r?\n/g, "\\n");
|
||||
}
|
||||
if (typeof value === "number" || typeof value === "boolean") {
|
||||
return String(value);
|
||||
}
|
||||
try {
|
||||
return JSON.stringify(value).replace(/\r?\n/g, "\\n");
|
||||
} catch {
|
||||
return String(value);
|
||||
}
|
||||
}
|
||||
|
||||
function formatFields(fields?: Record<string, unknown>): string {
|
||||
if (!fields) {
|
||||
return "";
|
||||
}
|
||||
const parts = Object.entries(fields)
|
||||
.filter(([, value]) => value !== undefined && value !== null && sanitizeFieldValue(value) !== "")
|
||||
.map(([key, value]) => `${key}=${sanitizeFieldValue(value)}`);
|
||||
return parts.length > 0 ? ` | ${parts.join(" | ")}` : "";
|
||||
}
|
||||
|
||||
function getItemLogFilePathFromNormalized(normalized: string): string | null {
|
||||
if (!normalized || !itemLogsDir) {
|
||||
return null;
|
||||
}
|
||||
const existing = knownLogPaths.get(normalized);
|
||||
if (existing) {
|
||||
return existing;
|
||||
}
|
||||
const logPath = path.join(itemLogsDir, `item_${normalized}.txt`);
|
||||
knownLogPaths.set(normalized, logPath);
|
||||
return logPath;
|
||||
}
|
||||
|
||||
function getItemLogFilePath(itemId: string): string | null {
|
||||
return getItemLogFilePathFromNormalized(normalizeItemId(itemId));
|
||||
}
|
||||
|
||||
function flushPending(): void {
|
||||
for (const [itemId, lines] of pendingLinesByItem.entries()) {
|
||||
if (lines.length === 0) {
|
||||
continue;
|
||||
}
|
||||
const logPath = getItemLogFilePathFromNormalized(itemId);
|
||||
if (!logPath) {
|
||||
continue;
|
||||
}
|
||||
const chunk = lines.join("");
|
||||
pendingLinesByItem.set(itemId, []);
|
||||
try {
|
||||
fs.appendFileSync(logPath, chunk, "utf8");
|
||||
} catch {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function scheduleFlush(): void {
|
||||
if (flushTimer) {
|
||||
return;
|
||||
}
|
||||
flushTimer = setTimeout(() => {
|
||||
flushTimer = null;
|
||||
flushPending();
|
||||
}, ITEM_LOG_FLUSH_INTERVAL_MS);
|
||||
}
|
||||
|
||||
async function cleanupOldItemLogs(dir: string): Promise<void> {
|
||||
try {
|
||||
const files = await fs.promises.readdir(dir);
|
||||
const cutoff = Date.now() - ITEM_LOG_RETENTION_DAYS * 24 * 60 * 60 * 1000;
|
||||
for (const file of files) {
|
||||
if (!file.startsWith("item_") || !file.endsWith(".txt")) {
|
||||
continue;
|
||||
}
|
||||
const filePath = path.join(dir, file);
|
||||
try {
|
||||
const stat = await fs.promises.stat(filePath);
|
||||
if (stat.mtimeMs < cutoff) {
|
||||
await fs.promises.unlink(filePath);
|
||||
}
|
||||
} catch {
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
}
|
||||
}
|
||||
|
||||
function appendLine(itemId: string, line: string): void {
|
||||
const normalized = normalizeItemId(itemId);
|
||||
if (!normalized) {
|
||||
return;
|
||||
}
|
||||
const lines = pendingLinesByItem.get(normalized) || [];
|
||||
lines.push(line);
|
||||
pendingLinesByItem.set(normalized, lines);
|
||||
scheduleFlush();
|
||||
}
|
||||
|
||||
export function initItemLogs(baseDir: string): void {
|
||||
itemLogsDir = path.join(baseDir, "item-logs");
|
||||
try {
|
||||
fs.mkdirSync(itemLogsDir, { recursive: true });
|
||||
} catch {
|
||||
itemLogsDir = null;
|
||||
return;
|
||||
}
|
||||
void cleanupOldItemLogs(itemLogsDir);
|
||||
}
|
||||
|
||||
export function ensureItemLog(meta: ItemLogMeta): string | null {
|
||||
const normalizedItemId = normalizeItemId(meta.itemId);
|
||||
const logPath = getItemLogFilePath(meta.itemId);
|
||||
if (!logPath) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
fs.mkdirSync(path.dirname(logPath), { recursive: true });
|
||||
if (!fs.existsSync(logPath)) {
|
||||
fs.writeFileSync(logPath, "", "utf8");
|
||||
}
|
||||
if (!initializedThisProcess.has(normalizedItemId)) {
|
||||
initializedThisProcess.add(normalizedItemId);
|
||||
const startedAt = logTimestamp();
|
||||
fs.appendFileSync(
|
||||
logPath,
|
||||
`=== Item-Log Start: ${startedAt} | itemId=${sanitizeFieldValue(String(meta.itemId || ""))} | logKey=${normalizedItemId} | fileName=${sanitizeFieldValue(meta.fileName)} ===\n`,
|
||||
"utf8"
|
||||
);
|
||||
fs.appendFileSync(
|
||||
logPath,
|
||||
`${logTimestamp()} [INFO] Item-Kontext initialisiert${formatFields({
|
||||
packageId: meta.packageId,
|
||||
packageName: meta.packageName,
|
||||
fileName: meta.fileName,
|
||||
targetPath: meta.targetPath
|
||||
})}\n`,
|
||||
"utf8"
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
return logPath;
|
||||
}
|
||||
|
||||
export function logItemEvent(
|
||||
itemId: string,
|
||||
level: ItemLogLevel,
|
||||
message: string,
|
||||
fields?: Record<string, unknown>
|
||||
): void {
|
||||
const logPath = getItemLogFilePath(itemId);
|
||||
if (!logPath) {
|
||||
return;
|
||||
}
|
||||
const line = `${logTimestamp()} [${level}] ${message}${formatFields(fields)}\n`;
|
||||
appendLine(itemId, line);
|
||||
}
|
||||
|
||||
export function getItemLogPath(itemId: string): string | null {
|
||||
const logPath = getItemLogFilePath(itemId);
|
||||
if (!logPath) {
|
||||
return null;
|
||||
}
|
||||
return fs.existsSync(logPath) ? logPath : null;
|
||||
}
|
||||
|
||||
export function shutdownItemLogs(): void {
|
||||
if (flushTimer) {
|
||||
clearTimeout(flushTimer);
|
||||
flushTimer = null;
|
||||
}
|
||||
flushPending();
|
||||
for (const itemId of knownLogPaths.keys()) {
|
||||
const logPath = getItemLogFilePathFromNormalized(itemId);
|
||||
if (!logPath) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
fs.appendFileSync(logPath, `=== Item-Log Ende: ${logTimestamp()} ===\n`, "utf8");
|
||||
} catch {
|
||||
}
|
||||
}
|
||||
pendingLinesByItem.clear();
|
||||
knownLogPaths.clear();
|
||||
initializedThisProcess.clear();
|
||||
itemLogsDir = null;
|
||||
}
|
||||
import fs from "node:fs";
|
||||
import { logTimestamp } from "./log-timestamp";
|
||||
import path from "node:path";
|
||||
import crypto from "node:crypto";
|
||||
|
||||
const ITEM_LOG_FLUSH_INTERVAL_MS = 200;
|
||||
const ITEM_LOG_RETENTION_DAYS = 30;
|
||||
|
||||
type ItemLogLevel = "INFO" | "WARN" | "ERROR";
|
||||
|
||||
export interface ItemLogMeta {
|
||||
itemId: string;
|
||||
packageId: string;
|
||||
packageName: string;
|
||||
fileName: string;
|
||||
targetPath: string;
|
||||
}
|
||||
|
||||
let itemLogsDir: string | null = null;
|
||||
const knownLogPaths = new Map<string, string>();
|
||||
const pendingLinesByItem = new Map<string, string[]>();
|
||||
const initializedThisProcess = new Set<string>();
|
||||
let flushTimer: NodeJS.Timeout | null = null;
|
||||
|
||||
function normalizeItemId(itemId: string): string {
|
||||
const trimmed = String(itemId || "").trim();
|
||||
if (!trimmed) {
|
||||
return "";
|
||||
}
|
||||
const safePrefix = trimmed
|
||||
.replace(/[^a-zA-Z0-9._-]/g, "_")
|
||||
.replace(/_+/g, "_")
|
||||
.slice(0, 64)
|
||||
.replace(/^_+|_+$/g, "");
|
||||
const hash = crypto.createHash("sha1").update(trimmed).digest("hex").slice(0, 12);
|
||||
return `${safePrefix || "item"}_${hash}`;
|
||||
}
|
||||
|
||||
function sanitizeFieldValue(value: unknown): string {
|
||||
if (value === undefined || value === null) {
|
||||
return "";
|
||||
}
|
||||
if (typeof value === "string") {
|
||||
return value.replace(/\r?\n/g, "\\n");
|
||||
}
|
||||
if (typeof value === "number" || typeof value === "boolean") {
|
||||
return String(value);
|
||||
}
|
||||
try {
|
||||
return JSON.stringify(value).replace(/\r?\n/g, "\\n");
|
||||
} catch {
|
||||
return String(value);
|
||||
}
|
||||
}
|
||||
|
||||
function formatFields(fields?: Record<string, unknown>): string {
|
||||
if (!fields) {
|
||||
return "";
|
||||
}
|
||||
const parts = Object.entries(fields)
|
||||
.filter(([, value]) => value !== undefined && value !== null && sanitizeFieldValue(value) !== "")
|
||||
.map(([key, value]) => `${key}=${sanitizeFieldValue(value)}`);
|
||||
return parts.length > 0 ? ` | ${parts.join(" | ")}` : "";
|
||||
}
|
||||
|
||||
function getItemLogFilePathFromNormalized(normalized: string): string | null {
|
||||
if (!normalized || !itemLogsDir) {
|
||||
return null;
|
||||
}
|
||||
const existing = knownLogPaths.get(normalized);
|
||||
if (existing) {
|
||||
return existing;
|
||||
}
|
||||
const logPath = path.join(itemLogsDir, `item_${normalized}.txt`);
|
||||
knownLogPaths.set(normalized, logPath);
|
||||
return logPath;
|
||||
}
|
||||
|
||||
function getItemLogFilePath(itemId: string): string | null {
|
||||
return getItemLogFilePathFromNormalized(normalizeItemId(itemId));
|
||||
}
|
||||
|
||||
function flushPending(): void {
|
||||
for (const [itemId, lines] of pendingLinesByItem.entries()) {
|
||||
if (lines.length === 0) {
|
||||
continue;
|
||||
}
|
||||
const logPath = getItemLogFilePathFromNormalized(itemId);
|
||||
if (!logPath) {
|
||||
continue;
|
||||
}
|
||||
const chunk = lines.join("");
|
||||
pendingLinesByItem.set(itemId, []);
|
||||
try {
|
||||
fs.appendFileSync(logPath, chunk, "utf8");
|
||||
} catch {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function scheduleFlush(): void {
|
||||
if (flushTimer) {
|
||||
return;
|
||||
}
|
||||
flushTimer = setTimeout(() => {
|
||||
flushTimer = null;
|
||||
flushPending();
|
||||
}, ITEM_LOG_FLUSH_INTERVAL_MS);
|
||||
}
|
||||
|
||||
async function cleanupOldItemLogs(dir: string): Promise<void> {
|
||||
try {
|
||||
const files = await fs.promises.readdir(dir);
|
||||
const cutoff = Date.now() - ITEM_LOG_RETENTION_DAYS * 24 * 60 * 60 * 1000;
|
||||
for (const file of files) {
|
||||
if (!file.startsWith("item_") || !file.endsWith(".txt")) {
|
||||
continue;
|
||||
}
|
||||
const filePath = path.join(dir, file);
|
||||
try {
|
||||
const stat = await fs.promises.stat(filePath);
|
||||
if (stat.mtimeMs < cutoff) {
|
||||
await fs.promises.unlink(filePath);
|
||||
}
|
||||
} catch {
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
}
|
||||
}
|
||||
|
||||
function appendLine(itemId: string, line: string): void {
|
||||
const normalized = normalizeItemId(itemId);
|
||||
if (!normalized) {
|
||||
return;
|
||||
}
|
||||
const lines = pendingLinesByItem.get(normalized) || [];
|
||||
lines.push(line);
|
||||
pendingLinesByItem.set(normalized, lines);
|
||||
scheduleFlush();
|
||||
}
|
||||
|
||||
export function initItemLogs(baseDir: string): void {
|
||||
itemLogsDir = path.join(baseDir, "item-logs");
|
||||
try {
|
||||
fs.mkdirSync(itemLogsDir, { recursive: true });
|
||||
} catch {
|
||||
itemLogsDir = null;
|
||||
return;
|
||||
}
|
||||
void cleanupOldItemLogs(itemLogsDir);
|
||||
}
|
||||
|
||||
export function ensureItemLog(meta: ItemLogMeta): string | null {
|
||||
const normalizedItemId = normalizeItemId(meta.itemId);
|
||||
const logPath = getItemLogFilePath(meta.itemId);
|
||||
if (!logPath) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
fs.mkdirSync(path.dirname(logPath), { recursive: true });
|
||||
if (!fs.existsSync(logPath)) {
|
||||
fs.writeFileSync(logPath, "", "utf8");
|
||||
}
|
||||
if (!initializedThisProcess.has(normalizedItemId)) {
|
||||
initializedThisProcess.add(normalizedItemId);
|
||||
const startedAt = logTimestamp();
|
||||
fs.appendFileSync(
|
||||
logPath,
|
||||
`=== Item-Log Start: ${startedAt} | itemId=${sanitizeFieldValue(String(meta.itemId || ""))} | logKey=${normalizedItemId} | fileName=${sanitizeFieldValue(meta.fileName)} ===\n`,
|
||||
"utf8"
|
||||
);
|
||||
fs.appendFileSync(
|
||||
logPath,
|
||||
`${logTimestamp()} [INFO] Item-Kontext initialisiert${formatFields({
|
||||
packageId: meta.packageId,
|
||||
packageName: meta.packageName,
|
||||
fileName: meta.fileName,
|
||||
targetPath: meta.targetPath
|
||||
})}\n`,
|
||||
"utf8"
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
return logPath;
|
||||
}
|
||||
|
||||
export function logItemEvent(
|
||||
itemId: string,
|
||||
level: ItemLogLevel,
|
||||
message: string,
|
||||
fields?: Record<string, unknown>
|
||||
): void {
|
||||
const logPath = getItemLogFilePath(itemId);
|
||||
if (!logPath) {
|
||||
return;
|
||||
}
|
||||
const line = `${logTimestamp()} [${level}] ${message}${formatFields(fields)}\n`;
|
||||
appendLine(itemId, line);
|
||||
}
|
||||
|
||||
export function getItemLogPath(itemId: string): string | null {
|
||||
const logPath = getItemLogFilePath(itemId);
|
||||
if (!logPath) {
|
||||
return null;
|
||||
}
|
||||
return fs.existsSync(logPath) ? logPath : null;
|
||||
}
|
||||
|
||||
export function shutdownItemLogs(): void {
|
||||
if (flushTimer) {
|
||||
clearTimeout(flushTimer);
|
||||
flushTimer = null;
|
||||
}
|
||||
flushPending();
|
||||
for (const itemId of knownLogPaths.keys()) {
|
||||
const logPath = getItemLogFilePathFromNormalized(itemId);
|
||||
if (!logPath) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
fs.appendFileSync(logPath, `=== Item-Log Ende: ${logTimestamp()} ===\n`, "utf8");
|
||||
} catch {
|
||||
}
|
||||
}
|
||||
pendingLinesByItem.clear();
|
||||
knownLogPaths.clear();
|
||||
initializedThisProcess.clear();
|
||||
itemLogsDir = null;
|
||||
}
|
||||
|
||||
@ -1,116 +1,116 @@
|
||||
import type { ParsedPackageInput, UiSnapshot } from "../shared/types";
|
||||
import { sanitizeFilename } from "./utils";
|
||||
|
||||
export type LinkExportSelection = {
|
||||
packages: ParsedPackageInput[];
|
||||
packageCount: number;
|
||||
linkCount: number;
|
||||
defaultFileName: string;
|
||||
};
|
||||
|
||||
function formatTimestampForFileName(date: Date): string {
|
||||
const y = date.getFullYear();
|
||||
const mo = String(date.getMonth() + 1).padStart(2, "0");
|
||||
const d = String(date.getDate()).padStart(2, "0");
|
||||
const h = String(date.getHours()).padStart(2, "0");
|
||||
const mi = String(date.getMinutes()).padStart(2, "0");
|
||||
const s = String(date.getSeconds()).padStart(2, "0");
|
||||
return `${y}-${mo}-${d}_${h}-${mi}-${s}`;
|
||||
}
|
||||
|
||||
function buildDefaultFileName(packages: ParsedPackageInput[]): string {
|
||||
if (packages.length === 1) {
|
||||
const only = packages[0];
|
||||
if (only.links.length === 1) {
|
||||
const itemName = sanitizeFilename(only.fileNames?.[0] || only.name || "link-export");
|
||||
return `${itemName}.txt`;
|
||||
}
|
||||
return `${sanitizeFilename(only.name || "paket-export")}.txt`;
|
||||
}
|
||||
return `rd-link-export-${formatTimestampForFileName(new Date())}.txt`;
|
||||
}
|
||||
|
||||
export function buildLinkExportSelection(snapshot: UiSnapshot, packageIds: string[], itemIds: string[]): LinkExportSelection {
|
||||
const selectedPackageIds = new Set(packageIds);
|
||||
const selectedItemIds = new Set(itemIds);
|
||||
const packages: ParsedPackageInput[] = [];
|
||||
|
||||
for (const packageId of snapshot.session.packageOrder) {
|
||||
const pkg = snapshot.session.packages[packageId];
|
||||
if (!pkg) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const useWholePackage = selectedPackageIds.has(packageId);
|
||||
const relevantItemIds = useWholePackage
|
||||
? pkg.itemIds
|
||||
: pkg.itemIds.filter((itemId) => selectedItemIds.has(itemId));
|
||||
|
||||
if (relevantItemIds.length === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const links: string[] = [];
|
||||
const fileNames: string[] = [];
|
||||
for (const itemId of relevantItemIds) {
|
||||
const item = snapshot.session.items[itemId];
|
||||
if (!item || !String(item.url || "").trim()) {
|
||||
continue;
|
||||
}
|
||||
links.push(String(item.url).trim());
|
||||
const rawFileName = String(item.fileName || "").trim();
|
||||
fileNames.push(rawFileName ? sanitizeFilename(rawFileName) : "");
|
||||
}
|
||||
|
||||
if (links.length === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const exportEntry: ParsedPackageInput = {
|
||||
name: sanitizeFilename(pkg.name || "Paket"),
|
||||
links
|
||||
};
|
||||
if (fileNames.some((fileName) => fileName.length > 0)) {
|
||||
exportEntry.fileNames = fileNames;
|
||||
}
|
||||
packages.push(exportEntry);
|
||||
}
|
||||
|
||||
const linkCount = packages.reduce((sum, pkg) => sum + pkg.links.length, 0);
|
||||
return {
|
||||
packages,
|
||||
packageCount: packages.length,
|
||||
linkCount,
|
||||
defaultFileName: buildDefaultFileName(packages)
|
||||
};
|
||||
}
|
||||
|
||||
export function serializeLinkExportText(packages: ParsedPackageInput[]): string {
|
||||
const lines: string[] = [
|
||||
"# rd-link-export: 1",
|
||||
"# Re-import in Real-Debrid-Downloader keeps package names and optional file names.",
|
||||
""
|
||||
];
|
||||
|
||||
for (const pkg of packages) {
|
||||
if (!pkg || !pkg.name || !Array.isArray(pkg.links) || pkg.links.length === 0) {
|
||||
continue;
|
||||
}
|
||||
lines.push(`# package: ${sanitizeFilename(pkg.name)}`);
|
||||
for (let index = 0; index < pkg.links.length; index += 1) {
|
||||
const link = String(pkg.links[index] || "").trim();
|
||||
if (!link) {
|
||||
continue;
|
||||
}
|
||||
const rawFileName = String(pkg.fileNames?.[index] || "").trim();
|
||||
const fileName = rawFileName ? sanitizeFilename(rawFileName) : "";
|
||||
if (fileName) {
|
||||
lines.push(`# file: ${fileName}`);
|
||||
}
|
||||
lines.push(link);
|
||||
}
|
||||
lines.push("");
|
||||
}
|
||||
|
||||
return `${lines.join("\n").trim()}\n`;
|
||||
}
|
||||
import type { ParsedPackageInput, UiSnapshot } from "../shared/types";
|
||||
import { sanitizeFilename } from "./utils";
|
||||
|
||||
export type LinkExportSelection = {
|
||||
packages: ParsedPackageInput[];
|
||||
packageCount: number;
|
||||
linkCount: number;
|
||||
defaultFileName: string;
|
||||
};
|
||||
|
||||
function formatTimestampForFileName(date: Date): string {
|
||||
const y = date.getFullYear();
|
||||
const mo = String(date.getMonth() + 1).padStart(2, "0");
|
||||
const d = String(date.getDate()).padStart(2, "0");
|
||||
const h = String(date.getHours()).padStart(2, "0");
|
||||
const mi = String(date.getMinutes()).padStart(2, "0");
|
||||
const s = String(date.getSeconds()).padStart(2, "0");
|
||||
return `${y}-${mo}-${d}_${h}-${mi}-${s}`;
|
||||
}
|
||||
|
||||
function buildDefaultFileName(packages: ParsedPackageInput[]): string {
|
||||
if (packages.length === 1) {
|
||||
const only = packages[0];
|
||||
if (only.links.length === 1) {
|
||||
const itemName = sanitizeFilename(only.fileNames?.[0] || only.name || "link-export");
|
||||
return `${itemName}.txt`;
|
||||
}
|
||||
return `${sanitizeFilename(only.name || "paket-export")}.txt`;
|
||||
}
|
||||
return `rd-link-export-${formatTimestampForFileName(new Date())}.txt`;
|
||||
}
|
||||
|
||||
export function buildLinkExportSelection(snapshot: UiSnapshot, packageIds: string[], itemIds: string[]): LinkExportSelection {
|
||||
const selectedPackageIds = new Set(packageIds);
|
||||
const selectedItemIds = new Set(itemIds);
|
||||
const packages: ParsedPackageInput[] = [];
|
||||
|
||||
for (const packageId of snapshot.session.packageOrder) {
|
||||
const pkg = snapshot.session.packages[packageId];
|
||||
if (!pkg) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const useWholePackage = selectedPackageIds.has(packageId);
|
||||
const relevantItemIds = useWholePackage
|
||||
? pkg.itemIds
|
||||
: pkg.itemIds.filter((itemId) => selectedItemIds.has(itemId));
|
||||
|
||||
if (relevantItemIds.length === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const links: string[] = [];
|
||||
const fileNames: string[] = [];
|
||||
for (const itemId of relevantItemIds) {
|
||||
const item = snapshot.session.items[itemId];
|
||||
if (!item || !String(item.url || "").trim()) {
|
||||
continue;
|
||||
}
|
||||
links.push(String(item.url).trim());
|
||||
const rawFileName = String(item.fileName || "").trim();
|
||||
fileNames.push(rawFileName ? sanitizeFilename(rawFileName) : "");
|
||||
}
|
||||
|
||||
if (links.length === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const exportEntry: ParsedPackageInput = {
|
||||
name: sanitizeFilename(pkg.name || "Paket"),
|
||||
links
|
||||
};
|
||||
if (fileNames.some((fileName) => fileName.length > 0)) {
|
||||
exportEntry.fileNames = fileNames;
|
||||
}
|
||||
packages.push(exportEntry);
|
||||
}
|
||||
|
||||
const linkCount = packages.reduce((sum, pkg) => sum + pkg.links.length, 0);
|
||||
return {
|
||||
packages,
|
||||
packageCount: packages.length,
|
||||
linkCount,
|
||||
defaultFileName: buildDefaultFileName(packages)
|
||||
};
|
||||
}
|
||||
|
||||
export function serializeLinkExportText(packages: ParsedPackageInput[]): string {
|
||||
const lines: string[] = [
|
||||
"# rd-link-export: 1",
|
||||
"# Re-import in Real-Debrid-Downloader keeps package names and optional file names.",
|
||||
""
|
||||
];
|
||||
|
||||
for (const pkg of packages) {
|
||||
if (!pkg || !pkg.name || !Array.isArray(pkg.links) || pkg.links.length === 0) {
|
||||
continue;
|
||||
}
|
||||
lines.push(`# package: ${sanitizeFilename(pkg.name)}`);
|
||||
for (let index = 0; index < pkg.links.length; index += 1) {
|
||||
const link = String(pkg.links[index] || "").trim();
|
||||
if (!link) {
|
||||
continue;
|
||||
}
|
||||
const rawFileName = String(pkg.fileNames?.[index] || "").trim();
|
||||
const fileName = rawFileName ? sanitizeFilename(rawFileName) : "";
|
||||
if (fileName) {
|
||||
lines.push(`# file: ${fileName}`);
|
||||
}
|
||||
lines.push(link);
|
||||
}
|
||||
lines.push("");
|
||||
}
|
||||
|
||||
return `${lines.join("\n").trim()}\n`;
|
||||
}
|
||||
|
||||
@ -1,42 +1,42 @@
|
||||
import { ParsedPackageInput } from "../shared/types";
|
||||
import { inferPackageNameFromLinks, parsePackagesFromLinksText, sanitizeFilename, uniquePreserveOrder } from "./utils";
|
||||
|
||||
export function mergePackageInputs(packages: ParsedPackageInput[]): ParsedPackageInput[] {
|
||||
const grouped = new Map<string, { links: string[]; fileNameByLink: Map<string, string> }>();
|
||||
for (const pkg of packages) {
|
||||
const name = sanitizeFilename(pkg.name || inferPackageNameFromLinks(pkg.links));
|
||||
const current = grouped.get(name) ?? { links: [], fileNameByLink: new Map<string, string>() };
|
||||
for (let index = 0; index < pkg.links.length; index += 1) {
|
||||
const link = String(pkg.links[index] || "").trim();
|
||||
if (!link) {
|
||||
continue;
|
||||
}
|
||||
if (!current.links.includes(link)) {
|
||||
current.links.push(link);
|
||||
}
|
||||
const rawFileName = String(pkg.fileNames?.[index] || "").trim();
|
||||
const fileName = rawFileName ? sanitizeFilename(rawFileName) : "";
|
||||
if (fileName && !current.fileNameByLink.has(link)) {
|
||||
current.fileNameByLink.set(link, fileName);
|
||||
}
|
||||
}
|
||||
grouped.set(name, current);
|
||||
}
|
||||
return Array.from(grouped.entries()).map(([name, entry]) => {
|
||||
const links = uniquePreserveOrder(entry.links);
|
||||
const fileNames = links.map((link) => entry.fileNameByLink.get(link) || "");
|
||||
return {
|
||||
name,
|
||||
links,
|
||||
...(fileNames.some((fileName) => fileName.length > 0) ? { fileNames } : {})
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export function parseCollectorInput(rawText: string, packageName = ""): ParsedPackageInput[] {
|
||||
const parsed = parsePackagesFromLinksText(rawText, packageName);
|
||||
if (parsed.length === 0) {
|
||||
return [];
|
||||
}
|
||||
return mergePackageInputs(parsed);
|
||||
}
|
||||
import { ParsedPackageInput } from "../shared/types";
|
||||
import { inferPackageNameFromLinks, parsePackagesFromLinksText, sanitizeFilename, uniquePreserveOrder } from "./utils";
|
||||
|
||||
export function mergePackageInputs(packages: ParsedPackageInput[]): ParsedPackageInput[] {
|
||||
const grouped = new Map<string, { links: string[]; fileNameByLink: Map<string, string> }>();
|
||||
for (const pkg of packages) {
|
||||
const name = sanitizeFilename(pkg.name || inferPackageNameFromLinks(pkg.links));
|
||||
const current = grouped.get(name) ?? { links: [], fileNameByLink: new Map<string, string>() };
|
||||
for (let index = 0; index < pkg.links.length; index += 1) {
|
||||
const link = String(pkg.links[index] || "").trim();
|
||||
if (!link) {
|
||||
continue;
|
||||
}
|
||||
if (!current.links.includes(link)) {
|
||||
current.links.push(link);
|
||||
}
|
||||
const rawFileName = String(pkg.fileNames?.[index] || "").trim();
|
||||
const fileName = rawFileName ? sanitizeFilename(rawFileName) : "";
|
||||
if (fileName && !current.fileNameByLink.has(link)) {
|
||||
current.fileNameByLink.set(link, fileName);
|
||||
}
|
||||
}
|
||||
grouped.set(name, current);
|
||||
}
|
||||
return Array.from(grouped.entries()).map(([name, entry]) => {
|
||||
const links = uniquePreserveOrder(entry.links);
|
||||
const fileNames = links.map((link) => entry.fileNameByLink.get(link) || "");
|
||||
return {
|
||||
name,
|
||||
links,
|
||||
...(fileNames.some((fileName) => fileName.length > 0) ? { fileNames } : {})
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export function parseCollectorInput(rawText: string, packageName = ""): ParsedPackageInput[] {
|
||||
const parsed = parsePackagesFromLinksText(rawText, packageName);
|
||||
if (parsed.length === 0) {
|
||||
return [];
|
||||
}
|
||||
return mergePackageInputs(parsed);
|
||||
}
|
||||
|
||||
@ -1,11 +1,11 @@
|
||||
export function logTimestamp(date: Date = new Date()): string {
|
||||
const pad = (value: number, length = 2): string => String(value).padStart(length, "0");
|
||||
const offsetMinutes = -date.getTimezoneOffset();
|
||||
const sign = offsetMinutes >= 0 ? "+" : "-";
|
||||
const absOffset = Math.abs(offsetMinutes);
|
||||
const offset = `${sign}${pad(Math.floor(absOffset / 60))}:${pad(absOffset % 60)}`;
|
||||
return (
|
||||
`${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}` +
|
||||
`T${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}.${pad(date.getMilliseconds(), 3)}${offset}`
|
||||
);
|
||||
}
|
||||
export function logTimestamp(date: Date = new Date()): string {
|
||||
const pad = (value: number, length = 2): string => String(value).padStart(length, "0");
|
||||
const offsetMinutes = -date.getTimezoneOffset();
|
||||
const sign = offsetMinutes >= 0 ? "+" : "-";
|
||||
const absOffset = Math.abs(offsetMinutes);
|
||||
const offset = `${sign}${pad(Math.floor(absOffset / 60))}:${pad(absOffset % 60)}`;
|
||||
return (
|
||||
`${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}` +
|
||||
`T${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}.${pad(date.getMilliseconds(), 3)}${offset}`
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,284 +1,284 @@
|
||||
import fs from "node:fs";
|
||||
import { logTimestamp } from "./log-timestamp";
|
||||
import { recordRecentError } from "./error-ring";
|
||||
import path from "node:path";
|
||||
|
||||
export function isDebugFlagEnabled(value: string | undefined): boolean {
|
||||
if (!value) {
|
||||
return false;
|
||||
}
|
||||
return /^(1|true|yes|on)$/i.test(value.trim());
|
||||
}
|
||||
|
||||
// Read once at startup. Enabling verbose DEBUG logging on the (unattended) server
|
||||
// is a deliberate support action that requires a restart — the runtime-toggleable
|
||||
// channel is the trace log, not this.
|
||||
const DEBUG_ENABLED = isDebugFlagEnabled(process.env.RD_DEBUG);
|
||||
|
||||
export function isDebugLoggingEnabled(): boolean {
|
||||
return DEBUG_ENABLED;
|
||||
}
|
||||
|
||||
let logFilePath = path.resolve(process.cwd(), "rd_downloader.log");
|
||||
let fallbackLogFilePath: string | null = null;
|
||||
const LOG_FLUSH_INTERVAL_MS = 120;
|
||||
const LOG_BUFFER_LIMIT_CHARS = 1_000_000;
|
||||
const LOG_MAX_FILE_BYTES = 10 * 1024 * 1024;
|
||||
const rotateCheckAtByFile = new Map<string, number>();
|
||||
|
||||
type LogListener = (line: string) => void;
|
||||
const logListeners = new Set<LogListener>();
|
||||
let legacyLogListener: LogListener | null = null;
|
||||
|
||||
let pendingLines: string[] = [];
|
||||
let pendingChars = 0;
|
||||
let flushTimer: NodeJS.Timeout | null = null;
|
||||
let flushInFlight = false;
|
||||
let exitHookAttached = false;
|
||||
|
||||
export function setLogListener(listener: LogListener | null): void {
|
||||
if (legacyLogListener) {
|
||||
logListeners.delete(legacyLogListener);
|
||||
}
|
||||
legacyLogListener = listener;
|
||||
if (listener) {
|
||||
logListeners.add(listener);
|
||||
}
|
||||
}
|
||||
|
||||
export function addLogListener(listener: LogListener): void {
|
||||
logListeners.add(listener);
|
||||
}
|
||||
|
||||
export function removeLogListener(listener: LogListener): void {
|
||||
logListeners.delete(listener);
|
||||
if (legacyLogListener === listener) {
|
||||
legacyLogListener = null;
|
||||
}
|
||||
}
|
||||
|
||||
export function configureLogger(baseDir: string): void {
|
||||
logFilePath = path.join(baseDir, "rd_downloader.log");
|
||||
const cwdLogPath = path.resolve(process.cwd(), "rd_downloader.log");
|
||||
fallbackLogFilePath = cwdLogPath === logFilePath ? null : cwdLogPath;
|
||||
}
|
||||
|
||||
function appendLine(filePath: string, line: string): { ok: boolean; errorText: string } {
|
||||
try {
|
||||
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
||||
fs.appendFileSync(filePath, line, "utf8");
|
||||
return { ok: true, errorText: "" };
|
||||
} catch (error) {
|
||||
return { ok: false, errorText: String(error) };
|
||||
}
|
||||
}
|
||||
|
||||
async function appendChunk(filePath: string, chunk: string): Promise<{ ok: boolean; errorText: string }> {
|
||||
try {
|
||||
await fs.promises.mkdir(path.dirname(filePath), { recursive: true });
|
||||
await fs.promises.appendFile(filePath, chunk, "utf8");
|
||||
return { ok: true, errorText: "" };
|
||||
} catch (error) {
|
||||
return { ok: false, errorText: String(error) };
|
||||
}
|
||||
}
|
||||
|
||||
function writeStderr(text: string): void {
|
||||
try {
|
||||
process.stderr.write(text);
|
||||
} catch {
|
||||
}
|
||||
}
|
||||
|
||||
function flushSyncPending(): void {
|
||||
if (pendingLines.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const chunk = pendingLines.join("");
|
||||
pendingLines = [];
|
||||
pendingChars = 0;
|
||||
|
||||
rotateIfNeeded(logFilePath);
|
||||
const primary = appendLine(logFilePath, chunk);
|
||||
if (fallbackLogFilePath) {
|
||||
rotateIfNeeded(fallbackLogFilePath);
|
||||
const fallback = appendLine(fallbackLogFilePath, chunk);
|
||||
if (!primary.ok && !fallback.ok) {
|
||||
writeStderr(`LOGGER write failed (primary+fallback): ${primary.errorText} | ${fallback.errorText}\n`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (!primary.ok) {
|
||||
writeStderr(`LOGGER write failed: ${primary.errorText}\n`);
|
||||
}
|
||||
}
|
||||
|
||||
function scheduleFlush(immediate = false): void {
|
||||
if (flushInFlight) {
|
||||
return;
|
||||
}
|
||||
if (immediate) {
|
||||
if (flushTimer) {
|
||||
clearTimeout(flushTimer);
|
||||
flushTimer = null;
|
||||
}
|
||||
void flushAsync();
|
||||
return;
|
||||
}
|
||||
if (flushTimer) {
|
||||
return;
|
||||
}
|
||||
flushTimer = setTimeout(() => {
|
||||
flushTimer = null;
|
||||
void flushAsync();
|
||||
}, LOG_FLUSH_INTERVAL_MS);
|
||||
}
|
||||
|
||||
function rotateIfNeeded(filePath: string): void {
|
||||
try {
|
||||
const now = Date.now();
|
||||
const lastRotateCheckAt = rotateCheckAtByFile.get(filePath) || 0;
|
||||
if (now - lastRotateCheckAt < 60_000) {
|
||||
return;
|
||||
}
|
||||
rotateCheckAtByFile.set(filePath, now);
|
||||
const stat = fs.statSync(filePath);
|
||||
if (stat.size < LOG_MAX_FILE_BYTES) {
|
||||
return;
|
||||
}
|
||||
const backup = `${filePath}.old`;
|
||||
try {
|
||||
fs.rmSync(backup, { force: true });
|
||||
} catch {
|
||||
}
|
||||
fs.renameSync(filePath, backup);
|
||||
} catch {
|
||||
}
|
||||
}
|
||||
|
||||
async function rotateIfNeededAsync(filePath: string): Promise<void> {
|
||||
try {
|
||||
const now = Date.now();
|
||||
const lastRotateCheckAt = rotateCheckAtByFile.get(filePath) || 0;
|
||||
if (now - lastRotateCheckAt < 60_000) {
|
||||
return;
|
||||
}
|
||||
rotateCheckAtByFile.set(filePath, now);
|
||||
const stat = await fs.promises.stat(filePath);
|
||||
if (stat.size < LOG_MAX_FILE_BYTES) {
|
||||
return;
|
||||
}
|
||||
const backup = `${filePath}.old`;
|
||||
await fs.promises.rm(backup, { force: true }).catch(() => {});
|
||||
await fs.promises.rename(filePath, backup);
|
||||
} catch {
|
||||
}
|
||||
}
|
||||
|
||||
async function flushAsync(): Promise<void> {
|
||||
if (flushInFlight || pendingLines.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
flushInFlight = true;
|
||||
// Move (not copy) the pending lines out and take ownership. A concurrent write()
|
||||
// during the await below pushes new lines AND can trim the 1MB cap from the FRONT
|
||||
// of pendingLines; the old count-based removal (pendingLines.slice(snapshot.length))
|
||||
// then sliced off the wrong lines and dropped unwritten ones. Resetting the buffer
|
||||
// here means await-time writes queue independently and nothing desyncs.
|
||||
const linesSnapshot = pendingLines;
|
||||
pendingLines = [];
|
||||
pendingChars = 0;
|
||||
const chunk = linesSnapshot.join("");
|
||||
|
||||
try {
|
||||
await rotateIfNeededAsync(logFilePath);
|
||||
const primary = await appendChunk(logFilePath, chunk);
|
||||
let wroteAny = primary.ok;
|
||||
if (fallbackLogFilePath) {
|
||||
await rotateIfNeededAsync(fallbackLogFilePath);
|
||||
const fallback = await appendChunk(fallbackLogFilePath, chunk);
|
||||
wroteAny = wroteAny || fallback.ok;
|
||||
if (!primary.ok && !fallback.ok) {
|
||||
writeStderr(`LOGGER write failed (primary+fallback): ${primary.errorText} | ${fallback.errorText}\n`);
|
||||
}
|
||||
} else if (!primary.ok) {
|
||||
writeStderr(`LOGGER write failed: ${primary.errorText}\n`);
|
||||
}
|
||||
if (!wroteAny) {
|
||||
// Write failed: requeue the unwritten lines AHEAD of anything that arrived
|
||||
// during the await (preserve order), then re-apply the buffer cap so a
|
||||
// persistent write failure cannot grow the buffer without bound.
|
||||
pendingLines = linesSnapshot.concat(pendingLines);
|
||||
pendingChars += chunk.length;
|
||||
while (pendingChars > LOG_BUFFER_LIMIT_CHARS && pendingLines.length > 1) {
|
||||
const removed = pendingLines.shift();
|
||||
if (!removed) {
|
||||
break;
|
||||
}
|
||||
pendingChars = Math.max(0, pendingChars - removed.length);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
flushInFlight = false;
|
||||
if (pendingLines.length > 0) {
|
||||
scheduleFlush();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function ensureExitHook(): void {
|
||||
if (exitHookAttached) {
|
||||
return;
|
||||
}
|
||||
exitHookAttached = true;
|
||||
process.once("beforeExit", flushSyncPending);
|
||||
process.once("exit", flushSyncPending);
|
||||
}
|
||||
|
||||
function write(level: "DEBUG" | "INFO" | "WARN" | "ERROR", message: string): void {
|
||||
ensureExitHook();
|
||||
const ts = logTimestamp();
|
||||
const line = `${ts} [${level}] ${message}\n`;
|
||||
pendingLines.push(line);
|
||||
pendingChars += line.length;
|
||||
|
||||
// Single chokepoint: every WARN/ERROR also lands in the in-memory ring so
|
||||
// "what failed recently" is answerable even after the file rotates.
|
||||
if (level === "ERROR" || level === "WARN") {
|
||||
recordRecentError(level, message, ts);
|
||||
}
|
||||
|
||||
for (const listener of logListeners) {
|
||||
try { listener(line); } catch { }
|
||||
}
|
||||
|
||||
while (pendingChars > LOG_BUFFER_LIMIT_CHARS && pendingLines.length > 1) {
|
||||
const removed = pendingLines.shift();
|
||||
if (!removed) {
|
||||
break;
|
||||
}
|
||||
pendingChars = Math.max(0, pendingChars - removed.length);
|
||||
}
|
||||
|
||||
if (level === "ERROR") {
|
||||
scheduleFlush(true);
|
||||
return;
|
||||
}
|
||||
scheduleFlush();
|
||||
}
|
||||
|
||||
export const logger = {
|
||||
// Gated to a no-op when RD_DEBUG is unset so verbose call sites cost nothing
|
||||
// (no formatting, no allocation) in the normal/production path.
|
||||
debug: DEBUG_ENABLED ? (msg: string): void => write("DEBUG", msg) : (_msg: string): void => {},
|
||||
info: (msg: string): void => write("INFO", msg),
|
||||
warn: (msg: string): void => write("WARN", msg),
|
||||
error: (msg: string): void => write("ERROR", msg)
|
||||
};
|
||||
|
||||
export function getLogFilePath(): string {
|
||||
return logFilePath;
|
||||
}
|
||||
import fs from "node:fs";
|
||||
import { logTimestamp } from "./log-timestamp";
|
||||
import { recordRecentError } from "./error-ring";
|
||||
import path from "node:path";
|
||||
|
||||
export function isDebugFlagEnabled(value: string | undefined): boolean {
|
||||
if (!value) {
|
||||
return false;
|
||||
}
|
||||
return /^(1|true|yes|on)$/i.test(value.trim());
|
||||
}
|
||||
|
||||
// Read once at startup. Enabling verbose DEBUG logging on the (unattended) server
|
||||
// is a deliberate support action that requires a restart — the runtime-toggleable
|
||||
// channel is the trace log, not this.
|
||||
const DEBUG_ENABLED = isDebugFlagEnabled(process.env.RD_DEBUG);
|
||||
|
||||
export function isDebugLoggingEnabled(): boolean {
|
||||
return DEBUG_ENABLED;
|
||||
}
|
||||
|
||||
let logFilePath = path.resolve(process.cwd(), "rd_downloader.log");
|
||||
let fallbackLogFilePath: string | null = null;
|
||||
const LOG_FLUSH_INTERVAL_MS = 120;
|
||||
const LOG_BUFFER_LIMIT_CHARS = 1_000_000;
|
||||
const LOG_MAX_FILE_BYTES = 10 * 1024 * 1024;
|
||||
const rotateCheckAtByFile = new Map<string, number>();
|
||||
|
||||
type LogListener = (line: string) => void;
|
||||
const logListeners = new Set<LogListener>();
|
||||
let legacyLogListener: LogListener | null = null;
|
||||
|
||||
let pendingLines: string[] = [];
|
||||
let pendingChars = 0;
|
||||
let flushTimer: NodeJS.Timeout | null = null;
|
||||
let flushInFlight = false;
|
||||
let exitHookAttached = false;
|
||||
|
||||
export function setLogListener(listener: LogListener | null): void {
|
||||
if (legacyLogListener) {
|
||||
logListeners.delete(legacyLogListener);
|
||||
}
|
||||
legacyLogListener = listener;
|
||||
if (listener) {
|
||||
logListeners.add(listener);
|
||||
}
|
||||
}
|
||||
|
||||
export function addLogListener(listener: LogListener): void {
|
||||
logListeners.add(listener);
|
||||
}
|
||||
|
||||
export function removeLogListener(listener: LogListener): void {
|
||||
logListeners.delete(listener);
|
||||
if (legacyLogListener === listener) {
|
||||
legacyLogListener = null;
|
||||
}
|
||||
}
|
||||
|
||||
export function configureLogger(baseDir: string): void {
|
||||
logFilePath = path.join(baseDir, "rd_downloader.log");
|
||||
const cwdLogPath = path.resolve(process.cwd(), "rd_downloader.log");
|
||||
fallbackLogFilePath = cwdLogPath === logFilePath ? null : cwdLogPath;
|
||||
}
|
||||
|
||||
function appendLine(filePath: string, line: string): { ok: boolean; errorText: string } {
|
||||
try {
|
||||
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
||||
fs.appendFileSync(filePath, line, "utf8");
|
||||
return { ok: true, errorText: "" };
|
||||
} catch (error) {
|
||||
return { ok: false, errorText: String(error) };
|
||||
}
|
||||
}
|
||||
|
||||
async function appendChunk(filePath: string, chunk: string): Promise<{ ok: boolean; errorText: string }> {
|
||||
try {
|
||||
await fs.promises.mkdir(path.dirname(filePath), { recursive: true });
|
||||
await fs.promises.appendFile(filePath, chunk, "utf8");
|
||||
return { ok: true, errorText: "" };
|
||||
} catch (error) {
|
||||
return { ok: false, errorText: String(error) };
|
||||
}
|
||||
}
|
||||
|
||||
function writeStderr(text: string): void {
|
||||
try {
|
||||
process.stderr.write(text);
|
||||
} catch {
|
||||
}
|
||||
}
|
||||
|
||||
function flushSyncPending(): void {
|
||||
if (pendingLines.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const chunk = pendingLines.join("");
|
||||
pendingLines = [];
|
||||
pendingChars = 0;
|
||||
|
||||
rotateIfNeeded(logFilePath);
|
||||
const primary = appendLine(logFilePath, chunk);
|
||||
if (fallbackLogFilePath) {
|
||||
rotateIfNeeded(fallbackLogFilePath);
|
||||
const fallback = appendLine(fallbackLogFilePath, chunk);
|
||||
if (!primary.ok && !fallback.ok) {
|
||||
writeStderr(`LOGGER write failed (primary+fallback): ${primary.errorText} | ${fallback.errorText}\n`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (!primary.ok) {
|
||||
writeStderr(`LOGGER write failed: ${primary.errorText}\n`);
|
||||
}
|
||||
}
|
||||
|
||||
function scheduleFlush(immediate = false): void {
|
||||
if (flushInFlight) {
|
||||
return;
|
||||
}
|
||||
if (immediate) {
|
||||
if (flushTimer) {
|
||||
clearTimeout(flushTimer);
|
||||
flushTimer = null;
|
||||
}
|
||||
void flushAsync();
|
||||
return;
|
||||
}
|
||||
if (flushTimer) {
|
||||
return;
|
||||
}
|
||||
flushTimer = setTimeout(() => {
|
||||
flushTimer = null;
|
||||
void flushAsync();
|
||||
}, LOG_FLUSH_INTERVAL_MS);
|
||||
}
|
||||
|
||||
function rotateIfNeeded(filePath: string): void {
|
||||
try {
|
||||
const now = Date.now();
|
||||
const lastRotateCheckAt = rotateCheckAtByFile.get(filePath) || 0;
|
||||
if (now - lastRotateCheckAt < 60_000) {
|
||||
return;
|
||||
}
|
||||
rotateCheckAtByFile.set(filePath, now);
|
||||
const stat = fs.statSync(filePath);
|
||||
if (stat.size < LOG_MAX_FILE_BYTES) {
|
||||
return;
|
||||
}
|
||||
const backup = `${filePath}.old`;
|
||||
try {
|
||||
fs.rmSync(backup, { force: true });
|
||||
} catch {
|
||||
}
|
||||
fs.renameSync(filePath, backup);
|
||||
} catch {
|
||||
}
|
||||
}
|
||||
|
||||
async function rotateIfNeededAsync(filePath: string): Promise<void> {
|
||||
try {
|
||||
const now = Date.now();
|
||||
const lastRotateCheckAt = rotateCheckAtByFile.get(filePath) || 0;
|
||||
if (now - lastRotateCheckAt < 60_000) {
|
||||
return;
|
||||
}
|
||||
rotateCheckAtByFile.set(filePath, now);
|
||||
const stat = await fs.promises.stat(filePath);
|
||||
if (stat.size < LOG_MAX_FILE_BYTES) {
|
||||
return;
|
||||
}
|
||||
const backup = `${filePath}.old`;
|
||||
await fs.promises.rm(backup, { force: true }).catch(() => {});
|
||||
await fs.promises.rename(filePath, backup);
|
||||
} catch {
|
||||
}
|
||||
}
|
||||
|
||||
async function flushAsync(): Promise<void> {
|
||||
if (flushInFlight || pendingLines.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
flushInFlight = true;
|
||||
// Move (not copy) the pending lines out and take ownership. A concurrent write()
|
||||
// during the await below pushes new lines AND can trim the 1MB cap from the FRONT
|
||||
// of pendingLines; the old count-based removal (pendingLines.slice(snapshot.length))
|
||||
// then sliced off the wrong lines and dropped unwritten ones. Resetting the buffer
|
||||
// here means await-time writes queue independently and nothing desyncs.
|
||||
const linesSnapshot = pendingLines;
|
||||
pendingLines = [];
|
||||
pendingChars = 0;
|
||||
const chunk = linesSnapshot.join("");
|
||||
|
||||
try {
|
||||
await rotateIfNeededAsync(logFilePath);
|
||||
const primary = await appendChunk(logFilePath, chunk);
|
||||
let wroteAny = primary.ok;
|
||||
if (fallbackLogFilePath) {
|
||||
await rotateIfNeededAsync(fallbackLogFilePath);
|
||||
const fallback = await appendChunk(fallbackLogFilePath, chunk);
|
||||
wroteAny = wroteAny || fallback.ok;
|
||||
if (!primary.ok && !fallback.ok) {
|
||||
writeStderr(`LOGGER write failed (primary+fallback): ${primary.errorText} | ${fallback.errorText}\n`);
|
||||
}
|
||||
} else if (!primary.ok) {
|
||||
writeStderr(`LOGGER write failed: ${primary.errorText}\n`);
|
||||
}
|
||||
if (!wroteAny) {
|
||||
// Write failed: requeue the unwritten lines AHEAD of anything that arrived
|
||||
// during the await (preserve order), then re-apply the buffer cap so a
|
||||
// persistent write failure cannot grow the buffer without bound.
|
||||
pendingLines = linesSnapshot.concat(pendingLines);
|
||||
pendingChars += chunk.length;
|
||||
while (pendingChars > LOG_BUFFER_LIMIT_CHARS && pendingLines.length > 1) {
|
||||
const removed = pendingLines.shift();
|
||||
if (!removed) {
|
||||
break;
|
||||
}
|
||||
pendingChars = Math.max(0, pendingChars - removed.length);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
flushInFlight = false;
|
||||
if (pendingLines.length > 0) {
|
||||
scheduleFlush();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function ensureExitHook(): void {
|
||||
if (exitHookAttached) {
|
||||
return;
|
||||
}
|
||||
exitHookAttached = true;
|
||||
process.once("beforeExit", flushSyncPending);
|
||||
process.once("exit", flushSyncPending);
|
||||
}
|
||||
|
||||
function write(level: "DEBUG" | "INFO" | "WARN" | "ERROR", message: string): void {
|
||||
ensureExitHook();
|
||||
const ts = logTimestamp();
|
||||
const line = `${ts} [${level}] ${message}\n`;
|
||||
pendingLines.push(line);
|
||||
pendingChars += line.length;
|
||||
|
||||
// Single chokepoint: every WARN/ERROR also lands in the in-memory ring so
|
||||
// "what failed recently" is answerable even after the file rotates.
|
||||
if (level === "ERROR" || level === "WARN") {
|
||||
recordRecentError(level, message, ts);
|
||||
}
|
||||
|
||||
for (const listener of logListeners) {
|
||||
try { listener(line); } catch { }
|
||||
}
|
||||
|
||||
while (pendingChars > LOG_BUFFER_LIMIT_CHARS && pendingLines.length > 1) {
|
||||
const removed = pendingLines.shift();
|
||||
if (!removed) {
|
||||
break;
|
||||
}
|
||||
pendingChars = Math.max(0, pendingChars - removed.length);
|
||||
}
|
||||
|
||||
if (level === "ERROR") {
|
||||
scheduleFlush(true);
|
||||
return;
|
||||
}
|
||||
scheduleFlush();
|
||||
}
|
||||
|
||||
export const logger = {
|
||||
// Gated to a no-op when RD_DEBUG is unset so verbose call sites cost nothing
|
||||
// (no formatting, no allocation) in the normal/production path.
|
||||
debug: DEBUG_ENABLED ? (msg: string): void => write("DEBUG", msg) : (_msg: string): void => {},
|
||||
info: (msg: string): void => write("INFO", msg),
|
||||
warn: (msg: string): void => write("WARN", msg),
|
||||
error: (msg: string): void => write("ERROR", msg)
|
||||
};
|
||||
|
||||
export function getLogFilePath(): string {
|
||||
return logFilePath;
|
||||
}
|
||||
|
||||
1760
src/main/main.ts
1760
src/main/main.ts
File diff suppressed because it is too large
Load Diff
@ -1,129 +1,129 @@
|
||||
import crypto from "node:crypto";
|
||||
|
||||
const MEGA_API_BASE = "https://g.api.mega.co.nz/cs";
|
||||
const MEGA_API_TIMEOUT_MS = 12_000;
|
||||
|
||||
export interface MegaFileInfo {
|
||||
name: string;
|
||||
size: number;
|
||||
}
|
||||
|
||||
const NEW_FORMAT_RE = /^https?:\/\/mega\.(?:nz|co\.nz)\/file\/([A-Za-z0-9_-]+)#([A-Za-z0-9_-]+)/i;
|
||||
const LEGACY_FORMAT_RE = /^https?:\/\/mega\.(?:nz|co\.nz)\/#!([A-Za-z0-9_-]+)!([A-Za-z0-9_-]+)/i;
|
||||
|
||||
export function isMegaFileUrl(url: string): boolean {
|
||||
const s = String(url || "").trim();
|
||||
return NEW_FORMAT_RE.test(s) || LEGACY_FORMAT_RE.test(s);
|
||||
}
|
||||
|
||||
function base64UrlDecode(s: string): Buffer | null {
|
||||
let b64 = String(s || "").trim().replace(/-/g, "+").replace(/_/g, "/");
|
||||
while (b64.length % 4 !== 0) b64 += "=";
|
||||
try {
|
||||
return Buffer.from(b64, "base64");
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export interface ParsedMegaLink {
|
||||
id: string;
|
||||
rawKey: Buffer;
|
||||
}
|
||||
|
||||
export function parseMegaUrl(url: string): ParsedMegaLink | null {
|
||||
const s = String(url || "").trim();
|
||||
const m = NEW_FORMAT_RE.exec(s) || LEGACY_FORMAT_RE.exec(s);
|
||||
if (!m) return null;
|
||||
const id = m[1];
|
||||
const rawKey = base64UrlDecode(m[2]);
|
||||
if (!rawKey || rawKey.length !== 32) return null;
|
||||
return { id, rawKey };
|
||||
}
|
||||
|
||||
export function decryptMegaAttributes(encrypted: Buffer, aesKey: Buffer): Record<string, unknown> | null {
|
||||
if (!Buffer.isBuffer(encrypted) || encrypted.length === 0 || encrypted.length % 16 !== 0) return null;
|
||||
if (!Buffer.isBuffer(aesKey) || aesKey.length !== 16) return null;
|
||||
let plain: Buffer;
|
||||
try {
|
||||
const decipher = crypto.createDecipheriv("aes-128-cbc", aesKey, Buffer.alloc(16));
|
||||
decipher.setAutoPadding(false);
|
||||
plain = Buffer.concat([decipher.update(encrypted), decipher.final()]);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
const text = plain.toString("utf8").replace(/\0+$/, "").trim();
|
||||
if (!text.startsWith("MEGA{")) return null;
|
||||
try {
|
||||
return JSON.parse(text.slice(4));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function withTimeoutSignal(parent: AbortSignal | undefined, timeoutMs: number): AbortSignal {
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort("mega-api-timeout"), timeoutMs);
|
||||
if (parent) {
|
||||
if (parent.aborted) {
|
||||
controller.abort(parent.reason);
|
||||
} else {
|
||||
parent.addEventListener("abort", () => controller.abort(parent.reason), { once: true });
|
||||
}
|
||||
}
|
||||
controller.signal.addEventListener("abort", () => clearTimeout(timer), { once: true });
|
||||
return controller.signal;
|
||||
}
|
||||
|
||||
export async function resolveMegaFilename(
|
||||
url: string,
|
||||
signal?: AbortSignal
|
||||
): Promise<MegaFileInfo | null> {
|
||||
const parsed = parseMegaUrl(url);
|
||||
if (!parsed) return null;
|
||||
const aesKey = parsed.rawKey.subarray(0, 16);
|
||||
|
||||
const apiUrl = `${MEGA_API_BASE}?id=${Math.floor(Math.random() * 1e9)}`;
|
||||
const body = JSON.stringify([{ a: "g", g: 1, p: parsed.id }]);
|
||||
|
||||
let response: Response;
|
||||
try {
|
||||
response = await fetch(apiUrl, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body,
|
||||
signal: withTimeoutSignal(signal, MEGA_API_TIMEOUT_MS)
|
||||
});
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (!response.ok) return null;
|
||||
|
||||
let payload: unknown;
|
||||
try {
|
||||
payload = await response.json();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (typeof payload === "number") return null;
|
||||
if (!Array.isArray(payload) || payload.length === 0) return null;
|
||||
|
||||
const first = payload[0];
|
||||
if (typeof first === "number") return null;
|
||||
if (!first || typeof first !== "object") return null;
|
||||
|
||||
const info = first as { s?: unknown; at?: unknown; e?: unknown };
|
||||
if (typeof info.e === "number" && info.e !== 0) return null;
|
||||
|
||||
const size = typeof info.s === "number" && info.s > 0 ? info.s : 0;
|
||||
if (typeof info.at !== "string" || !info.at.trim()) return null;
|
||||
|
||||
const encryptedAttrs = base64UrlDecode(info.at);
|
||||
if (!encryptedAttrs) return null;
|
||||
|
||||
const attrs = decryptMegaAttributes(encryptedAttrs, aesKey);
|
||||
if (!attrs || typeof attrs.n !== "string" || !attrs.n.trim()) return null;
|
||||
|
||||
return { name: attrs.n.trim(), size };
|
||||
}
|
||||
import crypto from "node:crypto";
|
||||
|
||||
const MEGA_API_BASE = "https://g.api.mega.co.nz/cs";
|
||||
const MEGA_API_TIMEOUT_MS = 12_000;
|
||||
|
||||
export interface MegaFileInfo {
|
||||
name: string;
|
||||
size: number;
|
||||
}
|
||||
|
||||
const NEW_FORMAT_RE = /^https?:\/\/mega\.(?:nz|co\.nz)\/file\/([A-Za-z0-9_-]+)#([A-Za-z0-9_-]+)/i;
|
||||
const LEGACY_FORMAT_RE = /^https?:\/\/mega\.(?:nz|co\.nz)\/#!([A-Za-z0-9_-]+)!([A-Za-z0-9_-]+)/i;
|
||||
|
||||
export function isMegaFileUrl(url: string): boolean {
|
||||
const s = String(url || "").trim();
|
||||
return NEW_FORMAT_RE.test(s) || LEGACY_FORMAT_RE.test(s);
|
||||
}
|
||||
|
||||
function base64UrlDecode(s: string): Buffer | null {
|
||||
let b64 = String(s || "").trim().replace(/-/g, "+").replace(/_/g, "/");
|
||||
while (b64.length % 4 !== 0) b64 += "=";
|
||||
try {
|
||||
return Buffer.from(b64, "base64");
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export interface ParsedMegaLink {
|
||||
id: string;
|
||||
rawKey: Buffer;
|
||||
}
|
||||
|
||||
export function parseMegaUrl(url: string): ParsedMegaLink | null {
|
||||
const s = String(url || "").trim();
|
||||
const m = NEW_FORMAT_RE.exec(s) || LEGACY_FORMAT_RE.exec(s);
|
||||
if (!m) return null;
|
||||
const id = m[1];
|
||||
const rawKey = base64UrlDecode(m[2]);
|
||||
if (!rawKey || rawKey.length !== 32) return null;
|
||||
return { id, rawKey };
|
||||
}
|
||||
|
||||
export function decryptMegaAttributes(encrypted: Buffer, aesKey: Buffer): Record<string, unknown> | null {
|
||||
if (!Buffer.isBuffer(encrypted) || encrypted.length === 0 || encrypted.length % 16 !== 0) return null;
|
||||
if (!Buffer.isBuffer(aesKey) || aesKey.length !== 16) return null;
|
||||
let plain: Buffer;
|
||||
try {
|
||||
const decipher = crypto.createDecipheriv("aes-128-cbc", aesKey, Buffer.alloc(16));
|
||||
decipher.setAutoPadding(false);
|
||||
plain = Buffer.concat([decipher.update(encrypted), decipher.final()]);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
const text = plain.toString("utf8").replace(/\0+$/, "").trim();
|
||||
if (!text.startsWith("MEGA{")) return null;
|
||||
try {
|
||||
return JSON.parse(text.slice(4));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function withTimeoutSignal(parent: AbortSignal | undefined, timeoutMs: number): AbortSignal {
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort("mega-api-timeout"), timeoutMs);
|
||||
if (parent) {
|
||||
if (parent.aborted) {
|
||||
controller.abort(parent.reason);
|
||||
} else {
|
||||
parent.addEventListener("abort", () => controller.abort(parent.reason), { once: true });
|
||||
}
|
||||
}
|
||||
controller.signal.addEventListener("abort", () => clearTimeout(timer), { once: true });
|
||||
return controller.signal;
|
||||
}
|
||||
|
||||
export async function resolveMegaFilename(
|
||||
url: string,
|
||||
signal?: AbortSignal
|
||||
): Promise<MegaFileInfo | null> {
|
||||
const parsed = parseMegaUrl(url);
|
||||
if (!parsed) return null;
|
||||
const aesKey = parsed.rawKey.subarray(0, 16);
|
||||
|
||||
const apiUrl = `${MEGA_API_BASE}?id=${Math.floor(Math.random() * 1e9)}`;
|
||||
const body = JSON.stringify([{ a: "g", g: 1, p: parsed.id }]);
|
||||
|
||||
let response: Response;
|
||||
try {
|
||||
response = await fetch(apiUrl, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body,
|
||||
signal: withTimeoutSignal(signal, MEGA_API_TIMEOUT_MS)
|
||||
});
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (!response.ok) return null;
|
||||
|
||||
let payload: unknown;
|
||||
try {
|
||||
payload = await response.json();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (typeof payload === "number") return null;
|
||||
if (!Array.isArray(payload) || payload.length === 0) return null;
|
||||
|
||||
const first = payload[0];
|
||||
if (typeof first === "number") return null;
|
||||
if (!first || typeof first !== "object") return null;
|
||||
|
||||
const info = first as { s?: unknown; at?: unknown; e?: unknown };
|
||||
if (typeof info.e === "number" && info.e !== 0) return null;
|
||||
|
||||
const size = typeof info.s === "number" && info.s > 0 ? info.s : 0;
|
||||
if (typeof info.at !== "string" || !info.at.trim()) return null;
|
||||
|
||||
const encryptedAttrs = base64UrlDecode(info.at);
|
||||
if (!encryptedAttrs) return null;
|
||||
|
||||
const attrs = decryptMegaAttributes(encryptedAttrs, aesKey);
|
||||
if (!attrs || typeof attrs.n !== "string" || !attrs.n.trim()) return null;
|
||||
|
||||
return { name: attrs.n.trim(), size };
|
||||
}
|
||||
|
||||
@ -1,458 +1,458 @@
|
||||
import { UnrestrictedLink } from "./realdebrid";
|
||||
import { compactErrorText, filenameFromUrl, sleep } from "./utils";
|
||||
import { traceConversionPhase } from "./conversion-trace";
|
||||
|
||||
type MegaCredentials = {
|
||||
login: string;
|
||||
password: string;
|
||||
};
|
||||
|
||||
type CodeEntry = {
|
||||
code: string;
|
||||
linkHint: string;
|
||||
};
|
||||
|
||||
const LOGIN_URL = "https://www.mega-debrid.eu/index.php?form=login";
|
||||
const DEBRID_URL = "https://www.mega-debrid.eu/index.php?form=debrid";
|
||||
const DEBRID_AJAX_URL = "https://www.mega-debrid.eu/index.php?ajax=debrid&json";
|
||||
const DEBRID_REFERER = "https://www.mega-debrid.eu/index.php?page=debrideur&lang=de";
|
||||
|
||||
export const MEGA_DEBRID_NO_SERVER_RE = /kein server f(?:ü|u)r diesen hoster|no server (?:is )?available for this host|aucun serveur disponible/i;
|
||||
|
||||
function normalizeLink(link: string): string {
|
||||
return link.trim().toLowerCase();
|
||||
}
|
||||
|
||||
function parseSetCookieFromHeaders(headers: Headers): string {
|
||||
const getSetCookie = (headers as unknown as { getSetCookie?: () => string[] }).getSetCookie;
|
||||
if (typeof getSetCookie === "function") {
|
||||
const values = getSetCookie.call(headers)
|
||||
.map((entry) => entry.split(";")[0].trim())
|
||||
.filter(Boolean);
|
||||
if (values.length > 0) {
|
||||
return values.join("; ");
|
||||
}
|
||||
}
|
||||
|
||||
const raw = headers.get("set-cookie") || "";
|
||||
if (!raw) {
|
||||
return "";
|
||||
}
|
||||
return raw
|
||||
.split(/,(?=[^;=]+?=)/g)
|
||||
.map((chunk) => chunk.split(";")[0].trim())
|
||||
.filter(Boolean)
|
||||
.join("; ");
|
||||
}
|
||||
|
||||
const PERMANENT_HOSTER_ERRORS = [
|
||||
"hosternotavailable",
|
||||
"filenotfound",
|
||||
"file_unavailable",
|
||||
"file not found",
|
||||
"link is dead",
|
||||
"file has been removed",
|
||||
"file has been deleted",
|
||||
"file was deleted",
|
||||
"file was removed",
|
||||
"not available",
|
||||
"file is no longer available"
|
||||
];
|
||||
|
||||
function parsePageErrors(html: string): string[] {
|
||||
const errors: string[] = [];
|
||||
const errorRegex = /class=["'][^"']*\berror\b[^"']*["'][^>]*>([^<]+)</gi;
|
||||
let m: RegExpExecArray | null;
|
||||
while ((m = errorRegex.exec(html)) !== null) {
|
||||
const text = m[1].replace(/^Fehler:\s*/i, "").trim();
|
||||
if (text) {
|
||||
errors.push(text);
|
||||
}
|
||||
}
|
||||
return errors;
|
||||
}
|
||||
|
||||
function isPermanentHosterError(errors: string[]): string | null {
|
||||
for (const err of errors) {
|
||||
const lower = err.toLowerCase();
|
||||
for (const pattern of PERMANENT_HOSTER_ERRORS) {
|
||||
if (lower.includes(pattern)) {
|
||||
return err;
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function parseCodes(html: string): CodeEntry[] {
|
||||
const entries: CodeEntry[] = [];
|
||||
const cardRegex = /<div[^>]*class=['"][^'"]*acp-box[^'"]*['"][^>]*>[\s\S]*?<\/div>/gi;
|
||||
let cardMatch: RegExpExecArray | null;
|
||||
while ((cardMatch = cardRegex.exec(html)) !== null) {
|
||||
const block = cardMatch[0];
|
||||
const linkTitle = (block.match(/<h3>\s*Link:\s*([^<]+)<\/h3>/i)?.[1] || "").trim();
|
||||
const code = block.match(/processDebrid\(\d+,'([^']+)',0\)/i)?.[1] || "";
|
||||
if (!code) {
|
||||
continue;
|
||||
}
|
||||
entries.push({ code, linkHint: normalizeLink(linkTitle) });
|
||||
}
|
||||
|
||||
if (entries.length === 0) {
|
||||
const fallbackRegex = /processDebrid\(\d+,'([^']+)',0\)/gi;
|
||||
let m: RegExpExecArray | null;
|
||||
while ((m = fallbackRegex.exec(html)) !== null) {
|
||||
entries.push({ code: m[1], linkHint: "" });
|
||||
}
|
||||
}
|
||||
|
||||
return entries;
|
||||
}
|
||||
|
||||
function pickCode(entries: CodeEntry[], link: string): string {
|
||||
if (entries.length === 0) {
|
||||
return "";
|
||||
}
|
||||
const target = normalizeLink(link);
|
||||
const match = entries.find((entry) => entry.linkHint && entry.linkHint.includes(target));
|
||||
return (match?.code || entries[0].code || "").trim();
|
||||
}
|
||||
|
||||
function parseDebridJson(text: string): { link: string; text: string } | null {
|
||||
try {
|
||||
const parsed = JSON.parse(text) as { link?: string; text?: string };
|
||||
return {
|
||||
link: String(parsed.link || ""),
|
||||
text: String(parsed.text || "")
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function abortError(): Error {
|
||||
return new Error("aborted:mega-web");
|
||||
}
|
||||
|
||||
function withTimeoutSignal(signal: AbortSignal | undefined, timeoutMs: number): AbortSignal {
|
||||
const timeoutSignal = AbortSignal.timeout(timeoutMs);
|
||||
if (!signal) {
|
||||
return timeoutSignal;
|
||||
}
|
||||
return AbortSignal.any([signal, timeoutSignal]);
|
||||
}
|
||||
|
||||
function throwIfAborted(signal?: AbortSignal): void {
|
||||
if (signal?.aborted) {
|
||||
throw abortError();
|
||||
}
|
||||
}
|
||||
|
||||
async function sleepWithSignal(ms: number, signal?: AbortSignal): Promise<void> {
|
||||
if (!signal) {
|
||||
await sleep(ms);
|
||||
return;
|
||||
}
|
||||
if (signal.aborted) {
|
||||
throw abortError();
|
||||
}
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
let timer: NodeJS.Timeout | null = setTimeout(() => {
|
||||
timer = null;
|
||||
signal.removeEventListener("abort", onAbort);
|
||||
resolve();
|
||||
}, Math.max(0, ms));
|
||||
|
||||
const onAbort = (): void => {
|
||||
if (timer) {
|
||||
clearTimeout(timer);
|
||||
timer = null;
|
||||
}
|
||||
signal.removeEventListener("abort", onAbort);
|
||||
reject(abortError());
|
||||
};
|
||||
|
||||
signal.addEventListener("abort", onAbort, { once: true });
|
||||
});
|
||||
}
|
||||
|
||||
async function raceWithAbort<T>(promise: Promise<T>, signal?: AbortSignal, abortErrorFactory: () => Error = abortError): Promise<T> {
|
||||
if (!signal) {
|
||||
return promise;
|
||||
}
|
||||
if (signal.aborted) {
|
||||
throw abortErrorFactory();
|
||||
}
|
||||
|
||||
return new Promise<T>((resolve, reject) => {
|
||||
let settled = false;
|
||||
|
||||
const onAbort = (): void => {
|
||||
if (settled) {
|
||||
return;
|
||||
}
|
||||
settled = true;
|
||||
signal.removeEventListener("abort", onAbort);
|
||||
reject(abortErrorFactory());
|
||||
};
|
||||
|
||||
signal.addEventListener("abort", onAbort, { once: true });
|
||||
|
||||
promise.then((value) => {
|
||||
if (settled) {
|
||||
return;
|
||||
}
|
||||
settled = true;
|
||||
signal.removeEventListener("abort", onAbort);
|
||||
resolve(value);
|
||||
}, (error) => {
|
||||
if (settled) {
|
||||
return;
|
||||
}
|
||||
settled = true;
|
||||
signal.removeEventListener("abort", onAbort);
|
||||
reject(error);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export class MegaWebFallback {
|
||||
// Pro Account eine eigene Warteschlange: Umwandlungen auf DEMSELBEN Account laufen
|
||||
// seriell (kein Doppel-Login, kein Hammern eines einzelnen Accounts), verschiedene
|
||||
// Accounts laufen parallel. So koennen die Links eines Pakets ueber mehrere Accounts
|
||||
// gleichzeitig umgewandelt werden statt global eine nach der anderen.
|
||||
private queues = new Map<string, Promise<unknown>>();
|
||||
|
||||
private getCredentials: () => MegaCredentials;
|
||||
|
||||
private sessions = new Map<string, { cookie: string; setAt: number }>();
|
||||
|
||||
public constructor(getCredentials: () => MegaCredentials) {
|
||||
this.getCredentials = getCredentials;
|
||||
}
|
||||
|
||||
public async unrestrict(
|
||||
link: string,
|
||||
signal?: AbortSignal,
|
||||
account?: { login: string; password: string }
|
||||
): Promise<UnrestrictedLink | null> {
|
||||
const overallSignal = withTimeoutSignal(signal, 180000);
|
||||
const creds = (account && account.login.trim() && account.password.trim())
|
||||
? account
|
||||
: this.getCredentials();
|
||||
if (!creds.login.trim() || !creds.password.trim()) {
|
||||
return null;
|
||||
}
|
||||
const key = creds.login.trim().toLowerCase();
|
||||
return this.runExclusive(async () => {
|
||||
throwIfAborted(overallSignal);
|
||||
let cookie = await this.ensureSession(key, creds.login, creds.password, overallSignal);
|
||||
|
||||
let generated = await this.generate(link, cookie, overallSignal);
|
||||
if (!generated) {
|
||||
this.sessions.delete(key);
|
||||
cookie = await this.ensureSession(key, creds.login, creds.password, overallSignal);
|
||||
generated = await this.generate(link, cookie, overallSignal);
|
||||
if (!generated) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return {
|
||||
directUrl: generated.directUrl,
|
||||
fileName: generated.fileName || filenameFromUrl(link),
|
||||
fileSize: null,
|
||||
retriesUsed: 0
|
||||
};
|
||||
}, key, overallSignal);
|
||||
}
|
||||
|
||||
private async ensureSession(key: string, login: string, password: string, signal?: AbortSignal): Promise<string> {
|
||||
const existing = this.sessions.get(key);
|
||||
if (existing && existing.cookie && Date.now() - existing.setAt <= 20 * 60 * 1000) {
|
||||
return existing.cookie;
|
||||
}
|
||||
const cookie = await this.login(login, password, signal);
|
||||
this.sessions.set(key, { cookie, setAt: Date.now() });
|
||||
return cookie;
|
||||
}
|
||||
|
||||
public invalidateSession(): void {
|
||||
this.sessions.clear();
|
||||
}
|
||||
|
||||
private async runExclusive<T>(job: () => Promise<T>, key: string, signal?: AbortSignal): Promise<T> {
|
||||
const queuedAt = Date.now();
|
||||
const QUEUE_WAIT_TIMEOUT_MS = 90000;
|
||||
let workStarted = false;
|
||||
const guardedJob = async (): Promise<T> => {
|
||||
throwIfAborted(signal);
|
||||
const waited = Date.now() - queuedAt;
|
||||
if (waited > QUEUE_WAIT_TIMEOUT_MS) {
|
||||
traceConversionPhase({ phase: "web-queue", provider: "megadebrid-web", queueWaitMs: waited, outcome: "queue-timeout", detail: `${Math.floor(waited / 1000)}s in Web-Queue gewartet` });
|
||||
throw new Error(`Mega-Web Queue-Timeout (${Math.floor(waited / 1000)}s gewartet)`);
|
||||
}
|
||||
workStarted = true;
|
||||
const workStartedAt = Date.now();
|
||||
try {
|
||||
const result = await job();
|
||||
traceConversionPhase({ phase: "web-queue", provider: "megadebrid-web", queueWaitMs: waited, workMs: Date.now() - workStartedAt, outcome: "ok" });
|
||||
return result;
|
||||
} catch (jobError) {
|
||||
traceConversionPhase({ phase: "web-queue", provider: "megadebrid-web", queueWaitMs: waited, workMs: Date.now() - workStartedAt, outcome: "error", detail: compactErrorText(jobError).slice(0, 100) });
|
||||
throw jobError;
|
||||
}
|
||||
};
|
||||
const prev = this.queues.get(key) ?? Promise.resolve();
|
||||
const run = prev.then(guardedJob, guardedJob);
|
||||
this.queues.set(key, run.then(() => undefined, () => undefined));
|
||||
return raceWithAbort(run, signal, () =>
|
||||
workStarted
|
||||
? abortError()
|
||||
: new Error(`Mega-Web Queue-Timeout (abgebrochen nach ${Math.floor((Date.now() - queuedAt) / 1000)}s Wartezeit, Account war belegt)`)
|
||||
);
|
||||
}
|
||||
|
||||
private async login(login: string, password: string, signal?: AbortSignal): Promise<string> {
|
||||
throwIfAborted(signal);
|
||||
const response = await fetch(LOGIN_URL, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
"User-Agent": "Mozilla/5.0"
|
||||
},
|
||||
body: new URLSearchParams({
|
||||
login,
|
||||
password,
|
||||
remember: "on"
|
||||
}),
|
||||
redirect: "manual",
|
||||
signal: withTimeoutSignal(signal, 30000)
|
||||
});
|
||||
|
||||
const cookie = parseSetCookieFromHeaders(response.headers);
|
||||
if (!cookie) {
|
||||
throw new Error("Mega-Web Login liefert kein Session-Cookie");
|
||||
}
|
||||
|
||||
const verify = await fetch(DEBRID_REFERER, {
|
||||
method: "GET",
|
||||
headers: {
|
||||
"User-Agent": "Mozilla/5.0",
|
||||
Cookie: cookie,
|
||||
Referer: DEBRID_REFERER
|
||||
},
|
||||
signal: withTimeoutSignal(signal, 30000)
|
||||
});
|
||||
const verifyHtml = await verify.text();
|
||||
const hasDebridForm = /id=["']debridForm["']/i.test(verifyHtml) || /name=["']links["']/i.test(verifyHtml);
|
||||
if (!hasDebridForm) {
|
||||
throw new Error("Mega-Web Login ungültig oder Session blockiert");
|
||||
}
|
||||
|
||||
return cookie;
|
||||
}
|
||||
|
||||
private async generate(link: string, cookie: string, signal?: AbortSignal): Promise<{ directUrl: string; fileName: string } | null> {
|
||||
throwIfAborted(signal);
|
||||
const page = await fetch(DEBRID_URL, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
"User-Agent": "Mozilla/5.0",
|
||||
Cookie: cookie,
|
||||
Referer: DEBRID_REFERER
|
||||
},
|
||||
body: new URLSearchParams({
|
||||
links: link,
|
||||
password: "",
|
||||
showLinks: "1"
|
||||
}),
|
||||
signal: withTimeoutSignal(signal, 30000)
|
||||
});
|
||||
|
||||
const html = await page.text();
|
||||
|
||||
const pageErrors = parsePageErrors(html);
|
||||
const permanentError = isPermanentHosterError(pageErrors);
|
||||
if (permanentError) {
|
||||
throw new Error(`Mega-Web: Link permanent ungültig (${permanentError})`);
|
||||
}
|
||||
|
||||
const noServerError = pageErrors.find((err) => MEGA_DEBRID_NO_SERVER_RE.test(err));
|
||||
if (noServerError) {
|
||||
throw new Error(`Mega-Web: ${noServerError}`);
|
||||
}
|
||||
|
||||
const code = pickCode(parseCodes(html), link);
|
||||
if (!code) {
|
||||
return null;
|
||||
}
|
||||
|
||||
for (let attempt = 1; attempt <= 60; attempt += 1) {
|
||||
throwIfAborted(signal);
|
||||
const res = await fetch(DEBRID_AJAX_URL, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
"User-Agent": "Mozilla/5.0",
|
||||
Cookie: cookie,
|
||||
Referer: DEBRID_REFERER
|
||||
},
|
||||
body: new URLSearchParams({
|
||||
code,
|
||||
autodl: "0"
|
||||
}),
|
||||
signal: withTimeoutSignal(signal, 15000)
|
||||
});
|
||||
|
||||
const text = (await res.text()).trim();
|
||||
if (text === "reload") {
|
||||
await sleepWithSignal(650, signal);
|
||||
continue;
|
||||
}
|
||||
if (text === "false") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const parsed = parseDebridJson(text);
|
||||
if (!parsed) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!parsed.link) {
|
||||
if (/hoster does not respond correctly|could not be done for this moment/i.test(parsed.text || "")) {
|
||||
await sleepWithSignal(1200, signal);
|
||||
continue;
|
||||
}
|
||||
const serverMsg = (parsed.text || "").replace(/<[^>]*>/g, " ").replace(/\s+/g, " ").trim();
|
||||
if (serverMsg && MEGA_DEBRID_NO_SERVER_RE.test(serverMsg)) {
|
||||
throw new Error(`Mega-Web: ${serverMsg}`);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
const fromText = parsed.text
|
||||
.replace(/<[^>]*>/g, " ")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim();
|
||||
|
||||
const nameMatch = fromText.match(/([\w .\-\[\]\(\)]+\.(?:rar|r\d{2}|zip|7z|mkv|mp4|avi|mp3|flac))/i);
|
||||
const fileName = (nameMatch?.[1] || filenameFromUrl(link)).trim();
|
||||
return {
|
||||
directUrl: parsed.link,
|
||||
fileName
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public dispose(): void {
|
||||
this.sessions.clear();
|
||||
}
|
||||
}
|
||||
|
||||
export function compactMegaWebError(error: unknown): string {
|
||||
return compactErrorText(error);
|
||||
}
|
||||
import { UnrestrictedLink } from "./realdebrid";
|
||||
import { compactErrorText, filenameFromUrl, sleep } from "./utils";
|
||||
import { traceConversionPhase } from "./conversion-trace";
|
||||
|
||||
type MegaCredentials = {
|
||||
login: string;
|
||||
password: string;
|
||||
};
|
||||
|
||||
type CodeEntry = {
|
||||
code: string;
|
||||
linkHint: string;
|
||||
};
|
||||
|
||||
const LOGIN_URL = "https://www.mega-debrid.eu/index.php?form=login";
|
||||
const DEBRID_URL = "https://www.mega-debrid.eu/index.php?form=debrid";
|
||||
const DEBRID_AJAX_URL = "https://www.mega-debrid.eu/index.php?ajax=debrid&json";
|
||||
const DEBRID_REFERER = "https://www.mega-debrid.eu/index.php?page=debrideur&lang=de";
|
||||
|
||||
export const MEGA_DEBRID_NO_SERVER_RE = /kein server f(?:ü|u)r diesen hoster|no server (?:is )?available for this host|aucun serveur disponible/i;
|
||||
|
||||
function normalizeLink(link: string): string {
|
||||
return link.trim().toLowerCase();
|
||||
}
|
||||
|
||||
function parseSetCookieFromHeaders(headers: Headers): string {
|
||||
const getSetCookie = (headers as unknown as { getSetCookie?: () => string[] }).getSetCookie;
|
||||
if (typeof getSetCookie === "function") {
|
||||
const values = getSetCookie.call(headers)
|
||||
.map((entry) => entry.split(";")[0].trim())
|
||||
.filter(Boolean);
|
||||
if (values.length > 0) {
|
||||
return values.join("; ");
|
||||
}
|
||||
}
|
||||
|
||||
const raw = headers.get("set-cookie") || "";
|
||||
if (!raw) {
|
||||
return "";
|
||||
}
|
||||
return raw
|
||||
.split(/,(?=[^;=]+?=)/g)
|
||||
.map((chunk) => chunk.split(";")[0].trim())
|
||||
.filter(Boolean)
|
||||
.join("; ");
|
||||
}
|
||||
|
||||
const PERMANENT_HOSTER_ERRORS = [
|
||||
"hosternotavailable",
|
||||
"filenotfound",
|
||||
"file_unavailable",
|
||||
"file not found",
|
||||
"link is dead",
|
||||
"file has been removed",
|
||||
"file has been deleted",
|
||||
"file was deleted",
|
||||
"file was removed",
|
||||
"not available",
|
||||
"file is no longer available"
|
||||
];
|
||||
|
||||
function parsePageErrors(html: string): string[] {
|
||||
const errors: string[] = [];
|
||||
const errorRegex = /class=["'][^"']*\berror\b[^"']*["'][^>]*>([^<]+)</gi;
|
||||
let m: RegExpExecArray | null;
|
||||
while ((m = errorRegex.exec(html)) !== null) {
|
||||
const text = m[1].replace(/^Fehler:\s*/i, "").trim();
|
||||
if (text) {
|
||||
errors.push(text);
|
||||
}
|
||||
}
|
||||
return errors;
|
||||
}
|
||||
|
||||
function isPermanentHosterError(errors: string[]): string | null {
|
||||
for (const err of errors) {
|
||||
const lower = err.toLowerCase();
|
||||
for (const pattern of PERMANENT_HOSTER_ERRORS) {
|
||||
if (lower.includes(pattern)) {
|
||||
return err;
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function parseCodes(html: string): CodeEntry[] {
|
||||
const entries: CodeEntry[] = [];
|
||||
const cardRegex = /<div[^>]*class=['"][^'"]*acp-box[^'"]*['"][^>]*>[\s\S]*?<\/div>/gi;
|
||||
let cardMatch: RegExpExecArray | null;
|
||||
while ((cardMatch = cardRegex.exec(html)) !== null) {
|
||||
const block = cardMatch[0];
|
||||
const linkTitle = (block.match(/<h3>\s*Link:\s*([^<]+)<\/h3>/i)?.[1] || "").trim();
|
||||
const code = block.match(/processDebrid\(\d+,'([^']+)',0\)/i)?.[1] || "";
|
||||
if (!code) {
|
||||
continue;
|
||||
}
|
||||
entries.push({ code, linkHint: normalizeLink(linkTitle) });
|
||||
}
|
||||
|
||||
if (entries.length === 0) {
|
||||
const fallbackRegex = /processDebrid\(\d+,'([^']+)',0\)/gi;
|
||||
let m: RegExpExecArray | null;
|
||||
while ((m = fallbackRegex.exec(html)) !== null) {
|
||||
entries.push({ code: m[1], linkHint: "" });
|
||||
}
|
||||
}
|
||||
|
||||
return entries;
|
||||
}
|
||||
|
||||
function pickCode(entries: CodeEntry[], link: string): string {
|
||||
if (entries.length === 0) {
|
||||
return "";
|
||||
}
|
||||
const target = normalizeLink(link);
|
||||
const match = entries.find((entry) => entry.linkHint && entry.linkHint.includes(target));
|
||||
return (match?.code || entries[0].code || "").trim();
|
||||
}
|
||||
|
||||
function parseDebridJson(text: string): { link: string; text: string } | null {
|
||||
try {
|
||||
const parsed = JSON.parse(text) as { link?: string; text?: string };
|
||||
return {
|
||||
link: String(parsed.link || ""),
|
||||
text: String(parsed.text || "")
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function abortError(): Error {
|
||||
return new Error("aborted:mega-web");
|
||||
}
|
||||
|
||||
function withTimeoutSignal(signal: AbortSignal | undefined, timeoutMs: number): AbortSignal {
|
||||
const timeoutSignal = AbortSignal.timeout(timeoutMs);
|
||||
if (!signal) {
|
||||
return timeoutSignal;
|
||||
}
|
||||
return AbortSignal.any([signal, timeoutSignal]);
|
||||
}
|
||||
|
||||
function throwIfAborted(signal?: AbortSignal): void {
|
||||
if (signal?.aborted) {
|
||||
throw abortError();
|
||||
}
|
||||
}
|
||||
|
||||
async function sleepWithSignal(ms: number, signal?: AbortSignal): Promise<void> {
|
||||
if (!signal) {
|
||||
await sleep(ms);
|
||||
return;
|
||||
}
|
||||
if (signal.aborted) {
|
||||
throw abortError();
|
||||
}
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
let timer: NodeJS.Timeout | null = setTimeout(() => {
|
||||
timer = null;
|
||||
signal.removeEventListener("abort", onAbort);
|
||||
resolve();
|
||||
}, Math.max(0, ms));
|
||||
|
||||
const onAbort = (): void => {
|
||||
if (timer) {
|
||||
clearTimeout(timer);
|
||||
timer = null;
|
||||
}
|
||||
signal.removeEventListener("abort", onAbort);
|
||||
reject(abortError());
|
||||
};
|
||||
|
||||
signal.addEventListener("abort", onAbort, { once: true });
|
||||
});
|
||||
}
|
||||
|
||||
async function raceWithAbort<T>(promise: Promise<T>, signal?: AbortSignal, abortErrorFactory: () => Error = abortError): Promise<T> {
|
||||
if (!signal) {
|
||||
return promise;
|
||||
}
|
||||
if (signal.aborted) {
|
||||
throw abortErrorFactory();
|
||||
}
|
||||
|
||||
return new Promise<T>((resolve, reject) => {
|
||||
let settled = false;
|
||||
|
||||
const onAbort = (): void => {
|
||||
if (settled) {
|
||||
return;
|
||||
}
|
||||
settled = true;
|
||||
signal.removeEventListener("abort", onAbort);
|
||||
reject(abortErrorFactory());
|
||||
};
|
||||
|
||||
signal.addEventListener("abort", onAbort, { once: true });
|
||||
|
||||
promise.then((value) => {
|
||||
if (settled) {
|
||||
return;
|
||||
}
|
||||
settled = true;
|
||||
signal.removeEventListener("abort", onAbort);
|
||||
resolve(value);
|
||||
}, (error) => {
|
||||
if (settled) {
|
||||
return;
|
||||
}
|
||||
settled = true;
|
||||
signal.removeEventListener("abort", onAbort);
|
||||
reject(error);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export class MegaWebFallback {
|
||||
// Pro Account eine eigene Warteschlange: Umwandlungen auf DEMSELBEN Account laufen
|
||||
// seriell (kein Doppel-Login, kein Hammern eines einzelnen Accounts), verschiedene
|
||||
// Accounts laufen parallel. So koennen die Links eines Pakets ueber mehrere Accounts
|
||||
// gleichzeitig umgewandelt werden statt global eine nach der anderen.
|
||||
private queues = new Map<string, Promise<unknown>>();
|
||||
|
||||
private getCredentials: () => MegaCredentials;
|
||||
|
||||
private sessions = new Map<string, { cookie: string; setAt: number }>();
|
||||
|
||||
public constructor(getCredentials: () => MegaCredentials) {
|
||||
this.getCredentials = getCredentials;
|
||||
}
|
||||
|
||||
public async unrestrict(
|
||||
link: string,
|
||||
signal?: AbortSignal,
|
||||
account?: { login: string; password: string }
|
||||
): Promise<UnrestrictedLink | null> {
|
||||
const overallSignal = withTimeoutSignal(signal, 180000);
|
||||
const creds = (account && account.login.trim() && account.password.trim())
|
||||
? account
|
||||
: this.getCredentials();
|
||||
if (!creds.login.trim() || !creds.password.trim()) {
|
||||
return null;
|
||||
}
|
||||
const key = creds.login.trim().toLowerCase();
|
||||
return this.runExclusive(async () => {
|
||||
throwIfAborted(overallSignal);
|
||||
let cookie = await this.ensureSession(key, creds.login, creds.password, overallSignal);
|
||||
|
||||
let generated = await this.generate(link, cookie, overallSignal);
|
||||
if (!generated) {
|
||||
this.sessions.delete(key);
|
||||
cookie = await this.ensureSession(key, creds.login, creds.password, overallSignal);
|
||||
generated = await this.generate(link, cookie, overallSignal);
|
||||
if (!generated) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return {
|
||||
directUrl: generated.directUrl,
|
||||
fileName: generated.fileName || filenameFromUrl(link),
|
||||
fileSize: null,
|
||||
retriesUsed: 0
|
||||
};
|
||||
}, key, overallSignal);
|
||||
}
|
||||
|
||||
private async ensureSession(key: string, login: string, password: string, signal?: AbortSignal): Promise<string> {
|
||||
const existing = this.sessions.get(key);
|
||||
if (existing && existing.cookie && Date.now() - existing.setAt <= 20 * 60 * 1000) {
|
||||
return existing.cookie;
|
||||
}
|
||||
const cookie = await this.login(login, password, signal);
|
||||
this.sessions.set(key, { cookie, setAt: Date.now() });
|
||||
return cookie;
|
||||
}
|
||||
|
||||
public invalidateSession(): void {
|
||||
this.sessions.clear();
|
||||
}
|
||||
|
||||
private async runExclusive<T>(job: () => Promise<T>, key: string, signal?: AbortSignal): Promise<T> {
|
||||
const queuedAt = Date.now();
|
||||
const QUEUE_WAIT_TIMEOUT_MS = 90000;
|
||||
let workStarted = false;
|
||||
const guardedJob = async (): Promise<T> => {
|
||||
throwIfAborted(signal);
|
||||
const waited = Date.now() - queuedAt;
|
||||
if (waited > QUEUE_WAIT_TIMEOUT_MS) {
|
||||
traceConversionPhase({ phase: "web-queue", provider: "megadebrid-web", queueWaitMs: waited, outcome: "queue-timeout", detail: `${Math.floor(waited / 1000)}s in Web-Queue gewartet` });
|
||||
throw new Error(`Mega-Web Queue-Timeout (${Math.floor(waited / 1000)}s gewartet)`);
|
||||
}
|
||||
workStarted = true;
|
||||
const workStartedAt = Date.now();
|
||||
try {
|
||||
const result = await job();
|
||||
traceConversionPhase({ phase: "web-queue", provider: "megadebrid-web", queueWaitMs: waited, workMs: Date.now() - workStartedAt, outcome: "ok" });
|
||||
return result;
|
||||
} catch (jobError) {
|
||||
traceConversionPhase({ phase: "web-queue", provider: "megadebrid-web", queueWaitMs: waited, workMs: Date.now() - workStartedAt, outcome: "error", detail: compactErrorText(jobError).slice(0, 100) });
|
||||
throw jobError;
|
||||
}
|
||||
};
|
||||
const prev = this.queues.get(key) ?? Promise.resolve();
|
||||
const run = prev.then(guardedJob, guardedJob);
|
||||
this.queues.set(key, run.then(() => undefined, () => undefined));
|
||||
return raceWithAbort(run, signal, () =>
|
||||
workStarted
|
||||
? abortError()
|
||||
: new Error(`Mega-Web Queue-Timeout (abgebrochen nach ${Math.floor((Date.now() - queuedAt) / 1000)}s Wartezeit, Account war belegt)`)
|
||||
);
|
||||
}
|
||||
|
||||
private async login(login: string, password: string, signal?: AbortSignal): Promise<string> {
|
||||
throwIfAborted(signal);
|
||||
const response = await fetch(LOGIN_URL, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
"User-Agent": "Mozilla/5.0"
|
||||
},
|
||||
body: new URLSearchParams({
|
||||
login,
|
||||
password,
|
||||
remember: "on"
|
||||
}),
|
||||
redirect: "manual",
|
||||
signal: withTimeoutSignal(signal, 30000)
|
||||
});
|
||||
|
||||
const cookie = parseSetCookieFromHeaders(response.headers);
|
||||
if (!cookie) {
|
||||
throw new Error("Mega-Web Login liefert kein Session-Cookie");
|
||||
}
|
||||
|
||||
const verify = await fetch(DEBRID_REFERER, {
|
||||
method: "GET",
|
||||
headers: {
|
||||
"User-Agent": "Mozilla/5.0",
|
||||
Cookie: cookie,
|
||||
Referer: DEBRID_REFERER
|
||||
},
|
||||
signal: withTimeoutSignal(signal, 30000)
|
||||
});
|
||||
const verifyHtml = await verify.text();
|
||||
const hasDebridForm = /id=["']debridForm["']/i.test(verifyHtml) || /name=["']links["']/i.test(verifyHtml);
|
||||
if (!hasDebridForm) {
|
||||
throw new Error("Mega-Web Login ungültig oder Session blockiert");
|
||||
}
|
||||
|
||||
return cookie;
|
||||
}
|
||||
|
||||
private async generate(link: string, cookie: string, signal?: AbortSignal): Promise<{ directUrl: string; fileName: string } | null> {
|
||||
throwIfAborted(signal);
|
||||
const page = await fetch(DEBRID_URL, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
"User-Agent": "Mozilla/5.0",
|
||||
Cookie: cookie,
|
||||
Referer: DEBRID_REFERER
|
||||
},
|
||||
body: new URLSearchParams({
|
||||
links: link,
|
||||
password: "",
|
||||
showLinks: "1"
|
||||
}),
|
||||
signal: withTimeoutSignal(signal, 30000)
|
||||
});
|
||||
|
||||
const html = await page.text();
|
||||
|
||||
const pageErrors = parsePageErrors(html);
|
||||
const permanentError = isPermanentHosterError(pageErrors);
|
||||
if (permanentError) {
|
||||
throw new Error(`Mega-Web: Link permanent ungültig (${permanentError})`);
|
||||
}
|
||||
|
||||
const noServerError = pageErrors.find((err) => MEGA_DEBRID_NO_SERVER_RE.test(err));
|
||||
if (noServerError) {
|
||||
throw new Error(`Mega-Web: ${noServerError}`);
|
||||
}
|
||||
|
||||
const code = pickCode(parseCodes(html), link);
|
||||
if (!code) {
|
||||
return null;
|
||||
}
|
||||
|
||||
for (let attempt = 1; attempt <= 60; attempt += 1) {
|
||||
throwIfAborted(signal);
|
||||
const res = await fetch(DEBRID_AJAX_URL, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
"User-Agent": "Mozilla/5.0",
|
||||
Cookie: cookie,
|
||||
Referer: DEBRID_REFERER
|
||||
},
|
||||
body: new URLSearchParams({
|
||||
code,
|
||||
autodl: "0"
|
||||
}),
|
||||
signal: withTimeoutSignal(signal, 15000)
|
||||
});
|
||||
|
||||
const text = (await res.text()).trim();
|
||||
if (text === "reload") {
|
||||
await sleepWithSignal(650, signal);
|
||||
continue;
|
||||
}
|
||||
if (text === "false") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const parsed = parseDebridJson(text);
|
||||
if (!parsed) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!parsed.link) {
|
||||
if (/hoster does not respond correctly|could not be done for this moment/i.test(parsed.text || "")) {
|
||||
await sleepWithSignal(1200, signal);
|
||||
continue;
|
||||
}
|
||||
const serverMsg = (parsed.text || "").replace(/<[^>]*>/g, " ").replace(/\s+/g, " ").trim();
|
||||
if (serverMsg && MEGA_DEBRID_NO_SERVER_RE.test(serverMsg)) {
|
||||
throw new Error(`Mega-Web: ${serverMsg}`);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
const fromText = parsed.text
|
||||
.replace(/<[^>]*>/g, " ")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim();
|
||||
|
||||
const nameMatch = fromText.match(/([\w .\-\[\]\(\)]+\.(?:rar|r\d{2}|zip|7z|mkv|mp4|avi|mp3|flac))/i);
|
||||
const fileName = (nameMatch?.[1] || filenameFromUrl(link)).trim();
|
||||
return {
|
||||
directUrl: parsed.link,
|
||||
fileName
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public dispose(): void {
|
||||
this.sessions.clear();
|
||||
}
|
||||
}
|
||||
|
||||
export function compactMegaWebError(error: unknown): string {
|
||||
return compactErrorText(error);
|
||||
}
|
||||
|
||||
@ -1,152 +1,152 @@
|
||||
import { logger } from "./logger";
|
||||
|
||||
export interface NotifyPayload {
|
||||
title: string;
|
||||
message: string;
|
||||
mention?: string;
|
||||
}
|
||||
|
||||
const NOTIFY_TIMEOUT_MS = 5000;
|
||||
const WEBHOOK_USERNAME = "Real-Debrid Downloader";
|
||||
const MIN_SEND_GAP_MS = 450;
|
||||
const RETRY_DELAYS_MS = [1000, 2500];
|
||||
const RATE_LIMIT_MAX_WAIT_MS = 15_000;
|
||||
const CONTENT_MAX_CHARS = 2000;
|
||||
|
||||
export function isNotifyUrlValid(url: string): boolean {
|
||||
return /^https?:\/\/\S+$/i.test(String(url || "").trim());
|
||||
}
|
||||
|
||||
// Accepts a bare Discord user ID (wrapped as <@id> so it actually pings),
|
||||
// @everyone/@here, or an already-formed <@...>/<@&...> mention as-is.
|
||||
export function normalizeDiscordMention(raw: string): string {
|
||||
const text = String(raw || "").trim();
|
||||
if (!text) {
|
||||
return "";
|
||||
}
|
||||
if (/^\d{5,}$/.test(text)) {
|
||||
return `<@${text}>`;
|
||||
}
|
||||
return text;
|
||||
}
|
||||
|
||||
// Discord counts the limit itself; slicing UTF-16 units can split a surrogate
|
||||
// pair at the boundary, which Discord rejects as invalid content.
|
||||
export function truncateContent(content: string, maxChars = CONTENT_MAX_CHARS): string {
|
||||
if (content.length <= maxChars) {
|
||||
return content;
|
||||
}
|
||||
let cut = content.slice(0, maxChars);
|
||||
const last = cut.charCodeAt(cut.length - 1);
|
||||
if (last >= 0xd800 && last <= 0xdbff) {
|
||||
cut = cut.slice(0, -1);
|
||||
}
|
||||
return cut;
|
||||
}
|
||||
|
||||
export function buildNotifyRequest(url: string, payload: NotifyPayload): { url: string; init: RequestInit } {
|
||||
const mention = normalizeDiscordMention(payload.mention || "");
|
||||
const content = truncateContent(`${mention ? `${mention} ` : ""}**${payload.title}**\n${payload.message}`);
|
||||
return {
|
||||
url: String(url || "").trim(),
|
||||
init: {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ username: WEBHOOK_USERNAME, content })
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function delayMs(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
async function consumeBody(response: Response): Promise<string> {
|
||||
try {
|
||||
return await response.text();
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
function parseRetryAfterMs(response: Response, bodyText: string): number {
|
||||
const headerSeconds = Number(response.headers.get("X-RateLimit-Reset-After") || response.headers.get("Retry-After") || "");
|
||||
if (Number.isFinite(headerSeconds) && headerSeconds > 0) {
|
||||
return Math.ceil(headerSeconds * 1000);
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(bodyText) as { retry_after?: number };
|
||||
if (typeof parsed.retry_after === "number" && parsed.retry_after > 0) {
|
||||
return Math.ceil(parsed.retry_after * 1000);
|
||||
}
|
||||
} catch {
|
||||
}
|
||||
return 1500;
|
||||
}
|
||||
|
||||
async function sendOnce(url: string, payload: NotifyPayload, fetchFn: typeof fetch): Promise<{ ok: boolean; retryable: boolean; waitMs: number; detail: string }> {
|
||||
try {
|
||||
const request = buildNotifyRequest(url, payload);
|
||||
const response = await fetchFn(request.url, { ...request.init, signal: AbortSignal.timeout(NOTIFY_TIMEOUT_MS) });
|
||||
const bodyText = await consumeBody(response);
|
||||
if (response.ok) {
|
||||
return { ok: true, retryable: false, waitMs: 0, detail: "" };
|
||||
}
|
||||
if (response.status === 429) {
|
||||
const waitMs = Math.min(RATE_LIMIT_MAX_WAIT_MS, parseRetryAfterMs(response, bodyText));
|
||||
return { ok: false, retryable: true, waitMs, detail: `HTTP 429 (Rate-Limit, warte ${waitMs}ms)` };
|
||||
}
|
||||
if (response.status >= 500) {
|
||||
return { ok: false, retryable: true, waitMs: 0, detail: `HTTP ${response.status}` };
|
||||
}
|
||||
return { ok: false, retryable: false, waitMs: 0, detail: `HTTP ${response.status}` };
|
||||
} catch (error) {
|
||||
return { ok: false, retryable: true, waitMs: 0, detail: String(error) };
|
||||
}
|
||||
}
|
||||
|
||||
// All sends share one chain: serialized with a minimum gap so burst completions
|
||||
// (many packages finishing together) stay under Discord's 5-per-2s webhook
|
||||
// bucket instead of getting dropped as 429s.
|
||||
let sendChain: Promise<void> = Promise.resolve();
|
||||
let lastSendCompletedAt = 0;
|
||||
|
||||
export async function sendNotification(
|
||||
url: string,
|
||||
payload: NotifyPayload,
|
||||
fetchFn: typeof fetch = fetch,
|
||||
sleepFn: (ms: number) => Promise<void> = delayMs
|
||||
): Promise<boolean> {
|
||||
if (!isNotifyUrlValid(url)) {
|
||||
if (String(url || "").trim()) {
|
||||
logger.warn(`Benachrichtigung nicht gesendet: ungueltige Webhook-URL (muss mit http(s):// beginnen): ${payload.title}`);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
const result = sendChain.then(async () => {
|
||||
const sinceLast = Date.now() - lastSendCompletedAt;
|
||||
if (sinceLast < MIN_SEND_GAP_MS) {
|
||||
await sleepFn(MIN_SEND_GAP_MS - sinceLast);
|
||||
}
|
||||
let lastDetail = "";
|
||||
for (let attempt = 0; ; attempt += 1) {
|
||||
const outcome = await sendOnce(url, payload, fetchFn);
|
||||
if (outcome.ok) {
|
||||
return true;
|
||||
}
|
||||
lastDetail = outcome.detail;
|
||||
if (!outcome.retryable || attempt >= RETRY_DELAYS_MS.length) {
|
||||
break;
|
||||
}
|
||||
await sleepFn(outcome.waitMs > 0 ? outcome.waitMs : RETRY_DELAYS_MS[attempt]);
|
||||
}
|
||||
logger.warn(`Benachrichtigung fehlgeschlagen (${lastDetail}): ${payload.title}`);
|
||||
return false;
|
||||
});
|
||||
sendChain = result.then(() => {
|
||||
lastSendCompletedAt = Date.now();
|
||||
}, () => {
|
||||
lastSendCompletedAt = Date.now();
|
||||
});
|
||||
return result;
|
||||
}
|
||||
import { logger } from "./logger";
|
||||
|
||||
export interface NotifyPayload {
|
||||
title: string;
|
||||
message: string;
|
||||
mention?: string;
|
||||
}
|
||||
|
||||
const NOTIFY_TIMEOUT_MS = 5000;
|
||||
const WEBHOOK_USERNAME = "Real-Debrid Downloader";
|
||||
const MIN_SEND_GAP_MS = 450;
|
||||
const RETRY_DELAYS_MS = [1000, 2500];
|
||||
const RATE_LIMIT_MAX_WAIT_MS = 15_000;
|
||||
const CONTENT_MAX_CHARS = 2000;
|
||||
|
||||
export function isNotifyUrlValid(url: string): boolean {
|
||||
return /^https?:\/\/\S+$/i.test(String(url || "").trim());
|
||||
}
|
||||
|
||||
// Accepts a bare Discord user ID (wrapped as <@id> so it actually pings),
|
||||
// @everyone/@here, or an already-formed <@...>/<@&...> mention as-is.
|
||||
export function normalizeDiscordMention(raw: string): string {
|
||||
const text = String(raw || "").trim();
|
||||
if (!text) {
|
||||
return "";
|
||||
}
|
||||
if (/^\d{5,}$/.test(text)) {
|
||||
return `<@${text}>`;
|
||||
}
|
||||
return text;
|
||||
}
|
||||
|
||||
// Discord counts the limit itself; slicing UTF-16 units can split a surrogate
|
||||
// pair at the boundary, which Discord rejects as invalid content.
|
||||
export function truncateContent(content: string, maxChars = CONTENT_MAX_CHARS): string {
|
||||
if (content.length <= maxChars) {
|
||||
return content;
|
||||
}
|
||||
let cut = content.slice(0, maxChars);
|
||||
const last = cut.charCodeAt(cut.length - 1);
|
||||
if (last >= 0xd800 && last <= 0xdbff) {
|
||||
cut = cut.slice(0, -1);
|
||||
}
|
||||
return cut;
|
||||
}
|
||||
|
||||
export function buildNotifyRequest(url: string, payload: NotifyPayload): { url: string; init: RequestInit } {
|
||||
const mention = normalizeDiscordMention(payload.mention || "");
|
||||
const content = truncateContent(`${mention ? `${mention} ` : ""}**${payload.title}**\n${payload.message}`);
|
||||
return {
|
||||
url: String(url || "").trim(),
|
||||
init: {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ username: WEBHOOK_USERNAME, content })
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function delayMs(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
async function consumeBody(response: Response): Promise<string> {
|
||||
try {
|
||||
return await response.text();
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
function parseRetryAfterMs(response: Response, bodyText: string): number {
|
||||
const headerSeconds = Number(response.headers.get("X-RateLimit-Reset-After") || response.headers.get("Retry-After") || "");
|
||||
if (Number.isFinite(headerSeconds) && headerSeconds > 0) {
|
||||
return Math.ceil(headerSeconds * 1000);
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(bodyText) as { retry_after?: number };
|
||||
if (typeof parsed.retry_after === "number" && parsed.retry_after > 0) {
|
||||
return Math.ceil(parsed.retry_after * 1000);
|
||||
}
|
||||
} catch {
|
||||
}
|
||||
return 1500;
|
||||
}
|
||||
|
||||
async function sendOnce(url: string, payload: NotifyPayload, fetchFn: typeof fetch): Promise<{ ok: boolean; retryable: boolean; waitMs: number; detail: string }> {
|
||||
try {
|
||||
const request = buildNotifyRequest(url, payload);
|
||||
const response = await fetchFn(request.url, { ...request.init, signal: AbortSignal.timeout(NOTIFY_TIMEOUT_MS) });
|
||||
const bodyText = await consumeBody(response);
|
||||
if (response.ok) {
|
||||
return { ok: true, retryable: false, waitMs: 0, detail: "" };
|
||||
}
|
||||
if (response.status === 429) {
|
||||
const waitMs = Math.min(RATE_LIMIT_MAX_WAIT_MS, parseRetryAfterMs(response, bodyText));
|
||||
return { ok: false, retryable: true, waitMs, detail: `HTTP 429 (Rate-Limit, warte ${waitMs}ms)` };
|
||||
}
|
||||
if (response.status >= 500) {
|
||||
return { ok: false, retryable: true, waitMs: 0, detail: `HTTP ${response.status}` };
|
||||
}
|
||||
return { ok: false, retryable: false, waitMs: 0, detail: `HTTP ${response.status}` };
|
||||
} catch (error) {
|
||||
return { ok: false, retryable: true, waitMs: 0, detail: String(error) };
|
||||
}
|
||||
}
|
||||
|
||||
// All sends share one chain: serialized with a minimum gap so burst completions
|
||||
// (many packages finishing together) stay under Discord's 5-per-2s webhook
|
||||
// bucket instead of getting dropped as 429s.
|
||||
let sendChain: Promise<void> = Promise.resolve();
|
||||
let lastSendCompletedAt = 0;
|
||||
|
||||
export async function sendNotification(
|
||||
url: string,
|
||||
payload: NotifyPayload,
|
||||
fetchFn: typeof fetch = fetch,
|
||||
sleepFn: (ms: number) => Promise<void> = delayMs
|
||||
): Promise<boolean> {
|
||||
if (!isNotifyUrlValid(url)) {
|
||||
if (String(url || "").trim()) {
|
||||
logger.warn(`Benachrichtigung nicht gesendet: ungueltige Webhook-URL (muss mit http(s):// beginnen): ${payload.title}`);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
const result = sendChain.then(async () => {
|
||||
const sinceLast = Date.now() - lastSendCompletedAt;
|
||||
if (sinceLast < MIN_SEND_GAP_MS) {
|
||||
await sleepFn(MIN_SEND_GAP_MS - sinceLast);
|
||||
}
|
||||
let lastDetail = "";
|
||||
for (let attempt = 0; ; attempt += 1) {
|
||||
const outcome = await sendOnce(url, payload, fetchFn);
|
||||
if (outcome.ok) {
|
||||
return true;
|
||||
}
|
||||
lastDetail = outcome.detail;
|
||||
if (!outcome.retryable || attempt >= RETRY_DELAYS_MS.length) {
|
||||
break;
|
||||
}
|
||||
await sleepFn(outcome.waitMs > 0 ? outcome.waitMs : RETRY_DELAYS_MS[attempt]);
|
||||
}
|
||||
logger.warn(`Benachrichtigung fehlgeschlagen (${lastDetail}): ${payload.title}`);
|
||||
return false;
|
||||
});
|
||||
sendChain = result.then(() => {
|
||||
lastSendCompletedAt = Date.now();
|
||||
}, () => {
|
||||
lastSendCompletedAt = Date.now();
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
@ -1,273 +0,0 @@
|
||||
import crypto from "node:crypto";
|
||||
import zlib from "node:zlib";
|
||||
import type { AppSettings } from "../shared/types";
|
||||
|
||||
const KEY_PREFIX = "MDD2-";
|
||||
const KEY_BODY_LENGTH = 70;
|
||||
const RECORD_ID_LENGTH = 16;
|
||||
const MASTER_KEY_LENGTH = 32;
|
||||
const CHECKSUM_LENGTH = 4;
|
||||
const NONCE_LENGTH = 12;
|
||||
const AUTH_TAG_LENGTH = 16;
|
||||
const BLOB_VERSION = 1;
|
||||
const MAX_BLOB_BYTES = 256 * 1024;
|
||||
const MAX_RESPONSE_BYTES = 512 * 1024;
|
||||
const MAX_PLAINTEXT_BYTES = 512 * 1024;
|
||||
const REQUEST_TIMEOUT_MS = 12_000;
|
||||
const KEY_CONTEXT = Buffer.from("MDD2-ONLINE-KEY-V1", "utf8");
|
||||
const AAD_CONTEXT = Buffer.from("MDD-ONLINE-BACKUP-V1", "utf8");
|
||||
|
||||
export interface OnlineSettingsPayload {
|
||||
version: 1;
|
||||
kind: "settings-only";
|
||||
appVersion: string;
|
||||
exportedAt: string;
|
||||
settings: AppSettings;
|
||||
}
|
||||
|
||||
export interface OnlineBackupRecord {
|
||||
id: string;
|
||||
blob: string;
|
||||
deleteVerifier: string;
|
||||
}
|
||||
|
||||
export interface CreatedOnlineBackup {
|
||||
key: string;
|
||||
record: OnlineBackupRecord;
|
||||
}
|
||||
|
||||
export interface ParsedOnlineBackupKey {
|
||||
id: string;
|
||||
idBytes: Buffer;
|
||||
masterKey: Buffer;
|
||||
}
|
||||
|
||||
function checksum(idBytes: Buffer, masterKey: Buffer): Buffer {
|
||||
return crypto.createHash("sha256").update(KEY_CONTEXT).update(idBytes).update(masterKey).digest().subarray(0, CHECKSUM_LENGTH);
|
||||
}
|
||||
|
||||
function deriveSecret(masterKey: Buffer, idBytes: Buffer, purpose: string): Buffer {
|
||||
return Buffer.from(crypto.hkdfSync("sha256", masterKey, idBytes, Buffer.from(`MDD-ONLINE-${purpose}-V1`, "utf8"), 32));
|
||||
}
|
||||
|
||||
function deriveDeleteSecret(parsed: ParsedOnlineBackupKey): Buffer {
|
||||
return deriveSecret(parsed.masterKey, parsed.idBytes, "DELETE");
|
||||
}
|
||||
|
||||
function aad(idBytes: Buffer): Buffer {
|
||||
return Buffer.concat([AAD_CONTEXT, idBytes]);
|
||||
}
|
||||
|
||||
function encodeKey(idBytes: Buffer, masterKey: Buffer): string {
|
||||
const body = Buffer.concat([idBytes, masterKey, checksum(idBytes, masterKey)]).toString("base64url");
|
||||
return `${KEY_PREFIX}${body}`;
|
||||
}
|
||||
|
||||
function validatePayload(value: unknown): OnlineSettingsPayload {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
throw new Error("Online-Sicherung enthält keine gültigen Einstellungen");
|
||||
}
|
||||
const record = value as Record<string, unknown>;
|
||||
if (
|
||||
record.version !== 1
|
||||
|| record.kind !== "settings-only"
|
||||
|| typeof record.appVersion !== "string"
|
||||
|| typeof record.exportedAt !== "string"
|
||||
|| !record.settings
|
||||
|| typeof record.settings !== "object"
|
||||
|| Array.isArray(record.settings)
|
||||
|| "session" in record
|
||||
|| "history" in record
|
||||
) {
|
||||
throw new Error("Online-Sicherung enthält keine gültigen Einstellungen");
|
||||
}
|
||||
return record as unknown as OnlineSettingsPayload;
|
||||
}
|
||||
|
||||
function endpoint(baseUrl: string, relativePath: string): string {
|
||||
const normalized = String(baseUrl || "").trim().replace(/\/+$/, "");
|
||||
const url = new URL(`${normalized}${relativePath}`);
|
||||
if (url.protocol !== "https:" && !["127.0.0.1", "localhost", "::1"].includes(url.hostname)) {
|
||||
throw new Error("Online-Sicherungen benötigen eine sichere HTTPS-Verbindung");
|
||||
}
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
async function request(url: string, init?: RequestInit): Promise<Response> {
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
|
||||
try {
|
||||
return await fetch(url, { ...init, signal: controller.signal });
|
||||
} catch (error) {
|
||||
if (controller.signal.aborted) {
|
||||
throw new Error("Online-Sicherungsdienst antwortet nicht");
|
||||
}
|
||||
throw new Error(`Online-Sicherungsdienst nicht erreichbar: ${String((error as Error)?.message || error)}`);
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
async function readLimitedText(response: Response): Promise<string> {
|
||||
const contentLength = Number(response.headers.get("content-length") || "0");
|
||||
if (Number.isFinite(contentLength) && contentLength > MAX_RESPONSE_BYTES) {
|
||||
throw new Error("Antwort des Online-Sicherungsdienstes ist zu groß");
|
||||
}
|
||||
if (!response.body) return "";
|
||||
const reader = response.body.getReader();
|
||||
const chunks: Uint8Array[] = [];
|
||||
let total = 0;
|
||||
while (true) {
|
||||
const result = await reader.read();
|
||||
if (result.done) break;
|
||||
total += result.value.byteLength;
|
||||
if (total > MAX_RESPONSE_BYTES) {
|
||||
await reader.cancel();
|
||||
throw new Error("Antwort des Online-Sicherungsdienstes ist zu groß");
|
||||
}
|
||||
chunks.push(result.value);
|
||||
}
|
||||
return Buffer.concat(chunks.map((chunk) => Buffer.from(chunk))).toString("utf8");
|
||||
}
|
||||
|
||||
export function parseOnlineBackupKey(key: string): ParsedOnlineBackupKey {
|
||||
const normalized = String(key || "").trim();
|
||||
if (!new RegExp(`^${KEY_PREFIX}[A-Za-z0-9_-]{${KEY_BODY_LENGTH}}$`).test(normalized)) {
|
||||
throw new Error("Online-Sicherungsschlüssel ist ungültig");
|
||||
}
|
||||
const decoded = Buffer.from(normalized.slice(KEY_PREFIX.length), "base64url");
|
||||
if (decoded.length !== RECORD_ID_LENGTH + MASTER_KEY_LENGTH + CHECKSUM_LENGTH) {
|
||||
throw new Error("Online-Sicherungsschlüssel ist ungültig");
|
||||
}
|
||||
if (decoded.toString("base64url") !== normalized.slice(KEY_PREFIX.length)) {
|
||||
throw new Error("Online-Sicherungsschlüssel ist ungültig");
|
||||
}
|
||||
const idBytes = decoded.subarray(0, RECORD_ID_LENGTH);
|
||||
const masterKey = decoded.subarray(RECORD_ID_LENGTH, RECORD_ID_LENGTH + MASTER_KEY_LENGTH);
|
||||
const actualChecksum = decoded.subarray(RECORD_ID_LENGTH + MASTER_KEY_LENGTH);
|
||||
const expectedChecksum = checksum(idBytes, masterKey);
|
||||
if (!crypto.timingSafeEqual(actualChecksum, expectedChecksum)) {
|
||||
throw new Error("Online-Sicherungsschlüssel ist beschädigt");
|
||||
}
|
||||
return { id: idBytes.toString("base64url"), idBytes: Buffer.from(idBytes), masterKey: Buffer.from(masterKey) };
|
||||
}
|
||||
|
||||
export function createOnlineBackup(settings: AppSettings, appVersion: string, exportedAt = new Date().toISOString()): CreatedOnlineBackup {
|
||||
const idBytes = crypto.randomBytes(RECORD_ID_LENGTH);
|
||||
const masterKey = crypto.randomBytes(MASTER_KEY_LENGTH);
|
||||
const key = encodeKey(idBytes, masterKey);
|
||||
const encryptionKey = deriveSecret(masterKey, idBytes, "ENCRYPTION");
|
||||
const nonce = crypto.randomBytes(NONCE_LENGTH);
|
||||
const payload: OnlineSettingsPayload = {
|
||||
version: 1,
|
||||
kind: "settings-only",
|
||||
appVersion,
|
||||
exportedAt,
|
||||
settings: JSON.parse(JSON.stringify(settings)) as AppSettings
|
||||
};
|
||||
const plaintext = Buffer.from(JSON.stringify(payload), "utf8");
|
||||
if (plaintext.length > MAX_PLAINTEXT_BYTES) {
|
||||
throw new Error("Einstellungen sind für eine Online-Sicherung zu groß");
|
||||
}
|
||||
const compressed = zlib.gzipSync(plaintext, { level: 9 });
|
||||
const cipher = crypto.createCipheriv("aes-256-gcm", encryptionKey, nonce, { authTagLength: AUTH_TAG_LENGTH });
|
||||
cipher.setAAD(aad(idBytes));
|
||||
const ciphertext = Buffer.concat([cipher.update(compressed), cipher.final()]);
|
||||
const blobBytes = Buffer.concat([Buffer.from([BLOB_VERSION]), nonce, cipher.getAuthTag(), ciphertext]);
|
||||
if (blobBytes.length > MAX_BLOB_BYTES) {
|
||||
throw new Error("Einstellungen sind für eine Online-Sicherung zu groß");
|
||||
}
|
||||
const parsed = parseOnlineBackupKey(key);
|
||||
const deleteVerifier = crypto.createHash("sha256").update(deriveDeleteSecret(parsed)).digest("base64url");
|
||||
return {
|
||||
key,
|
||||
record: {
|
||||
id: parsed.id,
|
||||
blob: blobBytes.toString("base64url"),
|
||||
deleteVerifier
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function restoreOnlineBackup(key: string, blob: string): OnlineSettingsPayload {
|
||||
const parsed = parseOnlineBackupKey(key);
|
||||
if (!/^[A-Za-z0-9_-]+$/.test(blob) || blob.length > Math.ceil(MAX_BLOB_BYTES * 4 / 3) + 4) {
|
||||
throw new Error("Online-Sicherung ist beschädigt");
|
||||
}
|
||||
const bytes = Buffer.from(blob, "base64url");
|
||||
if (bytes.toString("base64url") !== blob) {
|
||||
throw new Error("Online-Sicherung ist beschädigt");
|
||||
}
|
||||
if (bytes.length < 1 + NONCE_LENGTH + AUTH_TAG_LENGTH || bytes[0] !== BLOB_VERSION) {
|
||||
throw new Error("Online-Sicherung ist beschädigt");
|
||||
}
|
||||
const nonce = bytes.subarray(1, 1 + NONCE_LENGTH);
|
||||
const tag = bytes.subarray(1 + NONCE_LENGTH, 1 + NONCE_LENGTH + AUTH_TAG_LENGTH);
|
||||
const ciphertext = bytes.subarray(1 + NONCE_LENGTH + AUTH_TAG_LENGTH);
|
||||
try {
|
||||
const decipher = crypto.createDecipheriv(
|
||||
"aes-256-gcm",
|
||||
deriveSecret(parsed.masterKey, parsed.idBytes, "ENCRYPTION"),
|
||||
nonce,
|
||||
{ authTagLength: AUTH_TAG_LENGTH }
|
||||
);
|
||||
decipher.setAAD(aad(parsed.idBytes));
|
||||
decipher.setAuthTag(tag);
|
||||
const compressed = Buffer.concat([decipher.update(ciphertext), decipher.final()]);
|
||||
const plaintext = zlib.gunzipSync(compressed, { maxOutputLength: MAX_PLAINTEXT_BYTES }).toString("utf8");
|
||||
return validatePayload(JSON.parse(plaintext));
|
||||
} catch (error) {
|
||||
if (error instanceof Error && /keine gültigen Einstellungen/.test(error.message)) throw error;
|
||||
throw new Error("Online-Sicherung konnte nicht entschlüsselt werden oder ist beschädigt");
|
||||
}
|
||||
}
|
||||
|
||||
export async function uploadOnlineBackup(record: OnlineBackupRecord, baseUrl: string): Promise<void> {
|
||||
const response = await request(endpoint(baseUrl, "/v1/backups"), {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json", accept: "application/json" },
|
||||
body: JSON.stringify(record)
|
||||
});
|
||||
await readLimitedText(response);
|
||||
if (response.status !== 201) {
|
||||
throw new Error("Online-Sicherung konnte nicht gespeichert werden");
|
||||
}
|
||||
}
|
||||
|
||||
export async function downloadOnlineBackup(key: string, baseUrl: string): Promise<OnlineSettingsPayload> {
|
||||
const parsed = parseOnlineBackupKey(key);
|
||||
const response = await request(endpoint(baseUrl, "/v1/backups/restore"), {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json", accept: "application/json" },
|
||||
body: JSON.stringify({ id: parsed.id })
|
||||
});
|
||||
const body = await readLimitedText(response);
|
||||
if (response.status !== 200) {
|
||||
throw new Error(response.status === 404 ? "Online-Sicherung wurde nicht gefunden" : "Online-Sicherung konnte nicht geladen werden");
|
||||
}
|
||||
let value: unknown;
|
||||
try {
|
||||
value = JSON.parse(body);
|
||||
} catch {
|
||||
throw new Error("Online-Sicherungsdienst hat ungültige Daten geliefert");
|
||||
}
|
||||
const blob = (value as { blob?: unknown })?.blob;
|
||||
if (typeof blob !== "string") {
|
||||
throw new Error("Online-Sicherungsdienst hat ungültige Daten geliefert");
|
||||
}
|
||||
return restoreOnlineBackup(key, blob);
|
||||
}
|
||||
|
||||
export async function deleteOnlineBackup(key: string, baseUrl: string): Promise<void> {
|
||||
const parsed = parseOnlineBackupKey(key);
|
||||
const deleteSecret = deriveDeleteSecret(parsed).toString("base64url");
|
||||
const response = await request(endpoint(baseUrl, "/v1/backups/delete"), {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json", accept: "application/json" },
|
||||
body: JSON.stringify({ id: parsed.id, deleteSecret })
|
||||
});
|
||||
if (response.status !== 204) {
|
||||
await readLimitedText(response);
|
||||
throw new Error(response.status === 404 ? "Online-Sicherung wurde nicht gefunden" : "Online-Sicherung konnte nicht gelöscht werden");
|
||||
}
|
||||
}
|
||||
@ -1,230 +1,230 @@
|
||||
import fs from "node:fs";
|
||||
import { logTimestamp } from "./log-timestamp";
|
||||
import path from "node:path";
|
||||
import crypto from "node:crypto";
|
||||
|
||||
const PACKAGE_LOG_FLUSH_INTERVAL_MS = 200;
|
||||
const PACKAGE_LOG_RETENTION_DAYS = 30;
|
||||
|
||||
type PackageLogLevel = "INFO" | "WARN" | "ERROR";
|
||||
|
||||
export interface PackageLogMeta {
|
||||
packageId: string;
|
||||
name: string;
|
||||
outputDir: string;
|
||||
extractDir: string;
|
||||
}
|
||||
|
||||
let packageLogsDir: string | null = null;
|
||||
const knownLogPaths = new Map<string, string>();
|
||||
const pendingLinesByPackage = new Map<string, string[]>();
|
||||
const initializedThisProcess = new Set<string>();
|
||||
let flushTimer: NodeJS.Timeout | null = null;
|
||||
|
||||
function normalizePackageId(packageId: string): string {
|
||||
const trimmed = String(packageId || "").trim();
|
||||
if (!trimmed) {
|
||||
return "";
|
||||
}
|
||||
const safePrefix = trimmed
|
||||
.replace(/[^a-zA-Z0-9._-]/g, "_")
|
||||
.replace(/_+/g, "_")
|
||||
.slice(0, 64)
|
||||
.replace(/^_+|_+$/g, "");
|
||||
const hash = crypto.createHash("sha1").update(trimmed).digest("hex").slice(0, 12);
|
||||
return `${safePrefix || "pkg"}_${hash}`;
|
||||
}
|
||||
|
||||
function sanitizeFieldValue(value: unknown): string {
|
||||
if (value === undefined || value === null) {
|
||||
return "";
|
||||
}
|
||||
if (typeof value === "string") {
|
||||
return value.replace(/\r?\n/g, "\\n");
|
||||
}
|
||||
if (typeof value === "number" || typeof value === "boolean") {
|
||||
return String(value);
|
||||
}
|
||||
try {
|
||||
return JSON.stringify(value).replace(/\r?\n/g, "\\n");
|
||||
} catch {
|
||||
return String(value);
|
||||
}
|
||||
}
|
||||
|
||||
function formatFields(fields?: Record<string, unknown>): string {
|
||||
if (!fields) {
|
||||
return "";
|
||||
}
|
||||
const parts = Object.entries(fields)
|
||||
.filter(([, value]) => value !== undefined && value !== null && sanitizeFieldValue(value) !== "")
|
||||
.map(([key, value]) => `${key}=${sanitizeFieldValue(value)}`);
|
||||
return parts.length > 0 ? ` | ${parts.join(" | ")}` : "";
|
||||
}
|
||||
|
||||
function getPackageLogFilePathFromNormalized(normalized: string): string | null {
|
||||
if (!normalized || !packageLogsDir) {
|
||||
return null;
|
||||
}
|
||||
const existing = knownLogPaths.get(normalized);
|
||||
if (existing) {
|
||||
return existing;
|
||||
}
|
||||
const logPath = path.join(packageLogsDir, `package_${normalized}.txt`);
|
||||
knownLogPaths.set(normalized, logPath);
|
||||
return logPath;
|
||||
}
|
||||
|
||||
function getPackageLogFilePath(packageId: string): string | null {
|
||||
return getPackageLogFilePathFromNormalized(normalizePackageId(packageId));
|
||||
}
|
||||
|
||||
function flushPending(): void {
|
||||
for (const [packageId, lines] of pendingLinesByPackage.entries()) {
|
||||
if (lines.length === 0) {
|
||||
continue;
|
||||
}
|
||||
const logPath = getPackageLogFilePathFromNormalized(packageId);
|
||||
if (!logPath) {
|
||||
continue;
|
||||
}
|
||||
const chunk = lines.join("");
|
||||
pendingLinesByPackage.set(packageId, []);
|
||||
try {
|
||||
fs.appendFileSync(logPath, chunk, "utf8");
|
||||
} catch {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function scheduleFlush(): void {
|
||||
if (flushTimer) {
|
||||
return;
|
||||
}
|
||||
flushTimer = setTimeout(() => {
|
||||
flushTimer = null;
|
||||
flushPending();
|
||||
}, PACKAGE_LOG_FLUSH_INTERVAL_MS);
|
||||
}
|
||||
|
||||
async function cleanupOldPackageLogs(dir: string): Promise<void> {
|
||||
try {
|
||||
const files = await fs.promises.readdir(dir);
|
||||
const cutoff = Date.now() - PACKAGE_LOG_RETENTION_DAYS * 24 * 60 * 60 * 1000;
|
||||
for (const file of files) {
|
||||
if (!file.startsWith("package_") || !file.endsWith(".txt")) {
|
||||
continue;
|
||||
}
|
||||
const filePath = path.join(dir, file);
|
||||
try {
|
||||
const stat = await fs.promises.stat(filePath);
|
||||
if (stat.mtimeMs < cutoff) {
|
||||
await fs.promises.unlink(filePath);
|
||||
}
|
||||
} catch {
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
}
|
||||
}
|
||||
|
||||
function appendLine(packageId: string, line: string): void {
|
||||
const normalized = normalizePackageId(packageId);
|
||||
if (!normalized) {
|
||||
return;
|
||||
}
|
||||
const lines = pendingLinesByPackage.get(normalized) || [];
|
||||
lines.push(line);
|
||||
pendingLinesByPackage.set(normalized, lines);
|
||||
scheduleFlush();
|
||||
}
|
||||
|
||||
export function initPackageLogs(baseDir: string): void {
|
||||
packageLogsDir = path.join(baseDir, "package-logs");
|
||||
try {
|
||||
fs.mkdirSync(packageLogsDir, { recursive: true });
|
||||
} catch {
|
||||
packageLogsDir = null;
|
||||
return;
|
||||
}
|
||||
void cleanupOldPackageLogs(packageLogsDir);
|
||||
}
|
||||
|
||||
export function ensurePackageLog(meta: PackageLogMeta): string | null {
|
||||
const normalizedPackageId = normalizePackageId(meta.packageId);
|
||||
const logPath = getPackageLogFilePath(meta.packageId);
|
||||
if (!logPath) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
fs.mkdirSync(path.dirname(logPath), { recursive: true });
|
||||
if (!fs.existsSync(logPath)) {
|
||||
fs.writeFileSync(logPath, "", "utf8");
|
||||
}
|
||||
if (!initializedThisProcess.has(normalizedPackageId)) {
|
||||
initializedThisProcess.add(normalizedPackageId);
|
||||
const startedAt = logTimestamp();
|
||||
fs.appendFileSync(
|
||||
logPath,
|
||||
`=== Paket-Log Start: ${startedAt} | packageId=${sanitizeFieldValue(String(meta.packageId || ""))} | logKey=${normalizedPackageId} | name=${sanitizeFieldValue(meta.name)} ===\n`,
|
||||
"utf8"
|
||||
);
|
||||
fs.appendFileSync(
|
||||
logPath,
|
||||
`${logTimestamp()} [INFO] Paket-Kontext initialisiert${formatFields({
|
||||
name: meta.name,
|
||||
outputDir: meta.outputDir,
|
||||
extractDir: meta.extractDir
|
||||
})}\n`,
|
||||
"utf8"
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
return logPath;
|
||||
}
|
||||
|
||||
export function logPackageEvent(
|
||||
packageId: string,
|
||||
level: PackageLogLevel,
|
||||
message: string,
|
||||
fields?: Record<string, unknown>
|
||||
): void {
|
||||
const logPath = getPackageLogFilePath(packageId);
|
||||
if (!logPath) {
|
||||
return;
|
||||
}
|
||||
const line = `${logTimestamp()} [${level}] ${message}${formatFields(fields)}\n`;
|
||||
appendLine(packageId, line);
|
||||
}
|
||||
|
||||
export function getPackageLogPath(packageId: string): string | null {
|
||||
const logPath = getPackageLogFilePath(packageId);
|
||||
if (!logPath) {
|
||||
return null;
|
||||
}
|
||||
return fs.existsSync(logPath) ? logPath : null;
|
||||
}
|
||||
|
||||
export function shutdownPackageLogs(): void {
|
||||
if (flushTimer) {
|
||||
clearTimeout(flushTimer);
|
||||
flushTimer = null;
|
||||
}
|
||||
flushPending();
|
||||
for (const packageId of knownLogPaths.keys()) {
|
||||
const logPath = getPackageLogFilePathFromNormalized(packageId);
|
||||
if (!logPath) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
fs.appendFileSync(logPath, `=== Paket-Log Ende: ${logTimestamp()} ===\n`, "utf8");
|
||||
} catch {
|
||||
}
|
||||
}
|
||||
pendingLinesByPackage.clear();
|
||||
knownLogPaths.clear();
|
||||
initializedThisProcess.clear();
|
||||
packageLogsDir = null;
|
||||
}
|
||||
import fs from "node:fs";
|
||||
import { logTimestamp } from "./log-timestamp";
|
||||
import path from "node:path";
|
||||
import crypto from "node:crypto";
|
||||
|
||||
const PACKAGE_LOG_FLUSH_INTERVAL_MS = 200;
|
||||
const PACKAGE_LOG_RETENTION_DAYS = 30;
|
||||
|
||||
type PackageLogLevel = "INFO" | "WARN" | "ERROR";
|
||||
|
||||
export interface PackageLogMeta {
|
||||
packageId: string;
|
||||
name: string;
|
||||
outputDir: string;
|
||||
extractDir: string;
|
||||
}
|
||||
|
||||
let packageLogsDir: string | null = null;
|
||||
const knownLogPaths = new Map<string, string>();
|
||||
const pendingLinesByPackage = new Map<string, string[]>();
|
||||
const initializedThisProcess = new Set<string>();
|
||||
let flushTimer: NodeJS.Timeout | null = null;
|
||||
|
||||
function normalizePackageId(packageId: string): string {
|
||||
const trimmed = String(packageId || "").trim();
|
||||
if (!trimmed) {
|
||||
return "";
|
||||
}
|
||||
const safePrefix = trimmed
|
||||
.replace(/[^a-zA-Z0-9._-]/g, "_")
|
||||
.replace(/_+/g, "_")
|
||||
.slice(0, 64)
|
||||
.replace(/^_+|_+$/g, "");
|
||||
const hash = crypto.createHash("sha1").update(trimmed).digest("hex").slice(0, 12);
|
||||
return `${safePrefix || "pkg"}_${hash}`;
|
||||
}
|
||||
|
||||
function sanitizeFieldValue(value: unknown): string {
|
||||
if (value === undefined || value === null) {
|
||||
return "";
|
||||
}
|
||||
if (typeof value === "string") {
|
||||
return value.replace(/\r?\n/g, "\\n");
|
||||
}
|
||||
if (typeof value === "number" || typeof value === "boolean") {
|
||||
return String(value);
|
||||
}
|
||||
try {
|
||||
return JSON.stringify(value).replace(/\r?\n/g, "\\n");
|
||||
} catch {
|
||||
return String(value);
|
||||
}
|
||||
}
|
||||
|
||||
function formatFields(fields?: Record<string, unknown>): string {
|
||||
if (!fields) {
|
||||
return "";
|
||||
}
|
||||
const parts = Object.entries(fields)
|
||||
.filter(([, value]) => value !== undefined && value !== null && sanitizeFieldValue(value) !== "")
|
||||
.map(([key, value]) => `${key}=${sanitizeFieldValue(value)}`);
|
||||
return parts.length > 0 ? ` | ${parts.join(" | ")}` : "";
|
||||
}
|
||||
|
||||
function getPackageLogFilePathFromNormalized(normalized: string): string | null {
|
||||
if (!normalized || !packageLogsDir) {
|
||||
return null;
|
||||
}
|
||||
const existing = knownLogPaths.get(normalized);
|
||||
if (existing) {
|
||||
return existing;
|
||||
}
|
||||
const logPath = path.join(packageLogsDir, `package_${normalized}.txt`);
|
||||
knownLogPaths.set(normalized, logPath);
|
||||
return logPath;
|
||||
}
|
||||
|
||||
function getPackageLogFilePath(packageId: string): string | null {
|
||||
return getPackageLogFilePathFromNormalized(normalizePackageId(packageId));
|
||||
}
|
||||
|
||||
function flushPending(): void {
|
||||
for (const [packageId, lines] of pendingLinesByPackage.entries()) {
|
||||
if (lines.length === 0) {
|
||||
continue;
|
||||
}
|
||||
const logPath = getPackageLogFilePathFromNormalized(packageId);
|
||||
if (!logPath) {
|
||||
continue;
|
||||
}
|
||||
const chunk = lines.join("");
|
||||
pendingLinesByPackage.set(packageId, []);
|
||||
try {
|
||||
fs.appendFileSync(logPath, chunk, "utf8");
|
||||
} catch {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function scheduleFlush(): void {
|
||||
if (flushTimer) {
|
||||
return;
|
||||
}
|
||||
flushTimer = setTimeout(() => {
|
||||
flushTimer = null;
|
||||
flushPending();
|
||||
}, PACKAGE_LOG_FLUSH_INTERVAL_MS);
|
||||
}
|
||||
|
||||
async function cleanupOldPackageLogs(dir: string): Promise<void> {
|
||||
try {
|
||||
const files = await fs.promises.readdir(dir);
|
||||
const cutoff = Date.now() - PACKAGE_LOG_RETENTION_DAYS * 24 * 60 * 60 * 1000;
|
||||
for (const file of files) {
|
||||
if (!file.startsWith("package_") || !file.endsWith(".txt")) {
|
||||
continue;
|
||||
}
|
||||
const filePath = path.join(dir, file);
|
||||
try {
|
||||
const stat = await fs.promises.stat(filePath);
|
||||
if (stat.mtimeMs < cutoff) {
|
||||
await fs.promises.unlink(filePath);
|
||||
}
|
||||
} catch {
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
}
|
||||
}
|
||||
|
||||
function appendLine(packageId: string, line: string): void {
|
||||
const normalized = normalizePackageId(packageId);
|
||||
if (!normalized) {
|
||||
return;
|
||||
}
|
||||
const lines = pendingLinesByPackage.get(normalized) || [];
|
||||
lines.push(line);
|
||||
pendingLinesByPackage.set(normalized, lines);
|
||||
scheduleFlush();
|
||||
}
|
||||
|
||||
export function initPackageLogs(baseDir: string): void {
|
||||
packageLogsDir = path.join(baseDir, "package-logs");
|
||||
try {
|
||||
fs.mkdirSync(packageLogsDir, { recursive: true });
|
||||
} catch {
|
||||
packageLogsDir = null;
|
||||
return;
|
||||
}
|
||||
void cleanupOldPackageLogs(packageLogsDir);
|
||||
}
|
||||
|
||||
export function ensurePackageLog(meta: PackageLogMeta): string | null {
|
||||
const normalizedPackageId = normalizePackageId(meta.packageId);
|
||||
const logPath = getPackageLogFilePath(meta.packageId);
|
||||
if (!logPath) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
fs.mkdirSync(path.dirname(logPath), { recursive: true });
|
||||
if (!fs.existsSync(logPath)) {
|
||||
fs.writeFileSync(logPath, "", "utf8");
|
||||
}
|
||||
if (!initializedThisProcess.has(normalizedPackageId)) {
|
||||
initializedThisProcess.add(normalizedPackageId);
|
||||
const startedAt = logTimestamp();
|
||||
fs.appendFileSync(
|
||||
logPath,
|
||||
`=== Paket-Log Start: ${startedAt} | packageId=${sanitizeFieldValue(String(meta.packageId || ""))} | logKey=${normalizedPackageId} | name=${sanitizeFieldValue(meta.name)} ===\n`,
|
||||
"utf8"
|
||||
);
|
||||
fs.appendFileSync(
|
||||
logPath,
|
||||
`${logTimestamp()} [INFO] Paket-Kontext initialisiert${formatFields({
|
||||
name: meta.name,
|
||||
outputDir: meta.outputDir,
|
||||
extractDir: meta.extractDir
|
||||
})}\n`,
|
||||
"utf8"
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
return logPath;
|
||||
}
|
||||
|
||||
export function logPackageEvent(
|
||||
packageId: string,
|
||||
level: PackageLogLevel,
|
||||
message: string,
|
||||
fields?: Record<string, unknown>
|
||||
): void {
|
||||
const logPath = getPackageLogFilePath(packageId);
|
||||
if (!logPath) {
|
||||
return;
|
||||
}
|
||||
const line = `${logTimestamp()} [${level}] ${message}${formatFields(fields)}\n`;
|
||||
appendLine(packageId, line);
|
||||
}
|
||||
|
||||
export function getPackageLogPath(packageId: string): string | null {
|
||||
const logPath = getPackageLogFilePath(packageId);
|
||||
if (!logPath) {
|
||||
return null;
|
||||
}
|
||||
return fs.existsSync(logPath) ? logPath : null;
|
||||
}
|
||||
|
||||
export function shutdownPackageLogs(): void {
|
||||
if (flushTimer) {
|
||||
clearTimeout(flushTimer);
|
||||
flushTimer = null;
|
||||
}
|
||||
flushPending();
|
||||
for (const packageId of knownLogPaths.keys()) {
|
||||
const logPath = getPackageLogFilePathFromNormalized(packageId);
|
||||
if (!logPath) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
fs.appendFileSync(logPath, `=== Paket-Log Ende: ${logTimestamp()} ===\n`, "utf8");
|
||||
} catch {
|
||||
}
|
||||
}
|
||||
pendingLinesByPackage.clear();
|
||||
knownLogPaths.clear();
|
||||
initializedThisProcess.clear();
|
||||
packageLogsDir = null;
|
||||
}
|
||||
|
||||
@ -1,477 +1,477 @@
|
||||
import { BrowserWindow, session } from "electron";
|
||||
import { UnrestrictedLink } from "./realdebrid";
|
||||
import { filenameFromUrl, sleep } from "./utils";
|
||||
import { API_BASE_URL, REQUEST_RETRIES } from "./constants";
|
||||
|
||||
const RD_BASE_URL = "https://real-debrid.com";
|
||||
const RD_LOGIN_URL = RD_BASE_URL;
|
||||
const RD_APITOKEN_URL = `${RD_BASE_URL}/apitoken`;
|
||||
const RD_UNRESTRICT_API = `${API_BASE_URL}/unrestrict/link`;
|
||||
const RD_PERSISTENT_PARTITION = "persist:realdebrid-web";
|
||||
const RD_TRANSIENT_PARTITION = "realdebrid-web";
|
||||
const RD_USER_AGENT = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/133.0.0.0 Safari/537.36";
|
||||
|
||||
type GenerateOutcome =
|
||||
| { kind: "success"; value: UnrestrictedLink }
|
||||
| { kind: "login_required" };
|
||||
|
||||
function abortError(): Error {
|
||||
return new Error("aborted:realdebrid-web");
|
||||
}
|
||||
|
||||
function withTimeoutSignal(signal: AbortSignal | undefined, timeoutMs: number): AbortSignal {
|
||||
const timeoutSignal = AbortSignal.timeout(timeoutMs);
|
||||
if (!signal) {
|
||||
return timeoutSignal;
|
||||
}
|
||||
return AbortSignal.any([signal, timeoutSignal]);
|
||||
}
|
||||
|
||||
function throwIfAborted(signal?: AbortSignal): void {
|
||||
if (signal?.aborted) {
|
||||
throw abortError();
|
||||
}
|
||||
}
|
||||
|
||||
async function sleepWithSignal(ms: number, signal?: AbortSignal): Promise<void> {
|
||||
if (!signal) {
|
||||
await sleep(ms);
|
||||
return;
|
||||
}
|
||||
if (signal.aborted) {
|
||||
throw abortError();
|
||||
}
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
let timer: NodeJS.Timeout | null = setTimeout(() => {
|
||||
timer = null;
|
||||
signal.removeEventListener("abort", onAbort);
|
||||
resolve();
|
||||
}, Math.max(0, ms));
|
||||
|
||||
const onAbort = (): void => {
|
||||
if (timer) {
|
||||
clearTimeout(timer);
|
||||
timer = null;
|
||||
}
|
||||
signal.removeEventListener("abort", onAbort);
|
||||
reject(abortError());
|
||||
};
|
||||
|
||||
signal.addEventListener("abort", onAbort, { once: true });
|
||||
});
|
||||
}
|
||||
|
||||
function parseJson(text: string): Record<string, unknown> | null {
|
||||
try {
|
||||
const parsed = JSON.parse(text) as unknown;
|
||||
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
||||
return null;
|
||||
}
|
||||
return parsed as Record<string, unknown>;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function looksLikeHtmlResponse(text: string): boolean {
|
||||
const trimmed = text.trim();
|
||||
return trimmed.startsWith("<!") || trimmed.startsWith("<html") || trimmed.startsWith("<HTML");
|
||||
}
|
||||
|
||||
export function extractPrivateTokenFromHtml(html: string): string | null {
|
||||
const normalized = String(html || "");
|
||||
if (!normalized.trim()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const patterns = [
|
||||
/private_token['"]\]\[0\]\.value\s*=\s*['"]([^'"]+)['"]/i,
|
||||
/getElementsByName\(\s*['"]private_token['"]\s*\)\s*\[\s*0\s*\]\.value\s*=\s*['"]([^'"]+)['"]/i,
|
||||
/querySelector(?:All)?\(\s*['"][^'"]*private_token[^'"]*['"]\s*\)(?:\s*\[\s*0\s*\])?\.value\s*=\s*['"]([^'"]+)['"]/i,
|
||||
/name=['"]private_token['"][^>]*value=['"]([^'"]+)['"]/i,
|
||||
/value=['"]([^'"]+)['"][^>]*name=['"]private_token['"]/i
|
||||
];
|
||||
|
||||
for (const pattern of patterns) {
|
||||
const match = normalized.match(pattern);
|
||||
const token = match?.[1]?.trim();
|
||||
if (token) {
|
||||
return token;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export class RealDebridWebFallback {
|
||||
private queue: Promise<unknown> = Promise.resolve();
|
||||
|
||||
private loginWindow: BrowserWindow | null = null;
|
||||
|
||||
private loginWindowPartition = "";
|
||||
|
||||
private cachedToken = "";
|
||||
|
||||
private cachedTokenAt = 0;
|
||||
|
||||
private getRememberSession: () => boolean;
|
||||
|
||||
public constructor(getRememberSession: () => boolean) {
|
||||
this.getRememberSession = getRememberSession;
|
||||
}
|
||||
|
||||
public async unrestrict(link: string, signal?: AbortSignal): Promise<UnrestrictedLink | null> {
|
||||
const overallSignal = withTimeoutSignal(signal, 10 * 60 * 1000);
|
||||
return this.runExclusive(async () => {
|
||||
throwIfAborted(overallSignal);
|
||||
if (!String(link || "").trim()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const initial = await this.generate(link, overallSignal);
|
||||
if (initial.kind === "success") {
|
||||
return initial.value;
|
||||
}
|
||||
return this.waitForLoginAndGenerate(link, overallSignal);
|
||||
}, overallSignal);
|
||||
}
|
||||
|
||||
public async openLoginWindow(): Promise<void> {
|
||||
const window = await this.ensureLoginWindow();
|
||||
if (window.isMinimized()) {
|
||||
window.restore();
|
||||
}
|
||||
window.show();
|
||||
window.focus();
|
||||
void this.primeTokenFromWindow(window);
|
||||
}
|
||||
|
||||
public async clearSessions(): Promise<void> {
|
||||
this.disposeLoginWindow();
|
||||
this.cachedToken = "";
|
||||
this.cachedTokenAt = 0;
|
||||
for (const partition of [RD_PERSISTENT_PARTITION, RD_TRANSIENT_PARTITION]) {
|
||||
const currentSession = session.fromPartition(partition);
|
||||
try {
|
||||
await currentSession.clearStorageData({
|
||||
storages: ["cookies", "indexdb", "localstorage", "serviceworkers", "cachestorage"]
|
||||
});
|
||||
} catch {
|
||||
}
|
||||
try {
|
||||
await currentSession.clearCache();
|
||||
} catch {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public dispose(): void {
|
||||
this.disposeLoginWindow();
|
||||
}
|
||||
|
||||
private getPartition(): string {
|
||||
return this.getRememberSession() ? RD_PERSISTENT_PARTITION : RD_TRANSIENT_PARTITION;
|
||||
}
|
||||
|
||||
private disposeLoginWindow(): void {
|
||||
const current = this.loginWindow;
|
||||
this.loginWindow = null;
|
||||
this.loginWindowPartition = "";
|
||||
if (current && !current.isDestroyed()) {
|
||||
current.close();
|
||||
}
|
||||
}
|
||||
|
||||
private async runExclusive<T>(job: () => Promise<T>, signal?: AbortSignal): Promise<T> {
|
||||
const queuedAt = Date.now();
|
||||
const queueWaitTimeoutMs = 10 * 60 * 1000 + 30_000;
|
||||
const guardedJob = async (): Promise<T> => {
|
||||
throwIfAborted(signal);
|
||||
const waited = Date.now() - queuedAt;
|
||||
if (waited > queueWaitTimeoutMs) {
|
||||
throw new Error(`Real-Debrid-Web Queue-Timeout (${Math.floor(waited / 1000)}s gewartet)`);
|
||||
}
|
||||
return job();
|
||||
};
|
||||
const run = this.queue.then(guardedJob, guardedJob);
|
||||
this.queue = run.then(() => undefined, () => undefined);
|
||||
return run;
|
||||
}
|
||||
|
||||
private async ensureLoginWindow(): Promise<BrowserWindow> {
|
||||
const partition = this.getPartition();
|
||||
const existing = this.loginWindow;
|
||||
if (existing && !existing.isDestroyed() && this.loginWindowPartition === partition) {
|
||||
return existing;
|
||||
}
|
||||
|
||||
if (existing && !existing.isDestroyed()) {
|
||||
existing.close();
|
||||
}
|
||||
|
||||
const window = new BrowserWindow({
|
||||
width: 1120,
|
||||
height: 900,
|
||||
minWidth: 980,
|
||||
minHeight: 760,
|
||||
autoHideMenuBar: true,
|
||||
title: "Real-Debrid Web-Login",
|
||||
webPreferences: {
|
||||
partition,
|
||||
contextIsolation: true,
|
||||
nodeIntegration: false
|
||||
}
|
||||
});
|
||||
window.setMenuBarVisibility(false);
|
||||
window.webContents.setUserAgent(RD_USER_AGENT);
|
||||
const primeFromWindow = (): void => {
|
||||
void this.primeTokenFromWindow(window);
|
||||
};
|
||||
window.webContents.on("did-finish-load", primeFromWindow);
|
||||
window.webContents.on("did-navigate", primeFromWindow);
|
||||
window.webContents.on("did-navigate-in-page", primeFromWindow);
|
||||
window.on("close", () => {
|
||||
void this.primeTokenFromWindow(window);
|
||||
});
|
||||
window.on("closed", () => {
|
||||
if (this.loginWindow === window) {
|
||||
this.loginWindow = null;
|
||||
this.loginWindowPartition = "";
|
||||
}
|
||||
});
|
||||
this.loginWindow = window;
|
||||
this.loginWindowPartition = partition;
|
||||
await window.loadURL(RD_LOGIN_URL);
|
||||
return window;
|
||||
}
|
||||
|
||||
private rememberToken(token: string): string {
|
||||
this.cachedToken = token;
|
||||
this.cachedTokenAt = Date.now();
|
||||
return token;
|
||||
}
|
||||
|
||||
private getActiveLoginWindow(): BrowserWindow | null {
|
||||
const window = this.loginWindow;
|
||||
if (!window || window.isDestroyed()) {
|
||||
return null;
|
||||
}
|
||||
if (this.loginWindowPartition !== this.getPartition()) {
|
||||
return null;
|
||||
}
|
||||
return window;
|
||||
}
|
||||
|
||||
private async extractApiTokenFromWindow(window: BrowserWindow, signal?: AbortSignal): Promise<string | null> {
|
||||
throwIfAborted(signal);
|
||||
|
||||
try {
|
||||
const rawResult = await window.webContents.executeJavaScript(`
|
||||
(async () => {
|
||||
const readTokenFromHtml = (html) => {
|
||||
const text = String(html || "");
|
||||
const patterns = [
|
||||
/private_token['"]\\]\\[0\\]\\.value\\s*=\\s*['"]([^'"]+)['"]/i,
|
||||
/getElementsByName\\(\\s*['"]private_token['"]\\s*\\)\\s*\\[\\s*0\\s*\\]\\.value\\s*=\\s*['"]([^'"]+)['"]/i,
|
||||
/querySelector(?:All)?\\(\\s*['"][^'"]*private_token[^'"]*['"]\\s*\\)(?:\\s*\\[\\s*0\\s*\\])?\\.value\\s*=\\s*['"]([^'"]+)['"]/i,
|
||||
/name=['"]private_token['"][^>]*value=['"]([^'"]+)['"]/i,
|
||||
/value=['"]([^'"]+)['"][^>]*name=['"]private_token['"]/i
|
||||
];
|
||||
for (const pattern of patterns) {
|
||||
const match = text.match(pattern);
|
||||
if (match && match[1]) {
|
||||
return String(match[1]).trim();
|
||||
}
|
||||
}
|
||||
return "";
|
||||
};
|
||||
|
||||
const directInput = document.querySelector('input[name="private_token"]');
|
||||
if (directInput instanceof HTMLInputElement && directInput.value.trim()) {
|
||||
return directInput.value.trim();
|
||||
}
|
||||
|
||||
const html = document.documentElement ? document.documentElement.outerHTML : "";
|
||||
const directToken = readTokenFromHtml(html);
|
||||
if (directToken) {
|
||||
return directToken;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(${JSON.stringify(RD_APITOKEN_URL)}, {
|
||||
credentials: "include",
|
||||
cache: "no-store",
|
||||
headers: {
|
||||
"X-Requested-With": "XMLHttpRequest"
|
||||
}
|
||||
});
|
||||
const tokenHtml = await response.text();
|
||||
return readTokenFromHtml(tokenHtml);
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
})();
|
||||
`, true);
|
||||
const token = String(rawResult || "").trim();
|
||||
if (token) {
|
||||
return this.rememberToken(token);
|
||||
}
|
||||
} catch {
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private async primeTokenFromWindow(window: BrowserWindow): Promise<void> {
|
||||
try {
|
||||
await this.extractApiTokenFromWindow(window);
|
||||
} catch {
|
||||
}
|
||||
}
|
||||
|
||||
private async extractApiToken(signal?: AbortSignal): Promise<string | null> {
|
||||
throwIfAborted(signal);
|
||||
|
||||
if (this.cachedToken && Date.now() - this.cachedTokenAt < 30 * 60 * 1000) {
|
||||
return this.cachedToken;
|
||||
}
|
||||
|
||||
const activeLoginWindow = this.getActiveLoginWindow();
|
||||
if (activeLoginWindow) {
|
||||
const windowToken = await this.extractApiTokenFromWindow(activeLoginWindow, signal);
|
||||
if (windowToken) {
|
||||
return windowToken;
|
||||
}
|
||||
}
|
||||
|
||||
const currentSession = session.fromPartition(this.getPartition());
|
||||
const response = await currentSession.fetch(RD_APITOKEN_URL, {
|
||||
headers: {
|
||||
Accept: "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
|
||||
Referer: RD_BASE_URL + "/",
|
||||
"User-Agent": RD_USER_AGENT
|
||||
},
|
||||
signal: withTimeoutSignal(signal, 30_000)
|
||||
});
|
||||
const html = await response.text();
|
||||
|
||||
if (!response.ok || response.status === 403) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const token = extractPrivateTokenFromHtml(html);
|
||||
if (token) {
|
||||
return this.rememberToken(token);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private async generate(link: string, signal?: AbortSignal): Promise<GenerateOutcome> {
|
||||
throwIfAborted(signal);
|
||||
|
||||
const token = await this.extractApiToken(signal);
|
||||
if (!token) {
|
||||
return { kind: "login_required" };
|
||||
}
|
||||
|
||||
for (let attempt = 1; attempt <= REQUEST_RETRIES; attempt += 1) {
|
||||
throwIfAborted(signal);
|
||||
try {
|
||||
const body = new URLSearchParams({ link });
|
||||
const response = await fetch(RD_UNRESTRICT_API, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
"User-Agent": RD_USER_AGENT
|
||||
},
|
||||
body,
|
||||
signal: withTimeoutSignal(signal, 30_000)
|
||||
});
|
||||
|
||||
const text = await response.text();
|
||||
|
||||
if (response.status === 401 || response.status === 403) {
|
||||
this.cachedToken = "";
|
||||
this.cachedTokenAt = 0;
|
||||
return { kind: "login_required" };
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
if ((response.status === 429 || response.status >= 500) && attempt < REQUEST_RETRIES) {
|
||||
await sleepWithSignal(Math.min(5000, 400 * 2 ** attempt), signal);
|
||||
continue;
|
||||
}
|
||||
throw new Error(`Real-Debrid Web HTTP ${response.status}: ${text.slice(0, 200)}`);
|
||||
}
|
||||
|
||||
if (looksLikeHtmlResponse(text)) {
|
||||
throw new Error("Real-Debrid Web lieferte HTML statt JSON");
|
||||
}
|
||||
|
||||
const payload = parseJson(text.trim());
|
||||
if (!payload) {
|
||||
throw new Error("Ungültige JSON-Antwort von Real-Debrid Web");
|
||||
}
|
||||
|
||||
const directUrl = String(payload.download || payload.link || "").trim();
|
||||
if (!directUrl) {
|
||||
throw new Error("Real-Debrid Web: Antwort ohne Download-URL");
|
||||
}
|
||||
|
||||
const fileName = String(payload.filename || "").trim() || filenameFromUrl(directUrl) || filenameFromUrl(link);
|
||||
const fileSizeRaw = Number(payload.filesize ?? NaN);
|
||||
return {
|
||||
kind: "success",
|
||||
value: {
|
||||
directUrl,
|
||||
fileName,
|
||||
fileSize: Number.isFinite(fileSizeRaw) && fileSizeRaw > 0 ? Math.floor(fileSizeRaw) : null,
|
||||
retriesUsed: attempt - 1
|
||||
}
|
||||
};
|
||||
} catch (error) {
|
||||
if (signal?.aborted) {
|
||||
throw abortError();
|
||||
}
|
||||
if (attempt >= REQUEST_RETRIES) {
|
||||
throw error;
|
||||
}
|
||||
await sleepWithSignal(Math.min(5000, 400 * 2 ** attempt), signal);
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error("Real-Debrid Web: Unrestrict fehlgeschlagen");
|
||||
}
|
||||
|
||||
private async waitForLoginAndGenerate(link: string, signal?: AbortSignal): Promise<UnrestrictedLink | null> {
|
||||
const window = await this.ensureLoginWindow();
|
||||
if (window.isMinimized()) {
|
||||
window.restore();
|
||||
}
|
||||
window.show();
|
||||
window.focus();
|
||||
|
||||
const startedAt = Date.now();
|
||||
while (Date.now() - startedAt < 10 * 60 * 1000) {
|
||||
throwIfAborted(signal);
|
||||
if (window.isDestroyed()) {
|
||||
throw new Error("Real-Debrid Web-Login abgebrochen");
|
||||
}
|
||||
|
||||
const outcome = await this.generate(link, signal);
|
||||
if (outcome.kind === "success") {
|
||||
if (!window.isDestroyed()) {
|
||||
window.close();
|
||||
}
|
||||
return outcome.value;
|
||||
}
|
||||
|
||||
await sleepWithSignal(1_500, signal);
|
||||
}
|
||||
|
||||
throw new Error("Real-Debrid Web-Login Timeout");
|
||||
}
|
||||
}
|
||||
import { BrowserWindow, session } from "electron";
|
||||
import { UnrestrictedLink } from "./realdebrid";
|
||||
import { filenameFromUrl, sleep } from "./utils";
|
||||
import { API_BASE_URL, REQUEST_RETRIES } from "./constants";
|
||||
|
||||
const RD_BASE_URL = "https://real-debrid.com";
|
||||
const RD_LOGIN_URL = RD_BASE_URL;
|
||||
const RD_APITOKEN_URL = `${RD_BASE_URL}/apitoken`;
|
||||
const RD_UNRESTRICT_API = `${API_BASE_URL}/unrestrict/link`;
|
||||
const RD_PERSISTENT_PARTITION = "persist:realdebrid-web";
|
||||
const RD_TRANSIENT_PARTITION = "realdebrid-web";
|
||||
const RD_USER_AGENT = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/133.0.0.0 Safari/537.36";
|
||||
|
||||
type GenerateOutcome =
|
||||
| { kind: "success"; value: UnrestrictedLink }
|
||||
| { kind: "login_required" };
|
||||
|
||||
function abortError(): Error {
|
||||
return new Error("aborted:realdebrid-web");
|
||||
}
|
||||
|
||||
function withTimeoutSignal(signal: AbortSignal | undefined, timeoutMs: number): AbortSignal {
|
||||
const timeoutSignal = AbortSignal.timeout(timeoutMs);
|
||||
if (!signal) {
|
||||
return timeoutSignal;
|
||||
}
|
||||
return AbortSignal.any([signal, timeoutSignal]);
|
||||
}
|
||||
|
||||
function throwIfAborted(signal?: AbortSignal): void {
|
||||
if (signal?.aborted) {
|
||||
throw abortError();
|
||||
}
|
||||
}
|
||||
|
||||
async function sleepWithSignal(ms: number, signal?: AbortSignal): Promise<void> {
|
||||
if (!signal) {
|
||||
await sleep(ms);
|
||||
return;
|
||||
}
|
||||
if (signal.aborted) {
|
||||
throw abortError();
|
||||
}
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
let timer: NodeJS.Timeout | null = setTimeout(() => {
|
||||
timer = null;
|
||||
signal.removeEventListener("abort", onAbort);
|
||||
resolve();
|
||||
}, Math.max(0, ms));
|
||||
|
||||
const onAbort = (): void => {
|
||||
if (timer) {
|
||||
clearTimeout(timer);
|
||||
timer = null;
|
||||
}
|
||||
signal.removeEventListener("abort", onAbort);
|
||||
reject(abortError());
|
||||
};
|
||||
|
||||
signal.addEventListener("abort", onAbort, { once: true });
|
||||
});
|
||||
}
|
||||
|
||||
function parseJson(text: string): Record<string, unknown> | null {
|
||||
try {
|
||||
const parsed = JSON.parse(text) as unknown;
|
||||
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
||||
return null;
|
||||
}
|
||||
return parsed as Record<string, unknown>;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function looksLikeHtmlResponse(text: string): boolean {
|
||||
const trimmed = text.trim();
|
||||
return trimmed.startsWith("<!") || trimmed.startsWith("<html") || trimmed.startsWith("<HTML");
|
||||
}
|
||||
|
||||
export function extractPrivateTokenFromHtml(html: string): string | null {
|
||||
const normalized = String(html || "");
|
||||
if (!normalized.trim()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const patterns = [
|
||||
/private_token['"]\]\[0\]\.value\s*=\s*['"]([^'"]+)['"]/i,
|
||||
/getElementsByName\(\s*['"]private_token['"]\s*\)\s*\[\s*0\s*\]\.value\s*=\s*['"]([^'"]+)['"]/i,
|
||||
/querySelector(?:All)?\(\s*['"][^'"]*private_token[^'"]*['"]\s*\)(?:\s*\[\s*0\s*\])?\.value\s*=\s*['"]([^'"]+)['"]/i,
|
||||
/name=['"]private_token['"][^>]*value=['"]([^'"]+)['"]/i,
|
||||
/value=['"]([^'"]+)['"][^>]*name=['"]private_token['"]/i
|
||||
];
|
||||
|
||||
for (const pattern of patterns) {
|
||||
const match = normalized.match(pattern);
|
||||
const token = match?.[1]?.trim();
|
||||
if (token) {
|
||||
return token;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export class RealDebridWebFallback {
|
||||
private queue: Promise<unknown> = Promise.resolve();
|
||||
|
||||
private loginWindow: BrowserWindow | null = null;
|
||||
|
||||
private loginWindowPartition = "";
|
||||
|
||||
private cachedToken = "";
|
||||
|
||||
private cachedTokenAt = 0;
|
||||
|
||||
private getRememberSession: () => boolean;
|
||||
|
||||
public constructor(getRememberSession: () => boolean) {
|
||||
this.getRememberSession = getRememberSession;
|
||||
}
|
||||
|
||||
public async unrestrict(link: string, signal?: AbortSignal): Promise<UnrestrictedLink | null> {
|
||||
const overallSignal = withTimeoutSignal(signal, 10 * 60 * 1000);
|
||||
return this.runExclusive(async () => {
|
||||
throwIfAborted(overallSignal);
|
||||
if (!String(link || "").trim()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const initial = await this.generate(link, overallSignal);
|
||||
if (initial.kind === "success") {
|
||||
return initial.value;
|
||||
}
|
||||
return this.waitForLoginAndGenerate(link, overallSignal);
|
||||
}, overallSignal);
|
||||
}
|
||||
|
||||
public async openLoginWindow(): Promise<void> {
|
||||
const window = await this.ensureLoginWindow();
|
||||
if (window.isMinimized()) {
|
||||
window.restore();
|
||||
}
|
||||
window.show();
|
||||
window.focus();
|
||||
void this.primeTokenFromWindow(window);
|
||||
}
|
||||
|
||||
public async clearSessions(): Promise<void> {
|
||||
this.disposeLoginWindow();
|
||||
this.cachedToken = "";
|
||||
this.cachedTokenAt = 0;
|
||||
for (const partition of [RD_PERSISTENT_PARTITION, RD_TRANSIENT_PARTITION]) {
|
||||
const currentSession = session.fromPartition(partition);
|
||||
try {
|
||||
await currentSession.clearStorageData({
|
||||
storages: ["cookies", "indexdb", "localstorage", "serviceworkers", "cachestorage"]
|
||||
});
|
||||
} catch {
|
||||
}
|
||||
try {
|
||||
await currentSession.clearCache();
|
||||
} catch {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public dispose(): void {
|
||||
this.disposeLoginWindow();
|
||||
}
|
||||
|
||||
private getPartition(): string {
|
||||
return this.getRememberSession() ? RD_PERSISTENT_PARTITION : RD_TRANSIENT_PARTITION;
|
||||
}
|
||||
|
||||
private disposeLoginWindow(): void {
|
||||
const current = this.loginWindow;
|
||||
this.loginWindow = null;
|
||||
this.loginWindowPartition = "";
|
||||
if (current && !current.isDestroyed()) {
|
||||
current.close();
|
||||
}
|
||||
}
|
||||
|
||||
private async runExclusive<T>(job: () => Promise<T>, signal?: AbortSignal): Promise<T> {
|
||||
const queuedAt = Date.now();
|
||||
const queueWaitTimeoutMs = 10 * 60 * 1000 + 30_000;
|
||||
const guardedJob = async (): Promise<T> => {
|
||||
throwIfAborted(signal);
|
||||
const waited = Date.now() - queuedAt;
|
||||
if (waited > queueWaitTimeoutMs) {
|
||||
throw new Error(`Real-Debrid-Web Queue-Timeout (${Math.floor(waited / 1000)}s gewartet)`);
|
||||
}
|
||||
return job();
|
||||
};
|
||||
const run = this.queue.then(guardedJob, guardedJob);
|
||||
this.queue = run.then(() => undefined, () => undefined);
|
||||
return run;
|
||||
}
|
||||
|
||||
private async ensureLoginWindow(): Promise<BrowserWindow> {
|
||||
const partition = this.getPartition();
|
||||
const existing = this.loginWindow;
|
||||
if (existing && !existing.isDestroyed() && this.loginWindowPartition === partition) {
|
||||
return existing;
|
||||
}
|
||||
|
||||
if (existing && !existing.isDestroyed()) {
|
||||
existing.close();
|
||||
}
|
||||
|
||||
const window = new BrowserWindow({
|
||||
width: 1120,
|
||||
height: 900,
|
||||
minWidth: 980,
|
||||
minHeight: 760,
|
||||
autoHideMenuBar: true,
|
||||
title: "Real-Debrid Web-Login",
|
||||
webPreferences: {
|
||||
partition,
|
||||
contextIsolation: true,
|
||||
nodeIntegration: false
|
||||
}
|
||||
});
|
||||
window.setMenuBarVisibility(false);
|
||||
window.webContents.setUserAgent(RD_USER_AGENT);
|
||||
const primeFromWindow = (): void => {
|
||||
void this.primeTokenFromWindow(window);
|
||||
};
|
||||
window.webContents.on("did-finish-load", primeFromWindow);
|
||||
window.webContents.on("did-navigate", primeFromWindow);
|
||||
window.webContents.on("did-navigate-in-page", primeFromWindow);
|
||||
window.on("close", () => {
|
||||
void this.primeTokenFromWindow(window);
|
||||
});
|
||||
window.on("closed", () => {
|
||||
if (this.loginWindow === window) {
|
||||
this.loginWindow = null;
|
||||
this.loginWindowPartition = "";
|
||||
}
|
||||
});
|
||||
this.loginWindow = window;
|
||||
this.loginWindowPartition = partition;
|
||||
await window.loadURL(RD_LOGIN_URL);
|
||||
return window;
|
||||
}
|
||||
|
||||
private rememberToken(token: string): string {
|
||||
this.cachedToken = token;
|
||||
this.cachedTokenAt = Date.now();
|
||||
return token;
|
||||
}
|
||||
|
||||
private getActiveLoginWindow(): BrowserWindow | null {
|
||||
const window = this.loginWindow;
|
||||
if (!window || window.isDestroyed()) {
|
||||
return null;
|
||||
}
|
||||
if (this.loginWindowPartition !== this.getPartition()) {
|
||||
return null;
|
||||
}
|
||||
return window;
|
||||
}
|
||||
|
||||
private async extractApiTokenFromWindow(window: BrowserWindow, signal?: AbortSignal): Promise<string | null> {
|
||||
throwIfAborted(signal);
|
||||
|
||||
try {
|
||||
const rawResult = await window.webContents.executeJavaScript(`
|
||||
(async () => {
|
||||
const readTokenFromHtml = (html) => {
|
||||
const text = String(html || "");
|
||||
const patterns = [
|
||||
/private_token['"]\\]\\[0\\]\\.value\\s*=\\s*['"]([^'"]+)['"]/i,
|
||||
/getElementsByName\\(\\s*['"]private_token['"]\\s*\\)\\s*\\[\\s*0\\s*\\]\\.value\\s*=\\s*['"]([^'"]+)['"]/i,
|
||||
/querySelector(?:All)?\\(\\s*['"][^'"]*private_token[^'"]*['"]\\s*\\)(?:\\s*\\[\\s*0\\s*\\])?\\.value\\s*=\\s*['"]([^'"]+)['"]/i,
|
||||
/name=['"]private_token['"][^>]*value=['"]([^'"]+)['"]/i,
|
||||
/value=['"]([^'"]+)['"][^>]*name=['"]private_token['"]/i
|
||||
];
|
||||
for (const pattern of patterns) {
|
||||
const match = text.match(pattern);
|
||||
if (match && match[1]) {
|
||||
return String(match[1]).trim();
|
||||
}
|
||||
}
|
||||
return "";
|
||||
};
|
||||
|
||||
const directInput = document.querySelector('input[name="private_token"]');
|
||||
if (directInput instanceof HTMLInputElement && directInput.value.trim()) {
|
||||
return directInput.value.trim();
|
||||
}
|
||||
|
||||
const html = document.documentElement ? document.documentElement.outerHTML : "";
|
||||
const directToken = readTokenFromHtml(html);
|
||||
if (directToken) {
|
||||
return directToken;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(${JSON.stringify(RD_APITOKEN_URL)}, {
|
||||
credentials: "include",
|
||||
cache: "no-store",
|
||||
headers: {
|
||||
"X-Requested-With": "XMLHttpRequest"
|
||||
}
|
||||
});
|
||||
const tokenHtml = await response.text();
|
||||
return readTokenFromHtml(tokenHtml);
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
})();
|
||||
`, true);
|
||||
const token = String(rawResult || "").trim();
|
||||
if (token) {
|
||||
return this.rememberToken(token);
|
||||
}
|
||||
} catch {
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private async primeTokenFromWindow(window: BrowserWindow): Promise<void> {
|
||||
try {
|
||||
await this.extractApiTokenFromWindow(window);
|
||||
} catch {
|
||||
}
|
||||
}
|
||||
|
||||
private async extractApiToken(signal?: AbortSignal): Promise<string | null> {
|
||||
throwIfAborted(signal);
|
||||
|
||||
if (this.cachedToken && Date.now() - this.cachedTokenAt < 30 * 60 * 1000) {
|
||||
return this.cachedToken;
|
||||
}
|
||||
|
||||
const activeLoginWindow = this.getActiveLoginWindow();
|
||||
if (activeLoginWindow) {
|
||||
const windowToken = await this.extractApiTokenFromWindow(activeLoginWindow, signal);
|
||||
if (windowToken) {
|
||||
return windowToken;
|
||||
}
|
||||
}
|
||||
|
||||
const currentSession = session.fromPartition(this.getPartition());
|
||||
const response = await currentSession.fetch(RD_APITOKEN_URL, {
|
||||
headers: {
|
||||
Accept: "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
|
||||
Referer: RD_BASE_URL + "/",
|
||||
"User-Agent": RD_USER_AGENT
|
||||
},
|
||||
signal: withTimeoutSignal(signal, 30_000)
|
||||
});
|
||||
const html = await response.text();
|
||||
|
||||
if (!response.ok || response.status === 403) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const token = extractPrivateTokenFromHtml(html);
|
||||
if (token) {
|
||||
return this.rememberToken(token);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private async generate(link: string, signal?: AbortSignal): Promise<GenerateOutcome> {
|
||||
throwIfAborted(signal);
|
||||
|
||||
const token = await this.extractApiToken(signal);
|
||||
if (!token) {
|
||||
return { kind: "login_required" };
|
||||
}
|
||||
|
||||
for (let attempt = 1; attempt <= REQUEST_RETRIES; attempt += 1) {
|
||||
throwIfAborted(signal);
|
||||
try {
|
||||
const body = new URLSearchParams({ link });
|
||||
const response = await fetch(RD_UNRESTRICT_API, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
"User-Agent": RD_USER_AGENT
|
||||
},
|
||||
body,
|
||||
signal: withTimeoutSignal(signal, 30_000)
|
||||
});
|
||||
|
||||
const text = await response.text();
|
||||
|
||||
if (response.status === 401 || response.status === 403) {
|
||||
this.cachedToken = "";
|
||||
this.cachedTokenAt = 0;
|
||||
return { kind: "login_required" };
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
if ((response.status === 429 || response.status >= 500) && attempt < REQUEST_RETRIES) {
|
||||
await sleepWithSignal(Math.min(5000, 400 * 2 ** attempt), signal);
|
||||
continue;
|
||||
}
|
||||
throw new Error(`Real-Debrid Web HTTP ${response.status}: ${text.slice(0, 200)}`);
|
||||
}
|
||||
|
||||
if (looksLikeHtmlResponse(text)) {
|
||||
throw new Error("Real-Debrid Web lieferte HTML statt JSON");
|
||||
}
|
||||
|
||||
const payload = parseJson(text.trim());
|
||||
if (!payload) {
|
||||
throw new Error("Ungültige JSON-Antwort von Real-Debrid Web");
|
||||
}
|
||||
|
||||
const directUrl = String(payload.download || payload.link || "").trim();
|
||||
if (!directUrl) {
|
||||
throw new Error("Real-Debrid Web: Antwort ohne Download-URL");
|
||||
}
|
||||
|
||||
const fileName = String(payload.filename || "").trim() || filenameFromUrl(directUrl) || filenameFromUrl(link);
|
||||
const fileSizeRaw = Number(payload.filesize ?? NaN);
|
||||
return {
|
||||
kind: "success",
|
||||
value: {
|
||||
directUrl,
|
||||
fileName,
|
||||
fileSize: Number.isFinite(fileSizeRaw) && fileSizeRaw > 0 ? Math.floor(fileSizeRaw) : null,
|
||||
retriesUsed: attempt - 1
|
||||
}
|
||||
};
|
||||
} catch (error) {
|
||||
if (signal?.aborted) {
|
||||
throw abortError();
|
||||
}
|
||||
if (attempt >= REQUEST_RETRIES) {
|
||||
throw error;
|
||||
}
|
||||
await sleepWithSignal(Math.min(5000, 400 * 2 ** attempt), signal);
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error("Real-Debrid Web: Unrestrict fehlgeschlagen");
|
||||
}
|
||||
|
||||
private async waitForLoginAndGenerate(link: string, signal?: AbortSignal): Promise<UnrestrictedLink | null> {
|
||||
const window = await this.ensureLoginWindow();
|
||||
if (window.isMinimized()) {
|
||||
window.restore();
|
||||
}
|
||||
window.show();
|
||||
window.focus();
|
||||
|
||||
const startedAt = Date.now();
|
||||
while (Date.now() - startedAt < 10 * 60 * 1000) {
|
||||
throwIfAborted(signal);
|
||||
if (window.isDestroyed()) {
|
||||
throw new Error("Real-Debrid Web-Login abgebrochen");
|
||||
}
|
||||
|
||||
const outcome = await this.generate(link, signal);
|
||||
if (outcome.kind === "success") {
|
||||
if (!window.isDestroyed()) {
|
||||
window.close();
|
||||
}
|
||||
return outcome.value;
|
||||
}
|
||||
|
||||
await sleepWithSignal(1_500, signal);
|
||||
}
|
||||
|
||||
throw new Error("Real-Debrid Web-Login Timeout");
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,204 +1,204 @@
|
||||
import { API_BASE_URL, APP_VERSION, REQUEST_RETRIES } from "./constants";
|
||||
import { compactErrorText, sleep } from "./utils";
|
||||
|
||||
const DEBRID_USER_AGENT = `RD-Node-Downloader/${APP_VERSION}`;
|
||||
|
||||
export interface UnrestrictedLink {
|
||||
fileName: string;
|
||||
directUrl: string;
|
||||
fileSize: number | null;
|
||||
retriesUsed: number;
|
||||
skipTlsVerify?: boolean;
|
||||
sourceLabel?: string;
|
||||
sourceAccountId?: string;
|
||||
sourceAccountLabel?: string;
|
||||
}
|
||||
|
||||
function shouldRetryStatus(status: number): boolean {
|
||||
return status === 429 || status >= 500;
|
||||
}
|
||||
|
||||
function retryDelay(attempt: number): number {
|
||||
return Math.min(5000, 400 * 2 ** attempt);
|
||||
}
|
||||
|
||||
function parseRetryAfterMs(value: string | null): number {
|
||||
const text = String(value || "").trim();
|
||||
if (!text) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const asSeconds = Number(text);
|
||||
if (Number.isFinite(asSeconds) && asSeconds >= 0) {
|
||||
return Math.min(120000, Math.floor(asSeconds * 1000));
|
||||
}
|
||||
|
||||
const asDate = Date.parse(text);
|
||||
if (Number.isFinite(asDate)) {
|
||||
return Math.min(120000, Math.max(0, asDate - Date.now()));
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
function retryDelayForResponse(response: Response, attempt: number): number {
|
||||
if (response.status !== 429) {
|
||||
return retryDelay(attempt);
|
||||
}
|
||||
const fromHeader = parseRetryAfterMs(response.headers.get("retry-after"));
|
||||
return fromHeader > 0 ? fromHeader : retryDelay(attempt);
|
||||
}
|
||||
|
||||
function readHttpStatusFromErrorText(text: string): number {
|
||||
const match = String(text || "").match(/HTTP\s+(\d{3})/i);
|
||||
return match ? Number(match[1]) : 0;
|
||||
}
|
||||
|
||||
function isRetryableErrorText(text: string): boolean {
|
||||
const status = readHttpStatusFromErrorText(text);
|
||||
if (status === 429 || status >= 500) {
|
||||
return true;
|
||||
}
|
||||
const lower = String(text || "").toLowerCase();
|
||||
return lower.includes("timeout")
|
||||
|| lower.includes("network")
|
||||
|| lower.includes("fetch failed")
|
||||
|| lower.includes("aborted")
|
||||
|| lower.includes("econnreset")
|
||||
|| lower.includes("enotfound")
|
||||
|| lower.includes("etimedout")
|
||||
|| lower.includes("html statt json");
|
||||
}
|
||||
|
||||
function withTimeoutSignal(signal: AbortSignal | undefined, timeoutMs: number): AbortSignal {
|
||||
if (!signal) {
|
||||
return AbortSignal.timeout(timeoutMs);
|
||||
}
|
||||
return AbortSignal.any([signal, AbortSignal.timeout(timeoutMs)]);
|
||||
}
|
||||
|
||||
async function sleepWithSignal(ms: number, signal?: AbortSignal): Promise<void> {
|
||||
if (!signal) {
|
||||
await sleep(ms);
|
||||
return;
|
||||
}
|
||||
if (signal.aborted) {
|
||||
throw new Error("aborted");
|
||||
}
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
let timer: NodeJS.Timeout | null = setTimeout(() => {
|
||||
timer = null;
|
||||
signal.removeEventListener("abort", onAbort);
|
||||
resolve();
|
||||
}, Math.max(0, ms));
|
||||
|
||||
const onAbort = (): void => {
|
||||
if (timer) {
|
||||
clearTimeout(timer);
|
||||
timer = null;
|
||||
}
|
||||
signal.removeEventListener("abort", onAbort);
|
||||
reject(new Error("aborted"));
|
||||
};
|
||||
|
||||
signal.addEventListener("abort", onAbort, { once: true });
|
||||
});
|
||||
}
|
||||
|
||||
function looksLikeHtmlResponse(contentType: string, body: string): boolean {
|
||||
const type = String(contentType || "").toLowerCase();
|
||||
if (type.includes("text/html") || type.includes("application/xhtml+xml")) {
|
||||
return true;
|
||||
}
|
||||
return /^\s*<(!doctype\s+html|html\b)/i.test(String(body || ""));
|
||||
}
|
||||
|
||||
function parseErrorBody(status: number, body: string, contentType: string): string {
|
||||
if (looksLikeHtmlResponse(contentType, body)) {
|
||||
return `Real-Debrid lieferte HTML statt JSON (HTTP ${status})`;
|
||||
}
|
||||
const clean = compactErrorText(body);
|
||||
return clean || `HTTP ${status}`;
|
||||
}
|
||||
|
||||
export class RealDebridClient {
|
||||
private token: string;
|
||||
|
||||
public constructor(token: string) {
|
||||
this.token = token;
|
||||
}
|
||||
|
||||
public async unrestrictLink(link: string, signal?: AbortSignal): Promise<UnrestrictedLink> {
|
||||
let lastError = "";
|
||||
for (let attempt = 1; attempt <= REQUEST_RETRIES; attempt += 1) {
|
||||
try {
|
||||
const body = new URLSearchParams({ link });
|
||||
const response = await fetch(`${API_BASE_URL}/unrestrict/link`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${this.token}`,
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
"User-Agent": DEBRID_USER_AGENT
|
||||
},
|
||||
body,
|
||||
signal: withTimeoutSignal(signal, 30000)
|
||||
});
|
||||
|
||||
const text = await response.text();
|
||||
const contentType = String(response.headers.get("content-type") || "");
|
||||
if (!response.ok) {
|
||||
const parsed = parseErrorBody(response.status, text, contentType);
|
||||
if (shouldRetryStatus(response.status) && attempt < REQUEST_RETRIES) {
|
||||
await sleepWithSignal(retryDelayForResponse(response, attempt), signal);
|
||||
continue;
|
||||
}
|
||||
throw new Error(parsed);
|
||||
}
|
||||
|
||||
if (looksLikeHtmlResponse(contentType, text)) {
|
||||
throw new Error("Real-Debrid lieferte HTML statt JSON");
|
||||
}
|
||||
|
||||
let payload: Record<string, unknown>;
|
||||
try {
|
||||
payload = JSON.parse(text) as Record<string, unknown>;
|
||||
} catch {
|
||||
throw new Error("Ungültige JSON-Antwort von Real-Debrid");
|
||||
}
|
||||
const directUrl = String(payload.download || payload.link || "").trim();
|
||||
if (!directUrl) {
|
||||
throw new Error("Unrestrict ohne Download-URL");
|
||||
}
|
||||
try {
|
||||
const parsedUrl = new URL(directUrl);
|
||||
if (parsedUrl.protocol !== "https:" && parsedUrl.protocol !== "http:") {
|
||||
throw new Error(`Ungültiges Download-URL-Protokoll (${parsedUrl.protocol})`);
|
||||
}
|
||||
} catch (urlError) {
|
||||
if (urlError instanceof Error && urlError.message.includes("Protokoll")) throw urlError;
|
||||
throw new Error("Real-Debrid Antwort enthält keine gültige Download-URL");
|
||||
}
|
||||
|
||||
const fileName = String(payload.filename || "download.bin").trim() || "download.bin";
|
||||
const fileSizeRaw = Number(payload.filesize ?? NaN);
|
||||
return {
|
||||
fileName,
|
||||
directUrl,
|
||||
fileSize: Number.isFinite(fileSizeRaw) && fileSizeRaw > 0 ? Math.floor(fileSizeRaw) : null,
|
||||
retriesUsed: attempt - 1
|
||||
};
|
||||
} catch (error) {
|
||||
lastError = compactErrorText(error);
|
||||
if (signal?.aborted || (/aborted/i.test(lastError) && !/timeout/i.test(lastError))) {
|
||||
break;
|
||||
}
|
||||
if (attempt >= REQUEST_RETRIES || !isRetryableErrorText(lastError)) {
|
||||
break;
|
||||
}
|
||||
await sleepWithSignal(retryDelay(attempt), signal);
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error(String(lastError || "Unrestrict fehlgeschlagen").replace(/^Error:\s*/i, ""));
|
||||
}
|
||||
}
|
||||
import { API_BASE_URL, APP_VERSION, REQUEST_RETRIES } from "./constants";
|
||||
import { compactErrorText, sleep } from "./utils";
|
||||
|
||||
const DEBRID_USER_AGENT = `RD-Node-Downloader/${APP_VERSION}`;
|
||||
|
||||
export interface UnrestrictedLink {
|
||||
fileName: string;
|
||||
directUrl: string;
|
||||
fileSize: number | null;
|
||||
retriesUsed: number;
|
||||
skipTlsVerify?: boolean;
|
||||
sourceLabel?: string;
|
||||
sourceAccountId?: string;
|
||||
sourceAccountLabel?: string;
|
||||
}
|
||||
|
||||
function shouldRetryStatus(status: number): boolean {
|
||||
return status === 429 || status >= 500;
|
||||
}
|
||||
|
||||
function retryDelay(attempt: number): number {
|
||||
return Math.min(5000, 400 * 2 ** attempt);
|
||||
}
|
||||
|
||||
function parseRetryAfterMs(value: string | null): number {
|
||||
const text = String(value || "").trim();
|
||||
if (!text) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const asSeconds = Number(text);
|
||||
if (Number.isFinite(asSeconds) && asSeconds >= 0) {
|
||||
return Math.min(120000, Math.floor(asSeconds * 1000));
|
||||
}
|
||||
|
||||
const asDate = Date.parse(text);
|
||||
if (Number.isFinite(asDate)) {
|
||||
return Math.min(120000, Math.max(0, asDate - Date.now()));
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
function retryDelayForResponse(response: Response, attempt: number): number {
|
||||
if (response.status !== 429) {
|
||||
return retryDelay(attempt);
|
||||
}
|
||||
const fromHeader = parseRetryAfterMs(response.headers.get("retry-after"));
|
||||
return fromHeader > 0 ? fromHeader : retryDelay(attempt);
|
||||
}
|
||||
|
||||
function readHttpStatusFromErrorText(text: string): number {
|
||||
const match = String(text || "").match(/HTTP\s+(\d{3})/i);
|
||||
return match ? Number(match[1]) : 0;
|
||||
}
|
||||
|
||||
function isRetryableErrorText(text: string): boolean {
|
||||
const status = readHttpStatusFromErrorText(text);
|
||||
if (status === 429 || status >= 500) {
|
||||
return true;
|
||||
}
|
||||
const lower = String(text || "").toLowerCase();
|
||||
return lower.includes("timeout")
|
||||
|| lower.includes("network")
|
||||
|| lower.includes("fetch failed")
|
||||
|| lower.includes("aborted")
|
||||
|| lower.includes("econnreset")
|
||||
|| lower.includes("enotfound")
|
||||
|| lower.includes("etimedout")
|
||||
|| lower.includes("html statt json");
|
||||
}
|
||||
|
||||
function withTimeoutSignal(signal: AbortSignal | undefined, timeoutMs: number): AbortSignal {
|
||||
if (!signal) {
|
||||
return AbortSignal.timeout(timeoutMs);
|
||||
}
|
||||
return AbortSignal.any([signal, AbortSignal.timeout(timeoutMs)]);
|
||||
}
|
||||
|
||||
async function sleepWithSignal(ms: number, signal?: AbortSignal): Promise<void> {
|
||||
if (!signal) {
|
||||
await sleep(ms);
|
||||
return;
|
||||
}
|
||||
if (signal.aborted) {
|
||||
throw new Error("aborted");
|
||||
}
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
let timer: NodeJS.Timeout | null = setTimeout(() => {
|
||||
timer = null;
|
||||
signal.removeEventListener("abort", onAbort);
|
||||
resolve();
|
||||
}, Math.max(0, ms));
|
||||
|
||||
const onAbort = (): void => {
|
||||
if (timer) {
|
||||
clearTimeout(timer);
|
||||
timer = null;
|
||||
}
|
||||
signal.removeEventListener("abort", onAbort);
|
||||
reject(new Error("aborted"));
|
||||
};
|
||||
|
||||
signal.addEventListener("abort", onAbort, { once: true });
|
||||
});
|
||||
}
|
||||
|
||||
function looksLikeHtmlResponse(contentType: string, body: string): boolean {
|
||||
const type = String(contentType || "").toLowerCase();
|
||||
if (type.includes("text/html") || type.includes("application/xhtml+xml")) {
|
||||
return true;
|
||||
}
|
||||
return /^\s*<(!doctype\s+html|html\b)/i.test(String(body || ""));
|
||||
}
|
||||
|
||||
function parseErrorBody(status: number, body: string, contentType: string): string {
|
||||
if (looksLikeHtmlResponse(contentType, body)) {
|
||||
return `Real-Debrid lieferte HTML statt JSON (HTTP ${status})`;
|
||||
}
|
||||
const clean = compactErrorText(body);
|
||||
return clean || `HTTP ${status}`;
|
||||
}
|
||||
|
||||
export class RealDebridClient {
|
||||
private token: string;
|
||||
|
||||
public constructor(token: string) {
|
||||
this.token = token;
|
||||
}
|
||||
|
||||
public async unrestrictLink(link: string, signal?: AbortSignal): Promise<UnrestrictedLink> {
|
||||
let lastError = "";
|
||||
for (let attempt = 1; attempt <= REQUEST_RETRIES; attempt += 1) {
|
||||
try {
|
||||
const body = new URLSearchParams({ link });
|
||||
const response = await fetch(`${API_BASE_URL}/unrestrict/link`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${this.token}`,
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
"User-Agent": DEBRID_USER_AGENT
|
||||
},
|
||||
body,
|
||||
signal: withTimeoutSignal(signal, 30000)
|
||||
});
|
||||
|
||||
const text = await response.text();
|
||||
const contentType = String(response.headers.get("content-type") || "");
|
||||
if (!response.ok) {
|
||||
const parsed = parseErrorBody(response.status, text, contentType);
|
||||
if (shouldRetryStatus(response.status) && attempt < REQUEST_RETRIES) {
|
||||
await sleepWithSignal(retryDelayForResponse(response, attempt), signal);
|
||||
continue;
|
||||
}
|
||||
throw new Error(parsed);
|
||||
}
|
||||
|
||||
if (looksLikeHtmlResponse(contentType, text)) {
|
||||
throw new Error("Real-Debrid lieferte HTML statt JSON");
|
||||
}
|
||||
|
||||
let payload: Record<string, unknown>;
|
||||
try {
|
||||
payload = JSON.parse(text) as Record<string, unknown>;
|
||||
} catch {
|
||||
throw new Error("Ungültige JSON-Antwort von Real-Debrid");
|
||||
}
|
||||
const directUrl = String(payload.download || payload.link || "").trim();
|
||||
if (!directUrl) {
|
||||
throw new Error("Unrestrict ohne Download-URL");
|
||||
}
|
||||
try {
|
||||
const parsedUrl = new URL(directUrl);
|
||||
if (parsedUrl.protocol !== "https:" && parsedUrl.protocol !== "http:") {
|
||||
throw new Error(`Ungültiges Download-URL-Protokoll (${parsedUrl.protocol})`);
|
||||
}
|
||||
} catch (urlError) {
|
||||
if (urlError instanceof Error && urlError.message.includes("Protokoll")) throw urlError;
|
||||
throw new Error("Real-Debrid Antwort enthält keine gültige Download-URL");
|
||||
}
|
||||
|
||||
const fileName = String(payload.filename || "download.bin").trim() || "download.bin";
|
||||
const fileSizeRaw = Number(payload.filesize ?? NaN);
|
||||
return {
|
||||
fileName,
|
||||
directUrl,
|
||||
fileSize: Number.isFinite(fileSizeRaw) && fileSizeRaw > 0 ? Math.floor(fileSizeRaw) : null,
|
||||
retriesUsed: attempt - 1
|
||||
};
|
||||
} catch (error) {
|
||||
lastError = compactErrorText(error);
|
||||
if (signal?.aborted || (/aborted/i.test(lastError) && !/timeout/i.test(lastError))) {
|
||||
break;
|
||||
}
|
||||
if (attempt >= REQUEST_RETRIES || !isRetryableErrorText(lastError)) {
|
||||
break;
|
||||
}
|
||||
await sleepWithSignal(retryDelay(attempt), signal);
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error(String(lastError || "Unrestrict fehlgeschlagen").replace(/^Error:\s*/i, ""));
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,119 +1,119 @@
|
||||
import fs from "node:fs";
|
||||
import { logTimestamp } from "./log-timestamp";
|
||||
import path from "node:path";
|
||||
|
||||
type RenameLogLevel = "INFO" | "WARN" | "ERROR";
|
||||
|
||||
const RENAME_LOG_MAX_FILE_BYTES = Number(process.env.RD_RENAME_LOG_MAX_BYTES || 10 * 1024 * 1024);
|
||||
const RENAME_LOG_RETENTION_DAYS = Number(process.env.RD_RENAME_LOG_RETENTION_DAYS || 30);
|
||||
|
||||
let renameLogPath: string | null = null;
|
||||
|
||||
function sanitizeFieldValue(value: unknown): string {
|
||||
if (value === undefined || value === null) {
|
||||
return "";
|
||||
}
|
||||
if (typeof value === "string") {
|
||||
return value.replace(/\r?\n/g, "\\n");
|
||||
}
|
||||
if (typeof value === "number" || typeof value === "boolean") {
|
||||
return String(value);
|
||||
}
|
||||
try {
|
||||
return JSON.stringify(value).replace(/\r?\n/g, "\\n");
|
||||
} catch {
|
||||
return String(value);
|
||||
}
|
||||
}
|
||||
|
||||
function formatFields(fields?: Record<string, unknown>): string {
|
||||
if (!fields) {
|
||||
return "";
|
||||
}
|
||||
const parts = Object.entries(fields)
|
||||
.filter(([, value]) => value !== undefined && value !== null && sanitizeFieldValue(value) !== "")
|
||||
.map(([key, value]) => `${key}=${sanitizeFieldValue(value)}`);
|
||||
return parts.length > 0 ? ` | ${parts.join(" | ")}` : "";
|
||||
}
|
||||
|
||||
function rotateIfNeeded(filePath: string): void {
|
||||
try {
|
||||
const stat = fs.statSync(filePath);
|
||||
if (stat.size < RENAME_LOG_MAX_FILE_BYTES) {
|
||||
return;
|
||||
}
|
||||
const backup = `${filePath}.old`;
|
||||
try {
|
||||
fs.rmSync(backup, { force: true });
|
||||
} catch {
|
||||
}
|
||||
fs.renameSync(filePath, backup);
|
||||
} catch {
|
||||
}
|
||||
}
|
||||
|
||||
function cleanupOldBackup(filePath: string): void {
|
||||
const backup = `${filePath}.old`;
|
||||
try {
|
||||
const stat = fs.statSync(backup);
|
||||
const cutoff = Date.now() - RENAME_LOG_RETENTION_DAYS * 24 * 60 * 60 * 1000;
|
||||
if (stat.mtimeMs < cutoff) {
|
||||
fs.rmSync(backup, { force: true });
|
||||
}
|
||||
} catch {
|
||||
}
|
||||
}
|
||||
|
||||
export function initRenameLog(baseDir: string): void {
|
||||
renameLogPath = path.join(baseDir, "rename.log");
|
||||
try {
|
||||
fs.mkdirSync(path.dirname(renameLogPath), { recursive: true });
|
||||
cleanupOldBackup(renameLogPath);
|
||||
if (!fs.existsSync(renameLogPath)) {
|
||||
fs.writeFileSync(renameLogPath, "", "utf8");
|
||||
}
|
||||
rotateIfNeeded(renameLogPath);
|
||||
if (!fs.existsSync(renameLogPath)) {
|
||||
fs.writeFileSync(renameLogPath, "", "utf8");
|
||||
}
|
||||
fs.appendFileSync(renameLogPath, `=== Rename-Log Start: ${logTimestamp()} ===\n`, "utf8");
|
||||
} catch {
|
||||
renameLogPath = null;
|
||||
}
|
||||
}
|
||||
|
||||
export function logRenameEvent(level: RenameLogLevel, message: string, fields?: Record<string, unknown>): void {
|
||||
if (!renameLogPath) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
rotateIfNeeded(renameLogPath);
|
||||
if (!fs.existsSync(renameLogPath)) {
|
||||
fs.writeFileSync(renameLogPath, "", "utf8");
|
||||
}
|
||||
fs.appendFileSync(
|
||||
renameLogPath,
|
||||
`${logTimestamp()} [${level}] ${message}${formatFields(fields)}\n`,
|
||||
"utf8"
|
||||
);
|
||||
} catch {
|
||||
}
|
||||
}
|
||||
|
||||
export function getRenameLogPath(): string | null {
|
||||
if (!renameLogPath) {
|
||||
return null;
|
||||
}
|
||||
return fs.existsSync(renameLogPath) ? renameLogPath : null;
|
||||
}
|
||||
|
||||
export function shutdownRenameLog(): void {
|
||||
if (!renameLogPath) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
fs.appendFileSync(renameLogPath, `=== Rename-Log Ende: ${logTimestamp()} ===\n`, "utf8");
|
||||
} catch {
|
||||
}
|
||||
renameLogPath = null;
|
||||
}
|
||||
import fs from "node:fs";
|
||||
import { logTimestamp } from "./log-timestamp";
|
||||
import path from "node:path";
|
||||
|
||||
type RenameLogLevel = "INFO" | "WARN" | "ERROR";
|
||||
|
||||
const RENAME_LOG_MAX_FILE_BYTES = Number(process.env.RD_RENAME_LOG_MAX_BYTES || 10 * 1024 * 1024);
|
||||
const RENAME_LOG_RETENTION_DAYS = Number(process.env.RD_RENAME_LOG_RETENTION_DAYS || 30);
|
||||
|
||||
let renameLogPath: string | null = null;
|
||||
|
||||
function sanitizeFieldValue(value: unknown): string {
|
||||
if (value === undefined || value === null) {
|
||||
return "";
|
||||
}
|
||||
if (typeof value === "string") {
|
||||
return value.replace(/\r?\n/g, "\\n");
|
||||
}
|
||||
if (typeof value === "number" || typeof value === "boolean") {
|
||||
return String(value);
|
||||
}
|
||||
try {
|
||||
return JSON.stringify(value).replace(/\r?\n/g, "\\n");
|
||||
} catch {
|
||||
return String(value);
|
||||
}
|
||||
}
|
||||
|
||||
function formatFields(fields?: Record<string, unknown>): string {
|
||||
if (!fields) {
|
||||
return "";
|
||||
}
|
||||
const parts = Object.entries(fields)
|
||||
.filter(([, value]) => value !== undefined && value !== null && sanitizeFieldValue(value) !== "")
|
||||
.map(([key, value]) => `${key}=${sanitizeFieldValue(value)}`);
|
||||
return parts.length > 0 ? ` | ${parts.join(" | ")}` : "";
|
||||
}
|
||||
|
||||
function rotateIfNeeded(filePath: string): void {
|
||||
try {
|
||||
const stat = fs.statSync(filePath);
|
||||
if (stat.size < RENAME_LOG_MAX_FILE_BYTES) {
|
||||
return;
|
||||
}
|
||||
const backup = `${filePath}.old`;
|
||||
try {
|
||||
fs.rmSync(backup, { force: true });
|
||||
} catch {
|
||||
}
|
||||
fs.renameSync(filePath, backup);
|
||||
} catch {
|
||||
}
|
||||
}
|
||||
|
||||
function cleanupOldBackup(filePath: string): void {
|
||||
const backup = `${filePath}.old`;
|
||||
try {
|
||||
const stat = fs.statSync(backup);
|
||||
const cutoff = Date.now() - RENAME_LOG_RETENTION_DAYS * 24 * 60 * 60 * 1000;
|
||||
if (stat.mtimeMs < cutoff) {
|
||||
fs.rmSync(backup, { force: true });
|
||||
}
|
||||
} catch {
|
||||
}
|
||||
}
|
||||
|
||||
export function initRenameLog(baseDir: string): void {
|
||||
renameLogPath = path.join(baseDir, "rename.log");
|
||||
try {
|
||||
fs.mkdirSync(path.dirname(renameLogPath), { recursive: true });
|
||||
cleanupOldBackup(renameLogPath);
|
||||
if (!fs.existsSync(renameLogPath)) {
|
||||
fs.writeFileSync(renameLogPath, "", "utf8");
|
||||
}
|
||||
rotateIfNeeded(renameLogPath);
|
||||
if (!fs.existsSync(renameLogPath)) {
|
||||
fs.writeFileSync(renameLogPath, "", "utf8");
|
||||
}
|
||||
fs.appendFileSync(renameLogPath, `=== Rename-Log Start: ${logTimestamp()} ===\n`, "utf8");
|
||||
} catch {
|
||||
renameLogPath = null;
|
||||
}
|
||||
}
|
||||
|
||||
export function logRenameEvent(level: RenameLogLevel, message: string, fields?: Record<string, unknown>): void {
|
||||
if (!renameLogPath) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
rotateIfNeeded(renameLogPath);
|
||||
if (!fs.existsSync(renameLogPath)) {
|
||||
fs.writeFileSync(renameLogPath, "", "utf8");
|
||||
}
|
||||
fs.appendFileSync(
|
||||
renameLogPath,
|
||||
`${logTimestamp()} [${level}] ${message}${formatFields(fields)}\n`,
|
||||
"utf8"
|
||||
);
|
||||
} catch {
|
||||
}
|
||||
}
|
||||
|
||||
export function getRenameLogPath(): string | null {
|
||||
if (!renameLogPath) {
|
||||
return null;
|
||||
}
|
||||
return fs.existsSync(renameLogPath) ? renameLogPath : null;
|
||||
}
|
||||
|
||||
export function shutdownRenameLog(): void {
|
||||
if (!renameLogPath) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
fs.appendFileSync(renameLogPath, `=== Rename-Log Ende: ${logTimestamp()} ===\n`, "utf8");
|
||||
} catch {
|
||||
}
|
||||
renameLogPath = null;
|
||||
}
|
||||
|
||||
@ -1,123 +1,123 @@
|
||||
import fs from "node:fs";
|
||||
import { logTimestamp } from "./log-timestamp";
|
||||
import path from "node:path";
|
||||
import { setLogListener } from "./logger";
|
||||
|
||||
const SESSION_LOG_FLUSH_INTERVAL_MS = 200;
|
||||
|
||||
let sessionLogPath: string | null = null;
|
||||
let sessionLogsDir: string | null = null;
|
||||
let pendingLines: string[] = [];
|
||||
let flushTimer: NodeJS.Timeout | null = null;
|
||||
|
||||
function formatTimestamp(): string {
|
||||
const now = new Date();
|
||||
const y = now.getFullYear();
|
||||
const mo = String(now.getMonth() + 1).padStart(2, "0");
|
||||
const d = String(now.getDate()).padStart(2, "0");
|
||||
const h = String(now.getHours()).padStart(2, "0");
|
||||
const mi = String(now.getMinutes()).padStart(2, "0");
|
||||
const s = String(now.getSeconds()).padStart(2, "0");
|
||||
return `${y}-${mo}-${d}_${h}-${mi}-${s}`;
|
||||
}
|
||||
|
||||
function flushPending(): void {
|
||||
if (pendingLines.length === 0 || !sessionLogPath) {
|
||||
return;
|
||||
}
|
||||
const chunk = pendingLines.join("");
|
||||
pendingLines = [];
|
||||
try {
|
||||
fs.appendFileSync(sessionLogPath, chunk, "utf8");
|
||||
} catch {
|
||||
}
|
||||
}
|
||||
|
||||
function scheduleFlush(): void {
|
||||
if (flushTimer) {
|
||||
return;
|
||||
}
|
||||
flushTimer = setTimeout(() => {
|
||||
flushTimer = null;
|
||||
flushPending();
|
||||
}, SESSION_LOG_FLUSH_INTERVAL_MS);
|
||||
}
|
||||
|
||||
function appendToSessionLog(line: string): void {
|
||||
if (!sessionLogPath) {
|
||||
return;
|
||||
}
|
||||
pendingLines.push(line);
|
||||
scheduleFlush();
|
||||
}
|
||||
|
||||
async function cleanupOldSessionLogs(dir: string, maxAgeDays: number): Promise<void> {
|
||||
try {
|
||||
const files = await fs.promises.readdir(dir);
|
||||
const cutoff = Date.now() - maxAgeDays * 24 * 60 * 60 * 1000;
|
||||
for (const file of files) {
|
||||
if (!file.startsWith("session_") || !file.endsWith(".txt")) {
|
||||
continue;
|
||||
}
|
||||
const filePath = path.join(dir, file);
|
||||
try {
|
||||
const stat = await fs.promises.stat(filePath);
|
||||
if (stat.mtimeMs < cutoff) {
|
||||
await fs.promises.unlink(filePath);
|
||||
}
|
||||
} catch {
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
}
|
||||
}
|
||||
|
||||
export function initSessionLog(baseDir: string): void {
|
||||
sessionLogsDir = path.join(baseDir, "session-logs");
|
||||
try {
|
||||
fs.mkdirSync(sessionLogsDir, { recursive: true });
|
||||
} catch {
|
||||
sessionLogsDir = null;
|
||||
return;
|
||||
}
|
||||
|
||||
const timestamp = formatTimestamp();
|
||||
sessionLogPath = path.join(sessionLogsDir, `session_${timestamp}.txt`);
|
||||
|
||||
const isoTimestamp = logTimestamp();
|
||||
try {
|
||||
fs.writeFileSync(sessionLogPath, `=== Session gestartet: ${isoTimestamp} ===\n`, "utf8");
|
||||
} catch {
|
||||
sessionLogPath = null;
|
||||
return;
|
||||
}
|
||||
|
||||
setLogListener((line) => appendToSessionLog(line));
|
||||
|
||||
void cleanupOldSessionLogs(sessionLogsDir, 7);
|
||||
}
|
||||
|
||||
export function getSessionLogPath(): string | null {
|
||||
return sessionLogPath;
|
||||
}
|
||||
|
||||
export function shutdownSessionLog(): void {
|
||||
if (!sessionLogPath) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (flushTimer) {
|
||||
clearTimeout(flushTimer);
|
||||
flushTimer = null;
|
||||
}
|
||||
flushPending();
|
||||
|
||||
const isoTimestamp = logTimestamp();
|
||||
try {
|
||||
fs.appendFileSync(sessionLogPath, `=== Session beendet: ${isoTimestamp} ===\n`, "utf8");
|
||||
} catch {
|
||||
}
|
||||
|
||||
setLogListener(null);
|
||||
sessionLogPath = null;
|
||||
}
|
||||
import fs from "node:fs";
|
||||
import { logTimestamp } from "./log-timestamp";
|
||||
import path from "node:path";
|
||||
import { setLogListener } from "./logger";
|
||||
|
||||
const SESSION_LOG_FLUSH_INTERVAL_MS = 200;
|
||||
|
||||
let sessionLogPath: string | null = null;
|
||||
let sessionLogsDir: string | null = null;
|
||||
let pendingLines: string[] = [];
|
||||
let flushTimer: NodeJS.Timeout | null = null;
|
||||
|
||||
function formatTimestamp(): string {
|
||||
const now = new Date();
|
||||
const y = now.getFullYear();
|
||||
const mo = String(now.getMonth() + 1).padStart(2, "0");
|
||||
const d = String(now.getDate()).padStart(2, "0");
|
||||
const h = String(now.getHours()).padStart(2, "0");
|
||||
const mi = String(now.getMinutes()).padStart(2, "0");
|
||||
const s = String(now.getSeconds()).padStart(2, "0");
|
||||
return `${y}-${mo}-${d}_${h}-${mi}-${s}`;
|
||||
}
|
||||
|
||||
function flushPending(): void {
|
||||
if (pendingLines.length === 0 || !sessionLogPath) {
|
||||
return;
|
||||
}
|
||||
const chunk = pendingLines.join("");
|
||||
pendingLines = [];
|
||||
try {
|
||||
fs.appendFileSync(sessionLogPath, chunk, "utf8");
|
||||
} catch {
|
||||
}
|
||||
}
|
||||
|
||||
function scheduleFlush(): void {
|
||||
if (flushTimer) {
|
||||
return;
|
||||
}
|
||||
flushTimer = setTimeout(() => {
|
||||
flushTimer = null;
|
||||
flushPending();
|
||||
}, SESSION_LOG_FLUSH_INTERVAL_MS);
|
||||
}
|
||||
|
||||
function appendToSessionLog(line: string): void {
|
||||
if (!sessionLogPath) {
|
||||
return;
|
||||
}
|
||||
pendingLines.push(line);
|
||||
scheduleFlush();
|
||||
}
|
||||
|
||||
async function cleanupOldSessionLogs(dir: string, maxAgeDays: number): Promise<void> {
|
||||
try {
|
||||
const files = await fs.promises.readdir(dir);
|
||||
const cutoff = Date.now() - maxAgeDays * 24 * 60 * 60 * 1000;
|
||||
for (const file of files) {
|
||||
if (!file.startsWith("session_") || !file.endsWith(".txt")) {
|
||||
continue;
|
||||
}
|
||||
const filePath = path.join(dir, file);
|
||||
try {
|
||||
const stat = await fs.promises.stat(filePath);
|
||||
if (stat.mtimeMs < cutoff) {
|
||||
await fs.promises.unlink(filePath);
|
||||
}
|
||||
} catch {
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
}
|
||||
}
|
||||
|
||||
export function initSessionLog(baseDir: string): void {
|
||||
sessionLogsDir = path.join(baseDir, "session-logs");
|
||||
try {
|
||||
fs.mkdirSync(sessionLogsDir, { recursive: true });
|
||||
} catch {
|
||||
sessionLogsDir = null;
|
||||
return;
|
||||
}
|
||||
|
||||
const timestamp = formatTimestamp();
|
||||
sessionLogPath = path.join(sessionLogsDir, `session_${timestamp}.txt`);
|
||||
|
||||
const isoTimestamp = logTimestamp();
|
||||
try {
|
||||
fs.writeFileSync(sessionLogPath, `=== Session gestartet: ${isoTimestamp} ===\n`, "utf8");
|
||||
} catch {
|
||||
sessionLogPath = null;
|
||||
return;
|
||||
}
|
||||
|
||||
setLogListener((line) => appendToSessionLog(line));
|
||||
|
||||
void cleanupOldSessionLogs(sessionLogsDir, 7);
|
||||
}
|
||||
|
||||
export function getSessionLogPath(): string | null {
|
||||
return sessionLogPath;
|
||||
}
|
||||
|
||||
export function shutdownSessionLog(): void {
|
||||
if (!sessionLogPath) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (flushTimer) {
|
||||
clearTimeout(flushTimer);
|
||||
flushTimer = null;
|
||||
}
|
||||
flushPending();
|
||||
|
||||
const isoTimestamp = logTimestamp();
|
||||
try {
|
||||
fs.appendFileSync(sessionLogPath, `=== Session beendet: ${isoTimestamp} ===\n`, "utf8");
|
||||
} catch {
|
||||
}
|
||||
|
||||
setLogListener(null);
|
||||
sessionLogPath = null;
|
||||
}
|
||||
|
||||
@ -1,195 +1,195 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { AppSettings } from "../shared/types";
|
||||
import { parseDebridLinkApiKeys } from "../shared/debrid-link-keys";
|
||||
import { parseMegaDebridAccounts } from "../shared/mega-debrid-accounts";
|
||||
import { StoragePaths } from "./storage";
|
||||
|
||||
export type HealthCheckSeverity = "INFO" | "WARN" | "ERROR";
|
||||
|
||||
export interface HealthCheckFinding {
|
||||
severity: HealthCheckSeverity;
|
||||
code: string;
|
||||
message: string;
|
||||
hint?: string;
|
||||
}
|
||||
|
||||
export interface HealthCheckReport {
|
||||
findings: HealthCheckFinding[];
|
||||
errorCount: number;
|
||||
warnCount: number;
|
||||
infoCount: number;
|
||||
}
|
||||
|
||||
const LOW_DISK_SPACE_BYTES = 5 * 1024 * 1024 * 1024;
|
||||
const LARGE_STATE_FILE_BYTES = 50 * 1024 * 1024;
|
||||
|
||||
function safeExists(p: string): boolean {
|
||||
try {
|
||||
return fs.existsSync(p);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function getFileSizeBytes(p: string): number {
|
||||
try {
|
||||
const stat = fs.statSync(p);
|
||||
return stat.size;
|
||||
} catch {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
function isWritable(dir: string): boolean {
|
||||
const probe = path.join(dir, `.rddl-health-probe-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`);
|
||||
try {
|
||||
fs.writeFileSync(probe, "x", { encoding: "utf8" });
|
||||
fs.rmSync(probe, { force: true });
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function getFreeDiskSpaceBytes(target: string): number | null {
|
||||
try {
|
||||
const statfs = (fs as unknown as { statfsSync?: (p: string) => { bavail: bigint; bsize: bigint } }).statfsSync;
|
||||
if (typeof statfs !== "function") {
|
||||
return null;
|
||||
}
|
||||
const result = statfs(target);
|
||||
const bavail = BigInt(result.bavail);
|
||||
const bsize = BigInt(result.bsize);
|
||||
const free = bavail * bsize;
|
||||
if (free > BigInt(Number.MAX_SAFE_INTEGER)) {
|
||||
return Number.MAX_SAFE_INTEGER;
|
||||
}
|
||||
return Number(free);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function countConfiguredProviders(settings: AppSettings): { count: number; providers: string[] } {
|
||||
const providers: string[] = [];
|
||||
if (settings.token?.trim() || settings.realDebridUseWebLogin) {
|
||||
providers.push("Real-Debrid");
|
||||
}
|
||||
if (settings.allDebridToken?.trim() || settings.allDebridUseWebLogin) {
|
||||
providers.push("AllDebrid");
|
||||
}
|
||||
if (settings.bestToken?.trim() || settings.bestDebridUseWebLogin) {
|
||||
providers.push("BestDebrid");
|
||||
}
|
||||
if (settings.oneFichierApiKey?.trim()) {
|
||||
providers.push("1Fichier");
|
||||
}
|
||||
if (settings.ddownloadLogin?.trim() && settings.ddownloadPassword?.trim()) {
|
||||
providers.push("DDownload");
|
||||
}
|
||||
if (settings.linkSnappyLogin?.trim() && settings.linkSnappyPassword?.trim()) {
|
||||
providers.push("LinkSnappy");
|
||||
}
|
||||
const dlKeys = parseDebridLinkApiKeys(settings.debridLinkApiKeys || "");
|
||||
if (dlKeys.length > 0) {
|
||||
providers.push(`Debrid-Link (${dlKeys.length} Key${dlKeys.length === 1 ? "" : "s"})`);
|
||||
}
|
||||
const megaAccounts = parseMegaDebridAccounts(settings.megaCredentials || "");
|
||||
const legacyMegaConfigured = Boolean(settings.megaLogin?.trim() && settings.megaPassword?.trim());
|
||||
if (megaAccounts.length > 0) {
|
||||
providers.push(`Mega-Debrid (${megaAccounts.length} Acc)`);
|
||||
} else if (legacyMegaConfigured) {
|
||||
providers.push("Mega-Debrid");
|
||||
}
|
||||
return { count: providers.length, providers };
|
||||
}
|
||||
|
||||
export function runStartupHealthCheck(settings: AppSettings, storagePaths: StoragePaths): HealthCheckReport {
|
||||
const findings: HealthCheckFinding[] = [];
|
||||
|
||||
const outputDir = String(settings.outputDir || "").trim();
|
||||
if (!outputDir) {
|
||||
findings.push({
|
||||
severity: "WARN",
|
||||
code: "outputDir_missing",
|
||||
message: "Kein Download-Ziel-Verzeichnis konfiguriert",
|
||||
hint: "In den Einstellungen unter 'Downloads' einen Ziel-Ordner setzen, sonst koennen keine Downloads starten."
|
||||
});
|
||||
} else if (!safeExists(outputDir)) {
|
||||
findings.push({
|
||||
severity: "WARN",
|
||||
code: "outputDir_not_found",
|
||||
message: `Download-Ziel-Ordner existiert nicht: ${outputDir}`,
|
||||
hint: "Der Ordner wird beim ersten Download automatisch erstellt, sofern der Elternordner existiert und beschreibbar ist."
|
||||
});
|
||||
} else if (!isWritable(outputDir)) {
|
||||
findings.push({
|
||||
severity: "ERROR",
|
||||
code: "outputDir_not_writable",
|
||||
message: `Download-Ziel-Ordner ist NICHT beschreibbar: ${outputDir}`,
|
||||
hint: "Rechte pruefen oder anderen Ordner waehlen. Downloads werden sonst direkt scheitern."
|
||||
});
|
||||
} else {
|
||||
const freeBytes = getFreeDiskSpaceBytes(outputDir);
|
||||
if (freeBytes !== null && freeBytes < LOW_DISK_SPACE_BYTES) {
|
||||
const freeMb = Math.round(freeBytes / (1024 * 1024));
|
||||
findings.push({
|
||||
severity: "WARN",
|
||||
code: "low_disk_space",
|
||||
message: `Wenig freier Speicher im Download-Ordner: ~${freeMb} MB verfuegbar (Schwelle ${LOW_DISK_SPACE_BYTES / (1024 * 1024 * 1024)} GB)`,
|
||||
hint: "Groessere Downloads koennen auf halbem Weg fehlschlagen. Vorher Platz schaffen oder anderen Ordner waehlen."
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const { count, providers } = countConfiguredProviders(settings);
|
||||
if (count === 0) {
|
||||
findings.push({
|
||||
severity: "WARN",
|
||||
code: "no_provider_configured",
|
||||
message: "Kein Debrid-Provider konfiguriert — Downloads werden nicht funktionieren",
|
||||
hint: "In den Einstellungen mindestens einen Provider (Real-Debrid, Mega-Debrid, Debrid-Link, ...) einrichten."
|
||||
});
|
||||
} else {
|
||||
findings.push({
|
||||
severity: "INFO",
|
||||
code: "providers_configured",
|
||||
message: `Konfigurierte Provider: ${providers.join(", ")}`
|
||||
});
|
||||
}
|
||||
|
||||
if (safeExists(storagePaths.sessionFile)) {
|
||||
const sizeBytes = getFileSizeBytes(storagePaths.sessionFile);
|
||||
if (sizeBytes > LARGE_STATE_FILE_BYTES) {
|
||||
const sizeMb = Math.round(sizeBytes / (1024 * 1024));
|
||||
findings.push({
|
||||
severity: "WARN",
|
||||
code: "large_state_file",
|
||||
message: `State-Datei ist sehr gross: ${sizeMb} MB (${path.basename(storagePaths.sessionFile)})`,
|
||||
hint: "Alte abgeschlossene Pakete aus der Queue entfernen, damit Startup + Save schneller werden."
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (!safeExists(storagePaths.baseDir)) {
|
||||
findings.push({
|
||||
severity: "ERROR",
|
||||
code: "baseDir_missing",
|
||||
message: `Runtime-Verzeichnis existiert nicht: ${storagePaths.baseDir}`,
|
||||
hint: "Ohne Runtime-Verzeichnis koennen weder Settings noch Session-State persistiert werden."
|
||||
});
|
||||
} else if (!isWritable(storagePaths.baseDir)) {
|
||||
findings.push({
|
||||
severity: "ERROR",
|
||||
code: "baseDir_not_writable",
|
||||
message: `Runtime-Verzeichnis ist NICHT beschreibbar: ${storagePaths.baseDir}`,
|
||||
hint: "Rechte auf das Runtime-Verzeichnis pruefen (%APPDATA%/Real-Debrid-Downloader/runtime)."
|
||||
});
|
||||
}
|
||||
|
||||
const errorCount = findings.filter((f) => f.severity === "ERROR").length;
|
||||
const warnCount = findings.filter((f) => f.severity === "WARN").length;
|
||||
const infoCount = findings.filter((f) => f.severity === "INFO").length;
|
||||
return { findings, errorCount, warnCount, infoCount };
|
||||
}
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { AppSettings } from "../shared/types";
|
||||
import { parseDebridLinkApiKeys } from "../shared/debrid-link-keys";
|
||||
import { parseMegaDebridAccounts } from "../shared/mega-debrid-accounts";
|
||||
import { StoragePaths } from "./storage";
|
||||
|
||||
export type HealthCheckSeverity = "INFO" | "WARN" | "ERROR";
|
||||
|
||||
export interface HealthCheckFinding {
|
||||
severity: HealthCheckSeverity;
|
||||
code: string;
|
||||
message: string;
|
||||
hint?: string;
|
||||
}
|
||||
|
||||
export interface HealthCheckReport {
|
||||
findings: HealthCheckFinding[];
|
||||
errorCount: number;
|
||||
warnCount: number;
|
||||
infoCount: number;
|
||||
}
|
||||
|
||||
const LOW_DISK_SPACE_BYTES = 5 * 1024 * 1024 * 1024;
|
||||
const LARGE_STATE_FILE_BYTES = 50 * 1024 * 1024;
|
||||
|
||||
function safeExists(p: string): boolean {
|
||||
try {
|
||||
return fs.existsSync(p);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function getFileSizeBytes(p: string): number {
|
||||
try {
|
||||
const stat = fs.statSync(p);
|
||||
return stat.size;
|
||||
} catch {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
function isWritable(dir: string): boolean {
|
||||
const probe = path.join(dir, `.rddl-health-probe-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`);
|
||||
try {
|
||||
fs.writeFileSync(probe, "x", { encoding: "utf8" });
|
||||
fs.rmSync(probe, { force: true });
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function getFreeDiskSpaceBytes(target: string): number | null {
|
||||
try {
|
||||
const statfs = (fs as unknown as { statfsSync?: (p: string) => { bavail: bigint; bsize: bigint } }).statfsSync;
|
||||
if (typeof statfs !== "function") {
|
||||
return null;
|
||||
}
|
||||
const result = statfs(target);
|
||||
const bavail = BigInt(result.bavail);
|
||||
const bsize = BigInt(result.bsize);
|
||||
const free = bavail * bsize;
|
||||
if (free > BigInt(Number.MAX_SAFE_INTEGER)) {
|
||||
return Number.MAX_SAFE_INTEGER;
|
||||
}
|
||||
return Number(free);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function countConfiguredProviders(settings: AppSettings): { count: number; providers: string[] } {
|
||||
const providers: string[] = [];
|
||||
if (settings.token?.trim() || settings.realDebridUseWebLogin) {
|
||||
providers.push("Real-Debrid");
|
||||
}
|
||||
if (settings.allDebridToken?.trim() || settings.allDebridUseWebLogin) {
|
||||
providers.push("AllDebrid");
|
||||
}
|
||||
if (settings.bestToken?.trim() || settings.bestDebridUseWebLogin) {
|
||||
providers.push("BestDebrid");
|
||||
}
|
||||
if (settings.oneFichierApiKey?.trim()) {
|
||||
providers.push("1Fichier");
|
||||
}
|
||||
if (settings.ddownloadLogin?.trim() && settings.ddownloadPassword?.trim()) {
|
||||
providers.push("DDownload");
|
||||
}
|
||||
if (settings.linkSnappyLogin?.trim() && settings.linkSnappyPassword?.trim()) {
|
||||
providers.push("LinkSnappy");
|
||||
}
|
||||
const dlKeys = parseDebridLinkApiKeys(settings.debridLinkApiKeys || "");
|
||||
if (dlKeys.length > 0) {
|
||||
providers.push(`Debrid-Link (${dlKeys.length} Key${dlKeys.length === 1 ? "" : "s"})`);
|
||||
}
|
||||
const megaAccounts = parseMegaDebridAccounts(settings.megaCredentials || "");
|
||||
const legacyMegaConfigured = Boolean(settings.megaLogin?.trim() && settings.megaPassword?.trim());
|
||||
if (megaAccounts.length > 0) {
|
||||
providers.push(`Mega-Debrid (${megaAccounts.length} Acc)`);
|
||||
} else if (legacyMegaConfigured) {
|
||||
providers.push("Mega-Debrid");
|
||||
}
|
||||
return { count: providers.length, providers };
|
||||
}
|
||||
|
||||
export function runStartupHealthCheck(settings: AppSettings, storagePaths: StoragePaths): HealthCheckReport {
|
||||
const findings: HealthCheckFinding[] = [];
|
||||
|
||||
const outputDir = String(settings.outputDir || "").trim();
|
||||
if (!outputDir) {
|
||||
findings.push({
|
||||
severity: "WARN",
|
||||
code: "outputDir_missing",
|
||||
message: "Kein Download-Ziel-Verzeichnis konfiguriert",
|
||||
hint: "In den Einstellungen unter 'Downloads' einen Ziel-Ordner setzen, sonst koennen keine Downloads starten."
|
||||
});
|
||||
} else if (!safeExists(outputDir)) {
|
||||
findings.push({
|
||||
severity: "WARN",
|
||||
code: "outputDir_not_found",
|
||||
message: `Download-Ziel-Ordner existiert nicht: ${outputDir}`,
|
||||
hint: "Der Ordner wird beim ersten Download automatisch erstellt, sofern der Elternordner existiert und beschreibbar ist."
|
||||
});
|
||||
} else if (!isWritable(outputDir)) {
|
||||
findings.push({
|
||||
severity: "ERROR",
|
||||
code: "outputDir_not_writable",
|
||||
message: `Download-Ziel-Ordner ist NICHT beschreibbar: ${outputDir}`,
|
||||
hint: "Rechte pruefen oder anderen Ordner waehlen. Downloads werden sonst direkt scheitern."
|
||||
});
|
||||
} else {
|
||||
const freeBytes = getFreeDiskSpaceBytes(outputDir);
|
||||
if (freeBytes !== null && freeBytes < LOW_DISK_SPACE_BYTES) {
|
||||
const freeMb = Math.round(freeBytes / (1024 * 1024));
|
||||
findings.push({
|
||||
severity: "WARN",
|
||||
code: "low_disk_space",
|
||||
message: `Wenig freier Speicher im Download-Ordner: ~${freeMb} MB verfuegbar (Schwelle ${LOW_DISK_SPACE_BYTES / (1024 * 1024 * 1024)} GB)`,
|
||||
hint: "Groessere Downloads koennen auf halbem Weg fehlschlagen. Vorher Platz schaffen oder anderen Ordner waehlen."
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const { count, providers } = countConfiguredProviders(settings);
|
||||
if (count === 0) {
|
||||
findings.push({
|
||||
severity: "WARN",
|
||||
code: "no_provider_configured",
|
||||
message: "Kein Debrid-Provider konfiguriert — Downloads werden nicht funktionieren",
|
||||
hint: "In den Einstellungen mindestens einen Provider (Real-Debrid, Mega-Debrid, Debrid-Link, ...) einrichten."
|
||||
});
|
||||
} else {
|
||||
findings.push({
|
||||
severity: "INFO",
|
||||
code: "providers_configured",
|
||||
message: `Konfigurierte Provider: ${providers.join(", ")}`
|
||||
});
|
||||
}
|
||||
|
||||
if (safeExists(storagePaths.sessionFile)) {
|
||||
const sizeBytes = getFileSizeBytes(storagePaths.sessionFile);
|
||||
if (sizeBytes > LARGE_STATE_FILE_BYTES) {
|
||||
const sizeMb = Math.round(sizeBytes / (1024 * 1024));
|
||||
findings.push({
|
||||
severity: "WARN",
|
||||
code: "large_state_file",
|
||||
message: `State-Datei ist sehr gross: ${sizeMb} MB (${path.basename(storagePaths.sessionFile)})`,
|
||||
hint: "Alte abgeschlossene Pakete aus der Queue entfernen, damit Startup + Save schneller werden."
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (!safeExists(storagePaths.baseDir)) {
|
||||
findings.push({
|
||||
severity: "ERROR",
|
||||
code: "baseDir_missing",
|
||||
message: `Runtime-Verzeichnis existiert nicht: ${storagePaths.baseDir}`,
|
||||
hint: "Ohne Runtime-Verzeichnis koennen weder Settings noch Session-State persistiert werden."
|
||||
});
|
||||
} else if (!isWritable(storagePaths.baseDir)) {
|
||||
findings.push({
|
||||
severity: "ERROR",
|
||||
code: "baseDir_not_writable",
|
||||
message: `Runtime-Verzeichnis ist NICHT beschreibbar: ${storagePaths.baseDir}`,
|
||||
hint: "Rechte auf das Runtime-Verzeichnis pruefen (%APPDATA%/Real-Debrid-Downloader/runtime)."
|
||||
});
|
||||
}
|
||||
|
||||
const errorCount = findings.filter((f) => f.severity === "ERROR").length;
|
||||
const warnCount = findings.filter((f) => f.severity === "WARN").length;
|
||||
const infoCount = findings.filter((f) => f.severity === "INFO").length;
|
||||
return { findings, errorCount, warnCount, infoCount };
|
||||
}
|
||||
|
||||
2680
src/main/storage.ts
2680
src/main/storage.ts
File diff suppressed because it is too large
Load Diff
@ -1,224 +1,224 @@
|
||||
import { promises as fsp } from "node:fs";
|
||||
import path from "node:path";
|
||||
import AdmZip from "adm-zip";
|
||||
import { APP_VERSION } from "./constants";
|
||||
import { getAccountRotationLogPath } from "./account-rotation-log";
|
||||
import { getConversionLogPath } from "./conversion-trace";
|
||||
import { getAuditLogPath } from "./audit-log";
|
||||
import { getDebugSetupCheck } from "./debug-setup";
|
||||
import { getLogFilePath } from "./logger";
|
||||
import { getRecentErrors } from "./error-ring";
|
||||
import { getPackageLogPath } from "./package-log";
|
||||
import { getRenameLogPath } from "./rename-log";
|
||||
import { getDesktopRenameLogPath } from "./desktop-rename-log";
|
||||
import { getSessionLogPath } from "./session-log";
|
||||
import { createStoragePaths, loadHistory, loadSettings } from "./storage";
|
||||
import { buildAccountSummary, buildRedactedSettingsPayload, buildStatsPayload, summarizeHistoryEntry } from "./support-data";
|
||||
import { getTraceConfig, getTraceConfigPath, getTraceLogPath } from "./trace-log";
|
||||
import { getCachedWindowsHostDiagnostics, getWindowsHostDiagnostics } from "./windows-host-diagnostics";
|
||||
import type { DownloadManager } from "./download-manager";
|
||||
|
||||
const SUPPORT_MANIFEST_FILE = "debug_support_manifest.json";
|
||||
|
||||
async function safeReadJson(filePath: string): Promise<unknown> {
|
||||
try {
|
||||
return JSON.parse(await fsp.readFile(filePath, "utf8")) as unknown;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function addJson(zip: AdmZip, zipPath: string, value: unknown): void {
|
||||
zip.addFile(zipPath, Buffer.from(`${JSON.stringify(value, null, 2)}\n`, "utf8"));
|
||||
}
|
||||
|
||||
async function addFileIfExists(zip: AdmZip, sourcePath: string | null, zipPath: string): Promise<void> {
|
||||
if (!sourcePath) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const buffer = await fsp.readFile(sourcePath);
|
||||
zip.addFile(zipPath, buffer);
|
||||
} catch {
|
||||
}
|
||||
}
|
||||
|
||||
async function addDirectoryIfExists(zip: AdmZip, dirPath: string, zipRoot: string): Promise<void> {
|
||||
let entries;
|
||||
try {
|
||||
entries = await fsp.readdir(dirPath, { withFileTypes: true });
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
for (const entry of entries) {
|
||||
const fullPath = path.join(dirPath, entry.name);
|
||||
const zipPath = path.posix.join(zipRoot, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
await addDirectoryIfExists(zip, fullPath, zipPath);
|
||||
continue;
|
||||
}
|
||||
await addFileIfExists(zip, fullPath, zipPath);
|
||||
}
|
||||
}
|
||||
|
||||
async function addRecentDirectoryFiles(zip: AdmZip, dirPath: string, zipRoot: string, maxAgeMs: number): Promise<number> {
|
||||
let entries;
|
||||
try {
|
||||
entries = await fsp.readdir(dirPath, { withFileTypes: true });
|
||||
} catch {
|
||||
return 0;
|
||||
}
|
||||
const cutoff = Date.now() - maxAgeMs;
|
||||
let added = 0;
|
||||
for (const entry of entries) {
|
||||
if (!entry.isFile()) continue;
|
||||
const fullPath = path.join(dirPath, entry.name);
|
||||
try {
|
||||
if ((await fsp.stat(fullPath)).mtimeMs >= cutoff) {
|
||||
await addFileIfExists(zip, fullPath, path.posix.join(zipRoot, entry.name));
|
||||
added += 1;
|
||||
}
|
||||
} catch { }
|
||||
}
|
||||
return added;
|
||||
}
|
||||
|
||||
function formatTimestampForFileName(date: Date): string {
|
||||
const y = date.getFullYear();
|
||||
const mo = String(date.getMonth() + 1).padStart(2, "0");
|
||||
const d = String(date.getDate()).padStart(2, "0");
|
||||
const h = String(date.getHours()).padStart(2, "0");
|
||||
const mi = String(date.getMinutes()).padStart(2, "0");
|
||||
const s = String(date.getSeconds()).padStart(2, "0");
|
||||
return `${y}-${mo}-${d}_${h}-${mi}-${s}`;
|
||||
}
|
||||
|
||||
export function getSupportBundleDefaultFileName(): string {
|
||||
return `rd-support-bundle-${formatTimestampForFileName(new Date())}.zip`;
|
||||
}
|
||||
|
||||
type HostDiagnosticsMode = "full" | "cached" | "none";
|
||||
|
||||
interface BuildSupportBundleOptions {
|
||||
hostDiagnosticsMode?: HostDiagnosticsMode;
|
||||
}
|
||||
|
||||
function createDeferredHostDiagnostics(reason: string): unknown {
|
||||
return {
|
||||
collectedAt: new Date().toISOString(),
|
||||
supported: process.platform === "win32",
|
||||
platform: process.platform,
|
||||
crashControl: null,
|
||||
recentKernelPower: [],
|
||||
recentWerKernel: [],
|
||||
recentKernelDump: [],
|
||||
recentAppCrashes: [],
|
||||
recentMinidumps: [],
|
||||
assessmentHints: [
|
||||
reason
|
||||
],
|
||||
errors: []
|
||||
};
|
||||
}
|
||||
|
||||
function resolveHostDiagnostics(mode: HostDiagnosticsMode): unknown {
|
||||
if (mode === "none") {
|
||||
return createDeferredHostDiagnostics("Host-Diagnose wurde fuer diesen Bundle-Export deaktiviert.");
|
||||
}
|
||||
if (mode === "cached") {
|
||||
const cached = getCachedWindowsHostDiagnostics();
|
||||
if (cached) {
|
||||
return cached;
|
||||
}
|
||||
return createDeferredHostDiagnostics("Host-Diagnose wurde uebersprungen, um den Export nicht zu blockieren. Fuer eine Voll-Diagnose /host/diagnostics nutzen.");
|
||||
}
|
||||
return getWindowsHostDiagnostics();
|
||||
}
|
||||
|
||||
export async function buildSupportBundle(manager: DownloadManager, baseDir: string, options: BuildSupportBundleOptions = {}): Promise<Buffer> {
|
||||
const zip = new AdmZip();
|
||||
const hostDiagnosticsMode = options.hostDiagnosticsMode || "full";
|
||||
const storagePaths = createStoragePaths(baseDir);
|
||||
const settings = loadSettings(storagePaths);
|
||||
const history = loadHistory(storagePaths);
|
||||
const snapshot = manager.getSnapshot();
|
||||
const packageIds = Object.keys(snapshot.session.packages);
|
||||
const itemIds = Object.keys(snapshot.session.items);
|
||||
const debugSetup = getDebugSetupCheck(baseDir);
|
||||
|
||||
addJson(zip, "overview/meta.json", {
|
||||
appVersion: APP_VERSION,
|
||||
generatedAt: new Date().toISOString(),
|
||||
runtimeBaseDir: baseDir,
|
||||
packageCount: packageIds.length,
|
||||
itemCount: itemIds.length
|
||||
});
|
||||
addJson(zip, "overview/status.json", snapshot.session);
|
||||
addJson(zip, "overview/settings.json", buildRedactedSettingsPayload(settings));
|
||||
addJson(zip, "overview/accounts.json", buildAccountSummary(settings));
|
||||
addJson(zip, "overview/stats.json", {
|
||||
...buildStatsPayload(snapshot),
|
||||
allTime: {
|
||||
totalDownloadedAllTime: settings.totalDownloadedAllTime,
|
||||
totalCompletedFilesAllTime: settings.totalCompletedFilesAllTime,
|
||||
totalRuntimeAllTimeMs: settings.totalRuntimeAllTimeMs
|
||||
}
|
||||
});
|
||||
addJson(zip, "overview/debug-setup.json", debugSetup);
|
||||
addJson(zip, "overview/self-check.json", debugSetup);
|
||||
addJson(zip, "overview/history.json", {
|
||||
total: history.length,
|
||||
entries: history.map((entry) => summarizeHistoryEntry(entry))
|
||||
});
|
||||
addJson(zip, "overview/packages.json", {
|
||||
count: packageIds.length,
|
||||
packages: packageIds.map((packageId) => snapshot.session.packages[packageId]).filter(Boolean)
|
||||
});
|
||||
addJson(zip, "overview/items.json", {
|
||||
count: itemIds.length,
|
||||
items: itemIds.map((itemId) => snapshot.session.items[itemId]).filter(Boolean)
|
||||
});
|
||||
addJson(zip, "overview/host-diagnostics.json", resolveHostDiagnostics(hostDiagnosticsMode));
|
||||
addJson(zip, "overview/trace-config.json", getTraceConfig());
|
||||
const recentErrors = getRecentErrors();
|
||||
addJson(zip, "overview/recent-errors.json", { count: recentErrors.length, entries: recentErrors });
|
||||
|
||||
await addFileIfExists(zip, path.join(baseDir, SUPPORT_MANIFEST_FILE), `runtime/${SUPPORT_MANIFEST_FILE}`);
|
||||
await addFileIfExists(zip, path.join(baseDir, "debug_host.txt"), "runtime/debug_host.txt");
|
||||
await addFileIfExists(zip, path.join(baseDir, "debug_port.txt"), "runtime/debug_port.txt");
|
||||
await addFileIfExists(zip, getTraceConfigPath(), "runtime/trace_config.json");
|
||||
|
||||
await addFileIfExists(zip, getLogFilePath(), "logs/rd_downloader.log");
|
||||
await addFileIfExists(zip, `${getLogFilePath()}.old`, "logs/rd_downloader.log.old");
|
||||
await addFileIfExists(zip, getAuditLogPath(), "logs/audit.log");
|
||||
await addFileIfExists(zip, getAuditLogPath() ? `${getAuditLogPath()}.old` : null, "logs/audit.log.old");
|
||||
await addFileIfExists(zip, getRenameLogPath(), "logs/rename.log");
|
||||
await addFileIfExists(zip, getRenameLogPath() ? `${getRenameLogPath()}.old` : null, "logs/rename.log.old");
|
||||
await addFileIfExists(zip, getDesktopRenameLogPath(), "logs/rename-session-desktop.txt");
|
||||
await addFileIfExists(zip, getSessionLogPath(), "logs/session.log");
|
||||
await addFileIfExists(zip, getTraceLogPath(), "logs/trace.log");
|
||||
await addFileIfExists(zip, getTraceLogPath() ? `${getTraceLogPath()}.old` : null, "logs/trace.log.old");
|
||||
await addFileIfExists(zip, getAccountRotationLogPath(), "logs/account-rotation.log");
|
||||
await addFileIfExists(zip, getAccountRotationLogPath() ? `${getAccountRotationLogPath()}.old` : null, "logs/account-rotation.log.old");
|
||||
await addFileIfExists(zip, getConversionLogPath(), "logs/conversion.log");
|
||||
await addFileIfExists(zip, getConversionLogPath() ? `${getConversionLogPath()}.old` : null, "logs/conversion.log.old");
|
||||
|
||||
const SUPPORT_BUNDLE_LOG_WINDOW_MS = 8 * 60 * 60 * 1000;
|
||||
await addDirectoryIfExists(zip, path.join(baseDir, "session-logs"), "logs/session-logs");
|
||||
await addRecentDirectoryFiles(zip, path.join(baseDir, "package-logs"), "logs/package-logs", SUPPORT_BUNDLE_LOG_WINDOW_MS);
|
||||
await addRecentDirectoryFiles(zip, path.join(baseDir, "item-logs"), "logs/item-logs", SUPPORT_BUNDLE_LOG_WINDOW_MS);
|
||||
|
||||
for (const packageId of packageIds) {
|
||||
await addFileIfExists(zip, manager.getPackageLogPath(packageId) || getPackageLogPath(packageId), `logs/live/package-${packageId}.txt`);
|
||||
}
|
||||
for (const itemId of itemIds) {
|
||||
await addFileIfExists(zip, manager.getItemLogPath(itemId), `logs/live/item-${itemId}.txt`);
|
||||
}
|
||||
|
||||
const supportManifest = await safeReadJson(path.join(baseDir, SUPPORT_MANIFEST_FILE));
|
||||
if (supportManifest) {
|
||||
addJson(zip, "overview/support-manifest.json", supportManifest);
|
||||
}
|
||||
|
||||
return zip.toBuffer();
|
||||
}
|
||||
import { promises as fsp } from "node:fs";
|
||||
import path from "node:path";
|
||||
import AdmZip from "adm-zip";
|
||||
import { APP_VERSION } from "./constants";
|
||||
import { getAccountRotationLogPath } from "./account-rotation-log";
|
||||
import { getConversionLogPath } from "./conversion-trace";
|
||||
import { getAuditLogPath } from "./audit-log";
|
||||
import { getDebugSetupCheck } from "./debug-setup";
|
||||
import { getLogFilePath } from "./logger";
|
||||
import { getRecentErrors } from "./error-ring";
|
||||
import { getPackageLogPath } from "./package-log";
|
||||
import { getRenameLogPath } from "./rename-log";
|
||||
import { getDesktopRenameLogPath } from "./desktop-rename-log";
|
||||
import { getSessionLogPath } from "./session-log";
|
||||
import { createStoragePaths, loadHistory, loadSettings } from "./storage";
|
||||
import { buildAccountSummary, buildRedactedSettingsPayload, buildStatsPayload, summarizeHistoryEntry } from "./support-data";
|
||||
import { getTraceConfig, getTraceConfigPath, getTraceLogPath } from "./trace-log";
|
||||
import { getCachedWindowsHostDiagnostics, getWindowsHostDiagnostics } from "./windows-host-diagnostics";
|
||||
import type { DownloadManager } from "./download-manager";
|
||||
|
||||
const AI_MANIFEST_FILE = "debug_ai_manifest.json";
|
||||
|
||||
async function safeReadJson(filePath: string): Promise<unknown> {
|
||||
try {
|
||||
return JSON.parse(await fsp.readFile(filePath, "utf8")) as unknown;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function addJson(zip: AdmZip, zipPath: string, value: unknown): void {
|
||||
zip.addFile(zipPath, Buffer.from(`${JSON.stringify(value, null, 2)}\n`, "utf8"));
|
||||
}
|
||||
|
||||
async function addFileIfExists(zip: AdmZip, sourcePath: string | null, zipPath: string): Promise<void> {
|
||||
if (!sourcePath) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const buffer = await fsp.readFile(sourcePath);
|
||||
zip.addFile(zipPath, buffer);
|
||||
} catch {
|
||||
}
|
||||
}
|
||||
|
||||
async function addDirectoryIfExists(zip: AdmZip, dirPath: string, zipRoot: string): Promise<void> {
|
||||
let entries;
|
||||
try {
|
||||
entries = await fsp.readdir(dirPath, { withFileTypes: true });
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
for (const entry of entries) {
|
||||
const fullPath = path.join(dirPath, entry.name);
|
||||
const zipPath = path.posix.join(zipRoot, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
await addDirectoryIfExists(zip, fullPath, zipPath);
|
||||
continue;
|
||||
}
|
||||
await addFileIfExists(zip, fullPath, zipPath);
|
||||
}
|
||||
}
|
||||
|
||||
async function addRecentDirectoryFiles(zip: AdmZip, dirPath: string, zipRoot: string, maxAgeMs: number): Promise<number> {
|
||||
let entries;
|
||||
try {
|
||||
entries = await fsp.readdir(dirPath, { withFileTypes: true });
|
||||
} catch {
|
||||
return 0;
|
||||
}
|
||||
const cutoff = Date.now() - maxAgeMs;
|
||||
let added = 0;
|
||||
for (const entry of entries) {
|
||||
if (!entry.isFile()) continue;
|
||||
const fullPath = path.join(dirPath, entry.name);
|
||||
try {
|
||||
if ((await fsp.stat(fullPath)).mtimeMs >= cutoff) {
|
||||
await addFileIfExists(zip, fullPath, path.posix.join(zipRoot, entry.name));
|
||||
added += 1;
|
||||
}
|
||||
} catch { }
|
||||
}
|
||||
return added;
|
||||
}
|
||||
|
||||
function formatTimestampForFileName(date: Date): string {
|
||||
const y = date.getFullYear();
|
||||
const mo = String(date.getMonth() + 1).padStart(2, "0");
|
||||
const d = String(date.getDate()).padStart(2, "0");
|
||||
const h = String(date.getHours()).padStart(2, "0");
|
||||
const mi = String(date.getMinutes()).padStart(2, "0");
|
||||
const s = String(date.getSeconds()).padStart(2, "0");
|
||||
return `${y}-${mo}-${d}_${h}-${mi}-${s}`;
|
||||
}
|
||||
|
||||
export function getSupportBundleDefaultFileName(): string {
|
||||
return `rd-support-bundle-${formatTimestampForFileName(new Date())}.zip`;
|
||||
}
|
||||
|
||||
type HostDiagnosticsMode = "full" | "cached" | "none";
|
||||
|
||||
interface BuildSupportBundleOptions {
|
||||
hostDiagnosticsMode?: HostDiagnosticsMode;
|
||||
}
|
||||
|
||||
function createDeferredHostDiagnostics(reason: string): unknown {
|
||||
return {
|
||||
collectedAt: new Date().toISOString(),
|
||||
supported: process.platform === "win32",
|
||||
platform: process.platform,
|
||||
crashControl: null,
|
||||
recentKernelPower: [],
|
||||
recentWerKernel: [],
|
||||
recentKernelDump: [],
|
||||
recentAppCrashes: [],
|
||||
recentMinidumps: [],
|
||||
assessmentHints: [
|
||||
reason
|
||||
],
|
||||
errors: []
|
||||
};
|
||||
}
|
||||
|
||||
function resolveHostDiagnostics(mode: HostDiagnosticsMode): unknown {
|
||||
if (mode === "none") {
|
||||
return createDeferredHostDiagnostics("Host-Diagnose wurde fuer diesen Bundle-Export deaktiviert.");
|
||||
}
|
||||
if (mode === "cached") {
|
||||
const cached = getCachedWindowsHostDiagnostics();
|
||||
if (cached) {
|
||||
return cached;
|
||||
}
|
||||
return createDeferredHostDiagnostics("Host-Diagnose wurde uebersprungen, um den Export nicht zu blockieren. Fuer eine Voll-Diagnose /host/diagnostics nutzen.");
|
||||
}
|
||||
return getWindowsHostDiagnostics();
|
||||
}
|
||||
|
||||
export async function buildSupportBundle(manager: DownloadManager, baseDir: string, options: BuildSupportBundleOptions = {}): Promise<Buffer> {
|
||||
const zip = new AdmZip();
|
||||
const hostDiagnosticsMode = options.hostDiagnosticsMode || "full";
|
||||
const storagePaths = createStoragePaths(baseDir);
|
||||
const settings = loadSettings(storagePaths);
|
||||
const history = loadHistory(storagePaths);
|
||||
const snapshot = manager.getSnapshot();
|
||||
const packageIds = Object.keys(snapshot.session.packages);
|
||||
const itemIds = Object.keys(snapshot.session.items);
|
||||
const debugSetup = getDebugSetupCheck(baseDir);
|
||||
|
||||
addJson(zip, "overview/meta.json", {
|
||||
appVersion: APP_VERSION,
|
||||
generatedAt: new Date().toISOString(),
|
||||
runtimeBaseDir: baseDir,
|
||||
packageCount: packageIds.length,
|
||||
itemCount: itemIds.length
|
||||
});
|
||||
addJson(zip, "overview/status.json", snapshot.session);
|
||||
addJson(zip, "overview/settings.json", buildRedactedSettingsPayload(settings));
|
||||
addJson(zip, "overview/accounts.json", buildAccountSummary(settings));
|
||||
addJson(zip, "overview/stats.json", {
|
||||
...buildStatsPayload(snapshot),
|
||||
allTime: {
|
||||
totalDownloadedAllTime: settings.totalDownloadedAllTime,
|
||||
totalCompletedFilesAllTime: settings.totalCompletedFilesAllTime,
|
||||
totalRuntimeAllTimeMs: settings.totalRuntimeAllTimeMs
|
||||
}
|
||||
});
|
||||
addJson(zip, "overview/debug-setup.json", debugSetup);
|
||||
addJson(zip, "overview/self-check.json", debugSetup);
|
||||
addJson(zip, "overview/history.json", {
|
||||
total: history.length,
|
||||
entries: history.map((entry) => summarizeHistoryEntry(entry))
|
||||
});
|
||||
addJson(zip, "overview/packages.json", {
|
||||
count: packageIds.length,
|
||||
packages: packageIds.map((packageId) => snapshot.session.packages[packageId]).filter(Boolean)
|
||||
});
|
||||
addJson(zip, "overview/items.json", {
|
||||
count: itemIds.length,
|
||||
items: itemIds.map((itemId) => snapshot.session.items[itemId]).filter(Boolean)
|
||||
});
|
||||
addJson(zip, "overview/host-diagnostics.json", resolveHostDiagnostics(hostDiagnosticsMode));
|
||||
addJson(zip, "overview/trace-config.json", getTraceConfig());
|
||||
const recentErrors = getRecentErrors();
|
||||
addJson(zip, "overview/recent-errors.json", { count: recentErrors.length, entries: recentErrors });
|
||||
|
||||
await addFileIfExists(zip, path.join(baseDir, 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_port.txt"), "runtime/debug_port.txt");
|
||||
await addFileIfExists(zip, getTraceConfigPath(), "runtime/trace_config.json");
|
||||
|
||||
await addFileIfExists(zip, getLogFilePath(), "logs/rd_downloader.log");
|
||||
await addFileIfExists(zip, `${getLogFilePath()}.old`, "logs/rd_downloader.log.old");
|
||||
await addFileIfExists(zip, getAuditLogPath(), "logs/audit.log");
|
||||
await addFileIfExists(zip, getAuditLogPath() ? `${getAuditLogPath()}.old` : null, "logs/audit.log.old");
|
||||
await addFileIfExists(zip, getRenameLogPath(), "logs/rename.log");
|
||||
await addFileIfExists(zip, getRenameLogPath() ? `${getRenameLogPath()}.old` : null, "logs/rename.log.old");
|
||||
await addFileIfExists(zip, getDesktopRenameLogPath(), "logs/rename-session-desktop.txt");
|
||||
await addFileIfExists(zip, getSessionLogPath(), "logs/session.log");
|
||||
await addFileIfExists(zip, getTraceLogPath(), "logs/trace.log");
|
||||
await addFileIfExists(zip, getTraceLogPath() ? `${getTraceLogPath()}.old` : null, "logs/trace.log.old");
|
||||
await addFileIfExists(zip, getAccountRotationLogPath(), "logs/account-rotation.log");
|
||||
await addFileIfExists(zip, getAccountRotationLogPath() ? `${getAccountRotationLogPath()}.old` : null, "logs/account-rotation.log.old");
|
||||
await addFileIfExists(zip, getConversionLogPath(), "logs/conversion.log");
|
||||
await addFileIfExists(zip, getConversionLogPath() ? `${getConversionLogPath()}.old` : null, "logs/conversion.log.old");
|
||||
|
||||
const SUPPORT_BUNDLE_LOG_WINDOW_MS = 8 * 60 * 60 * 1000;
|
||||
await addDirectoryIfExists(zip, path.join(baseDir, "session-logs"), "logs/session-logs");
|
||||
await addRecentDirectoryFiles(zip, path.join(baseDir, "package-logs"), "logs/package-logs", SUPPORT_BUNDLE_LOG_WINDOW_MS);
|
||||
await addRecentDirectoryFiles(zip, path.join(baseDir, "item-logs"), "logs/item-logs", SUPPORT_BUNDLE_LOG_WINDOW_MS);
|
||||
|
||||
for (const packageId of packageIds) {
|
||||
await addFileIfExists(zip, manager.getPackageLogPath(packageId) || getPackageLogPath(packageId), `logs/live/package-${packageId}.txt`);
|
||||
}
|
||||
for (const itemId of itemIds) {
|
||||
await addFileIfExists(zip, manager.getItemLogPath(itemId), `logs/live/item-${itemId}.txt`);
|
||||
}
|
||||
|
||||
const aiManifest = await safeReadJson(path.join(baseDir, AI_MANIFEST_FILE));
|
||||
if (aiManifest) {
|
||||
addJson(zip, "overview/ai-manifest.json", aiManifest);
|
||||
}
|
||||
|
||||
return zip.toBuffer();
|
||||
}
|
||||
|
||||
@ -1,188 +1,188 @@
|
||||
import { getDebridLinkApiKeyIds } from "../shared/debrid-link-keys";
|
||||
import { isNotifyUrlValid } from "./notify";
|
||||
import type { AppSettings, HistoryEntry, UiSnapshot } from "../shared/types";
|
||||
|
||||
function hasText(value: unknown): boolean {
|
||||
return String(value || "").trim().length > 0;
|
||||
}
|
||||
|
||||
export function buildAccountSummary(settings: AppSettings): Record<string, unknown> {
|
||||
const debridLinkKeyIds = getDebridLinkApiKeyIds(settings.debridLinkApiKeys);
|
||||
const disabledDebridLinkIds = new Set(settings.debridLinkDisabledKeyIds || []);
|
||||
|
||||
return {
|
||||
realDebrid: {
|
||||
configured: hasText(settings.token) || settings.realDebridUseWebLogin,
|
||||
tokenConfigured: hasText(settings.token),
|
||||
webLoginEnabled: settings.realDebridUseWebLogin,
|
||||
rememberToken: settings.rememberToken
|
||||
},
|
||||
megaDebrid: {
|
||||
configured: (hasText(settings.megaLogin) && hasText(settings.megaPassword))
|
||||
|| settings.megaDebridApiEnabled
|
||||
|| settings.megaDebridWebEnabled,
|
||||
loginConfigured: hasText(settings.megaLogin) && hasText(settings.megaPassword),
|
||||
apiEnabled: settings.megaDebridApiEnabled,
|
||||
webEnabled: settings.megaDebridWebEnabled,
|
||||
preferApi: settings.megaDebridPreferApi
|
||||
},
|
||||
bestDebrid: {
|
||||
configured: hasText(settings.bestToken) || settings.bestDebridUseWebLogin,
|
||||
tokenConfigured: hasText(settings.bestToken),
|
||||
webLoginEnabled: settings.bestDebridUseWebLogin
|
||||
},
|
||||
allDebrid: {
|
||||
configured: hasText(settings.allDebridToken) || settings.allDebridUseWebLogin,
|
||||
tokenConfigured: hasText(settings.allDebridToken),
|
||||
webLoginEnabled: settings.allDebridUseWebLogin
|
||||
},
|
||||
ddownload: {
|
||||
configured: hasText(settings.ddownloadLogin) && hasText(settings.ddownloadPassword)
|
||||
},
|
||||
oneFichier: {
|
||||
configured: hasText(settings.oneFichierApiKey)
|
||||
},
|
||||
debridLink: {
|
||||
configured: debridLinkKeyIds.length > 0,
|
||||
keyCount: debridLinkKeyIds.length,
|
||||
enabledKeyCount: debridLinkKeyIds.filter((id) => !disabledDebridLinkIds.has(id)).length,
|
||||
disabledKeyCount: debridLinkKeyIds.filter((id) => disabledDebridLinkIds.has(id)).length
|
||||
},
|
||||
linkSnappy: {
|
||||
configured: hasText(settings.linkSnappyLogin) && hasText(settings.linkSnappyPassword)
|
||||
},
|
||||
disabledProviders: [...(settings.disabledProviders || [])]
|
||||
};
|
||||
}
|
||||
|
||||
export function diffAccountSummary(previous: AppSettings, next: AppSettings): Record<string, unknown> {
|
||||
const before = buildAccountSummary(previous);
|
||||
const after = buildAccountSummary(next);
|
||||
const changes: Record<string, unknown> = {};
|
||||
for (const key of Object.keys(after)) {
|
||||
const beforeJson = JSON.stringify(before[key]);
|
||||
const afterJson = JSON.stringify(after[key]);
|
||||
if (beforeJson !== afterJson) {
|
||||
changes[key] = after[key];
|
||||
}
|
||||
}
|
||||
return changes;
|
||||
}
|
||||
|
||||
export function buildRedactedSettingsPayload(settings: AppSettings): Record<string, unknown> {
|
||||
return {
|
||||
paths: {
|
||||
outputDir: settings.outputDir,
|
||||
extractDir: settings.extractDir,
|
||||
mkvLibraryDir: settings.mkvLibraryDir
|
||||
},
|
||||
providers: {
|
||||
providerOrder: settings.providerOrder,
|
||||
providerPrimary: settings.providerPrimary,
|
||||
providerSecondary: settings.providerSecondary,
|
||||
providerTertiary: settings.providerTertiary,
|
||||
autoProviderFallback: settings.autoProviderFallback,
|
||||
disabledProviders: settings.disabledProviders,
|
||||
hosterRouting: settings.hosterRouting
|
||||
},
|
||||
extraction: {
|
||||
autoExtract: settings.autoExtract,
|
||||
autoExtractWhenStopped: settings.autoExtractWhenStopped,
|
||||
hybridExtract: settings.hybridExtract,
|
||||
createExtractSubfolder: settings.createExtractSubfolder,
|
||||
cleanupMode: settings.cleanupMode,
|
||||
extractConflictMode: settings.extractConflictMode,
|
||||
removeLinkFilesAfterExtract: settings.removeLinkFilesAfterExtract,
|
||||
removeSamplesAfterExtract: settings.removeSamplesAfterExtract,
|
||||
enableIntegrityCheck: settings.enableIntegrityCheck,
|
||||
archivePasswordCount: String(settings.archivePasswordList || "")
|
||||
.split(/\r?\n/)
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean)
|
||||
.length,
|
||||
extractCpuPriority: settings.extractCpuPriority,
|
||||
maxParallelExtract: settings.maxParallelExtract
|
||||
},
|
||||
downloads: {
|
||||
maxParallel: settings.maxParallel,
|
||||
retryLimit: settings.retryLimit,
|
||||
autoResumeOnStart: settings.autoResumeOnStart,
|
||||
autoReconnect: settings.autoReconnect,
|
||||
reconnectWaitSeconds: settings.reconnectWaitSeconds,
|
||||
autoSkipExtracted: settings.autoSkipExtracted,
|
||||
completedCleanupPolicy: settings.completedCleanupPolicy
|
||||
},
|
||||
ui: {
|
||||
packageName: settings.packageName,
|
||||
theme: settings.theme,
|
||||
collapseNewPackages: settings.collapseNewPackages,
|
||||
hideExtractedItems: settings.hideExtractedItems,
|
||||
confirmDeleteSelection: settings.confirmDeleteSelection,
|
||||
clipboardWatch: settings.clipboardWatch,
|
||||
minimizeToTray: settings.minimizeToTray,
|
||||
columnOrder: settings.columnOrder
|
||||
},
|
||||
bandwidth: {
|
||||
speedLimitEnabled: settings.speedLimitEnabled,
|
||||
speedLimitKbps: settings.speedLimitKbps,
|
||||
speedLimitMode: settings.speedLimitMode,
|
||||
bandwidthSchedules: settings.bandwidthSchedules
|
||||
},
|
||||
updates: {
|
||||
updateRepo: settings.updateRepo,
|
||||
autoUpdateCheck: settings.autoUpdateCheck
|
||||
},
|
||||
notifications: {
|
||||
notifyUrlConfigured: Boolean(String(settings.notifyUrl || "").trim()),
|
||||
notifyUrlLooksValid: isNotifyUrlValid(settings.notifyUrl),
|
||||
notifyMentionConfigured: Boolean(String(settings.notifyMention || "").trim()),
|
||||
notifyOnPackageCompleted: settings.notifyOnPackageCompleted,
|
||||
notifyOnPackageFailed: settings.notifyOnPackageFailed,
|
||||
notifyOnRunFinished: settings.notifyOnRunFinished
|
||||
},
|
||||
statistics: {
|
||||
totalDownloadedAllTime: settings.totalDownloadedAllTime,
|
||||
totalCompletedFilesAllTime: settings.totalCompletedFilesAllTime,
|
||||
totalRuntimeAllTimeMs: settings.totalRuntimeAllTimeMs,
|
||||
providerDailyLimitBytes: settings.providerDailyLimitBytes,
|
||||
providerDailyUsageBytes: settings.providerDailyUsageBytes,
|
||||
providerTotalUsageBytes: settings.providerTotalUsageBytes,
|
||||
debridLinkApiKeyDailyLimitBytes: settings.debridLinkApiKeyDailyLimitBytes,
|
||||
debridLinkApiKeyDailyUsageBytes: settings.debridLinkApiKeyDailyUsageBytes,
|
||||
debridLinkApiKeyTotalUsageBytes: settings.debridLinkApiKeyTotalUsageBytes,
|
||||
providerDailyUsageDay: settings.providerDailyUsageDay
|
||||
},
|
||||
accounts: buildAccountSummary(settings)
|
||||
};
|
||||
}
|
||||
|
||||
export function buildStatsPayload(snapshot: UiSnapshot): Record<string, unknown> {
|
||||
return {
|
||||
session: snapshot.stats,
|
||||
totals: {
|
||||
totalPackages: Object.keys(snapshot.session.packages).length,
|
||||
totalItems: Object.keys(snapshot.session.items).length,
|
||||
speedText: snapshot.speedText,
|
||||
etaText: snapshot.etaText,
|
||||
canStart: snapshot.canStart,
|
||||
canStop: snapshot.canStop,
|
||||
canPause: snapshot.canPause
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function summarizeHistoryEntry(entry: HistoryEntry): Record<string, unknown> {
|
||||
return {
|
||||
id: entry.id,
|
||||
name: entry.name,
|
||||
status: entry.status,
|
||||
provider: entry.provider,
|
||||
fileCount: entry.fileCount,
|
||||
totalBytes: entry.totalBytes,
|
||||
downloadedBytes: entry.downloadedBytes,
|
||||
durationSeconds: entry.durationSeconds,
|
||||
completedAt: entry.completedAt,
|
||||
outputDir: entry.outputDir,
|
||||
urlCount: Array.isArray(entry.urls) ? entry.urls.length : 0
|
||||
};
|
||||
}
|
||||
import { getDebridLinkApiKeyIds } from "../shared/debrid-link-keys";
|
||||
import { isNotifyUrlValid } from "./notify";
|
||||
import type { AppSettings, HistoryEntry, UiSnapshot } from "../shared/types";
|
||||
|
||||
function hasText(value: unknown): boolean {
|
||||
return String(value || "").trim().length > 0;
|
||||
}
|
||||
|
||||
export function buildAccountSummary(settings: AppSettings): Record<string, unknown> {
|
||||
const debridLinkKeyIds = getDebridLinkApiKeyIds(settings.debridLinkApiKeys);
|
||||
const disabledDebridLinkIds = new Set(settings.debridLinkDisabledKeyIds || []);
|
||||
|
||||
return {
|
||||
realDebrid: {
|
||||
configured: hasText(settings.token) || settings.realDebridUseWebLogin,
|
||||
tokenConfigured: hasText(settings.token),
|
||||
webLoginEnabled: settings.realDebridUseWebLogin,
|
||||
rememberToken: settings.rememberToken
|
||||
},
|
||||
megaDebrid: {
|
||||
configured: (hasText(settings.megaLogin) && hasText(settings.megaPassword))
|
||||
|| settings.megaDebridApiEnabled
|
||||
|| settings.megaDebridWebEnabled,
|
||||
loginConfigured: hasText(settings.megaLogin) && hasText(settings.megaPassword),
|
||||
apiEnabled: settings.megaDebridApiEnabled,
|
||||
webEnabled: settings.megaDebridWebEnabled,
|
||||
preferApi: settings.megaDebridPreferApi
|
||||
},
|
||||
bestDebrid: {
|
||||
configured: hasText(settings.bestToken) || settings.bestDebridUseWebLogin,
|
||||
tokenConfigured: hasText(settings.bestToken),
|
||||
webLoginEnabled: settings.bestDebridUseWebLogin
|
||||
},
|
||||
allDebrid: {
|
||||
configured: hasText(settings.allDebridToken) || settings.allDebridUseWebLogin,
|
||||
tokenConfigured: hasText(settings.allDebridToken),
|
||||
webLoginEnabled: settings.allDebridUseWebLogin
|
||||
},
|
||||
ddownload: {
|
||||
configured: hasText(settings.ddownloadLogin) && hasText(settings.ddownloadPassword)
|
||||
},
|
||||
oneFichier: {
|
||||
configured: hasText(settings.oneFichierApiKey)
|
||||
},
|
||||
debridLink: {
|
||||
configured: debridLinkKeyIds.length > 0,
|
||||
keyCount: debridLinkKeyIds.length,
|
||||
enabledKeyCount: debridLinkKeyIds.filter((id) => !disabledDebridLinkIds.has(id)).length,
|
||||
disabledKeyCount: debridLinkKeyIds.filter((id) => disabledDebridLinkIds.has(id)).length
|
||||
},
|
||||
linkSnappy: {
|
||||
configured: hasText(settings.linkSnappyLogin) && hasText(settings.linkSnappyPassword)
|
||||
},
|
||||
disabledProviders: [...(settings.disabledProviders || [])]
|
||||
};
|
||||
}
|
||||
|
||||
export function diffAccountSummary(previous: AppSettings, next: AppSettings): Record<string, unknown> {
|
||||
const before = buildAccountSummary(previous);
|
||||
const after = buildAccountSummary(next);
|
||||
const changes: Record<string, unknown> = {};
|
||||
for (const key of Object.keys(after)) {
|
||||
const beforeJson = JSON.stringify(before[key]);
|
||||
const afterJson = JSON.stringify(after[key]);
|
||||
if (beforeJson !== afterJson) {
|
||||
changes[key] = after[key];
|
||||
}
|
||||
}
|
||||
return changes;
|
||||
}
|
||||
|
||||
export function buildRedactedSettingsPayload(settings: AppSettings): Record<string, unknown> {
|
||||
return {
|
||||
paths: {
|
||||
outputDir: settings.outputDir,
|
||||
extractDir: settings.extractDir,
|
||||
mkvLibraryDir: settings.mkvLibraryDir
|
||||
},
|
||||
providers: {
|
||||
providerOrder: settings.providerOrder,
|
||||
providerPrimary: settings.providerPrimary,
|
||||
providerSecondary: settings.providerSecondary,
|
||||
providerTertiary: settings.providerTertiary,
|
||||
autoProviderFallback: settings.autoProviderFallback,
|
||||
disabledProviders: settings.disabledProviders,
|
||||
hosterRouting: settings.hosterRouting
|
||||
},
|
||||
extraction: {
|
||||
autoExtract: settings.autoExtract,
|
||||
autoExtractWhenStopped: settings.autoExtractWhenStopped,
|
||||
hybridExtract: settings.hybridExtract,
|
||||
createExtractSubfolder: settings.createExtractSubfolder,
|
||||
cleanupMode: settings.cleanupMode,
|
||||
extractConflictMode: settings.extractConflictMode,
|
||||
removeLinkFilesAfterExtract: settings.removeLinkFilesAfterExtract,
|
||||
removeSamplesAfterExtract: settings.removeSamplesAfterExtract,
|
||||
enableIntegrityCheck: settings.enableIntegrityCheck,
|
||||
archivePasswordCount: String(settings.archivePasswordList || "")
|
||||
.split(/\r?\n/)
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean)
|
||||
.length,
|
||||
extractCpuPriority: settings.extractCpuPriority,
|
||||
maxParallelExtract: settings.maxParallelExtract
|
||||
},
|
||||
downloads: {
|
||||
maxParallel: settings.maxParallel,
|
||||
retryLimit: settings.retryLimit,
|
||||
autoResumeOnStart: settings.autoResumeOnStart,
|
||||
autoReconnect: settings.autoReconnect,
|
||||
reconnectWaitSeconds: settings.reconnectWaitSeconds,
|
||||
autoSkipExtracted: settings.autoSkipExtracted,
|
||||
completedCleanupPolicy: settings.completedCleanupPolicy
|
||||
},
|
||||
ui: {
|
||||
packageName: settings.packageName,
|
||||
theme: settings.theme,
|
||||
collapseNewPackages: settings.collapseNewPackages,
|
||||
hideExtractedItems: settings.hideExtractedItems,
|
||||
confirmDeleteSelection: settings.confirmDeleteSelection,
|
||||
clipboardWatch: settings.clipboardWatch,
|
||||
minimizeToTray: settings.minimizeToTray,
|
||||
columnOrder: settings.columnOrder
|
||||
},
|
||||
bandwidth: {
|
||||
speedLimitEnabled: settings.speedLimitEnabled,
|
||||
speedLimitKbps: settings.speedLimitKbps,
|
||||
speedLimitMode: settings.speedLimitMode,
|
||||
bandwidthSchedules: settings.bandwidthSchedules
|
||||
},
|
||||
updates: {
|
||||
updateRepo: settings.updateRepo,
|
||||
autoUpdateCheck: settings.autoUpdateCheck
|
||||
},
|
||||
notifications: {
|
||||
notifyUrlConfigured: Boolean(String(settings.notifyUrl || "").trim()),
|
||||
notifyUrlLooksValid: isNotifyUrlValid(settings.notifyUrl),
|
||||
notifyMentionConfigured: Boolean(String(settings.notifyMention || "").trim()),
|
||||
notifyOnPackageCompleted: settings.notifyOnPackageCompleted,
|
||||
notifyOnPackageFailed: settings.notifyOnPackageFailed,
|
||||
notifyOnRunFinished: settings.notifyOnRunFinished
|
||||
},
|
||||
statistics: {
|
||||
totalDownloadedAllTime: settings.totalDownloadedAllTime,
|
||||
totalCompletedFilesAllTime: settings.totalCompletedFilesAllTime,
|
||||
totalRuntimeAllTimeMs: settings.totalRuntimeAllTimeMs,
|
||||
providerDailyLimitBytes: settings.providerDailyLimitBytes,
|
||||
providerDailyUsageBytes: settings.providerDailyUsageBytes,
|
||||
providerTotalUsageBytes: settings.providerTotalUsageBytes,
|
||||
debridLinkApiKeyDailyLimitBytes: settings.debridLinkApiKeyDailyLimitBytes,
|
||||
debridLinkApiKeyDailyUsageBytes: settings.debridLinkApiKeyDailyUsageBytes,
|
||||
debridLinkApiKeyTotalUsageBytes: settings.debridLinkApiKeyTotalUsageBytes,
|
||||
providerDailyUsageDay: settings.providerDailyUsageDay
|
||||
},
|
||||
accounts: buildAccountSummary(settings)
|
||||
};
|
||||
}
|
||||
|
||||
export function buildStatsPayload(snapshot: UiSnapshot): Record<string, unknown> {
|
||||
return {
|
||||
session: snapshot.stats,
|
||||
totals: {
|
||||
totalPackages: Object.keys(snapshot.session.packages).length,
|
||||
totalItems: Object.keys(snapshot.session.items).length,
|
||||
speedText: snapshot.speedText,
|
||||
etaText: snapshot.etaText,
|
||||
canStart: snapshot.canStart,
|
||||
canStop: snapshot.canStop,
|
||||
canPause: snapshot.canPause
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function summarizeHistoryEntry(entry: HistoryEntry): Record<string, unknown> {
|
||||
return {
|
||||
id: entry.id,
|
||||
name: entry.name,
|
||||
status: entry.status,
|
||||
provider: entry.provider,
|
||||
fileCount: entry.fileCount,
|
||||
totalBytes: entry.totalBytes,
|
||||
downloadedBytes: entry.downloadedBytes,
|
||||
durationSeconds: entry.durationSeconds,
|
||||
completedAt: entry.completedAt,
|
||||
outputDir: entry.outputDir,
|
||||
urlCount: Array.isArray(entry.urls) ? entry.urls.length : 0
|
||||
};
|
||||
}
|
||||
|
||||
@ -1,312 +1,312 @@
|
||||
import fs from "node:fs";
|
||||
import { logTimestamp } from "./log-timestamp";
|
||||
import path from "node:path";
|
||||
import { addLogListener, removeLogListener } from "./logger";
|
||||
import type { SupportTraceConfig } from "../shared/types";
|
||||
|
||||
type TraceLevel = "INFO" | "WARN" | "ERROR";
|
||||
|
||||
const TRACE_LOG_FLUSH_INTERVAL_MS = 200;
|
||||
const TRACE_CONFIG_FILE = "trace_config.json";
|
||||
const TRACE_LOG_MAX_FILE_BYTES = Number(process.env.RD_TRACE_LOG_MAX_BYTES || 10 * 1024 * 1024);
|
||||
const TRACE_LOG_RETENTION_DAYS = Number(process.env.RD_TRACE_LOG_RETENTION_DAYS || 30);
|
||||
const TRACE_DEFAULT_AUTO_DISABLE_MS = Number(process.env.RD_TRACE_AUTO_DISABLE_MS || 2 * 60 * 60 * 1000);
|
||||
|
||||
const DEFAULT_TRACE_CONFIG: SupportTraceConfig = {
|
||||
enabled: false,
|
||||
includeMainLog: true,
|
||||
includeAudit: true,
|
||||
logDebugRequests: true,
|
||||
autoDisableAt: null,
|
||||
updatedAt: new Date(0).toISOString()
|
||||
};
|
||||
|
||||
let traceLogPath: string | null = null;
|
||||
let traceConfigPath: string | null = null;
|
||||
let traceConfig: SupportTraceConfig = { ...DEFAULT_TRACE_CONFIG };
|
||||
let pendingLines: string[] = [];
|
||||
let flushTimer: NodeJS.Timeout | null = null;
|
||||
let autoDisableTimer: NodeJS.Timeout | null = null;
|
||||
|
||||
function sanitizeFieldValue(value: unknown): string {
|
||||
if (value === undefined || value === null) {
|
||||
return "";
|
||||
}
|
||||
if (typeof value === "string") {
|
||||
return value.replace(/\r?\n/g, "\\n");
|
||||
}
|
||||
if (typeof value === "number" || typeof value === "boolean") {
|
||||
return String(value);
|
||||
}
|
||||
try {
|
||||
return JSON.stringify(value).replace(/\r?\n/g, "\\n");
|
||||
} catch {
|
||||
return String(value);
|
||||
}
|
||||
}
|
||||
|
||||
function formatFields(fields?: Record<string, unknown>): string {
|
||||
if (!fields) {
|
||||
return "";
|
||||
}
|
||||
const parts = Object.entries(fields)
|
||||
.filter(([, value]) => value !== undefined && value !== null && sanitizeFieldValue(value) !== "")
|
||||
.map(([key, value]) => `${key}=${sanitizeFieldValue(value)}`);
|
||||
return parts.length > 0 ? ` | ${parts.join(" | ")}` : "";
|
||||
}
|
||||
|
||||
function flushPending(): void {
|
||||
if (!traceLogPath || pendingLines.length === 0) {
|
||||
return;
|
||||
}
|
||||
const chunk = pendingLines.join("");
|
||||
pendingLines = [];
|
||||
try {
|
||||
fs.appendFileSync(traceLogPath, chunk, "utf8");
|
||||
} catch {
|
||||
}
|
||||
}
|
||||
|
||||
function rotateIfNeeded(filePath: string): void {
|
||||
try {
|
||||
const stat = fs.statSync(filePath);
|
||||
if (stat.size < TRACE_LOG_MAX_FILE_BYTES) {
|
||||
return;
|
||||
}
|
||||
const backup = `${filePath}.old`;
|
||||
try {
|
||||
fs.rmSync(backup, { force: true });
|
||||
} catch {
|
||||
}
|
||||
fs.renameSync(filePath, backup);
|
||||
} catch {
|
||||
}
|
||||
}
|
||||
|
||||
function cleanupOldBackup(filePath: string): void {
|
||||
const backup = `${filePath}.old`;
|
||||
try {
|
||||
const stat = fs.statSync(backup);
|
||||
const cutoff = Date.now() - TRACE_LOG_RETENTION_DAYS * 24 * 60 * 60 * 1000;
|
||||
if (stat.mtimeMs < cutoff) {
|
||||
fs.rmSync(backup, { force: true });
|
||||
}
|
||||
} catch {
|
||||
}
|
||||
}
|
||||
|
||||
function scheduleFlush(): void {
|
||||
if (flushTimer) {
|
||||
return;
|
||||
}
|
||||
flushTimer = setTimeout(() => {
|
||||
flushTimer = null;
|
||||
flushPending();
|
||||
}, TRACE_LOG_FLUSH_INTERVAL_MS);
|
||||
}
|
||||
|
||||
function appendTraceLine(line: string): void {
|
||||
if (!traceLogPath) {
|
||||
return;
|
||||
}
|
||||
rotateIfNeeded(traceLogPath);
|
||||
if (!fs.existsSync(traceLogPath)) {
|
||||
try {
|
||||
fs.writeFileSync(traceLogPath, "", "utf8");
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
}
|
||||
pendingLines.push(line);
|
||||
scheduleFlush();
|
||||
}
|
||||
|
||||
function normalizeTraceConfig(raw: unknown): SupportTraceConfig {
|
||||
if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
|
||||
return { ...DEFAULT_TRACE_CONFIG };
|
||||
}
|
||||
const value = raw as Partial<SupportTraceConfig>;
|
||||
return {
|
||||
enabled: Boolean(value.enabled),
|
||||
includeMainLog: value.includeMainLog === undefined ? DEFAULT_TRACE_CONFIG.includeMainLog : Boolean(value.includeMainLog),
|
||||
includeAudit: value.includeAudit === undefined ? DEFAULT_TRACE_CONFIG.includeAudit : Boolean(value.includeAudit),
|
||||
logDebugRequests: value.logDebugRequests === undefined ? DEFAULT_TRACE_CONFIG.logDebugRequests : Boolean(value.logDebugRequests),
|
||||
autoDisableAt: typeof value.autoDisableAt === "string" && value.autoDisableAt.trim()
|
||||
? value.autoDisableAt
|
||||
: null,
|
||||
updatedAt: typeof value.updatedAt === "string" && value.updatedAt.trim()
|
||||
? value.updatedAt
|
||||
: DEFAULT_TRACE_CONFIG.updatedAt
|
||||
};
|
||||
}
|
||||
|
||||
function loadTraceConfig(): SupportTraceConfig {
|
||||
if (!traceConfigPath) {
|
||||
return { ...DEFAULT_TRACE_CONFIG };
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(fs.readFileSync(traceConfigPath, "utf8")) as unknown;
|
||||
return normalizeTraceConfig(parsed);
|
||||
} catch {
|
||||
return { ...DEFAULT_TRACE_CONFIG };
|
||||
}
|
||||
}
|
||||
|
||||
function persistTraceConfig(): void {
|
||||
if (!traceConfigPath) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
fs.writeFileSync(traceConfigPath, `${JSON.stringify(traceConfig, null, 2)}\n`, "utf8");
|
||||
} catch {
|
||||
}
|
||||
}
|
||||
|
||||
const mainLogListener = (line: string): void => {
|
||||
if (!traceConfig.enabled || !traceConfig.includeMainLog) {
|
||||
return;
|
||||
}
|
||||
appendTraceLine(line);
|
||||
};
|
||||
|
||||
function clearAutoDisableTimer(): void {
|
||||
if (autoDisableTimer) {
|
||||
clearTimeout(autoDisableTimer);
|
||||
autoDisableTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
function disableTraceDueToExpiry(): void {
|
||||
clearAutoDisableTimer();
|
||||
if (!traceConfig.enabled) {
|
||||
return;
|
||||
}
|
||||
traceConfig = normalizeTraceConfig({
|
||||
...traceConfig,
|
||||
enabled: false,
|
||||
autoDisableAt: null,
|
||||
updatedAt: logTimestamp()
|
||||
});
|
||||
persistTraceConfig();
|
||||
appendTraceLine(`${logTimestamp()} [INFO] [trace] Support-Trace automatisch deaktiviert | reason=expired\n`);
|
||||
}
|
||||
|
||||
function scheduleAutoDisable(): void {
|
||||
clearAutoDisableTimer();
|
||||
if (!traceConfig.enabled || !traceConfig.autoDisableAt) {
|
||||
return;
|
||||
}
|
||||
const until = Date.parse(traceConfig.autoDisableAt);
|
||||
if (!Number.isFinite(until)) {
|
||||
return;
|
||||
}
|
||||
const remainingMs = until - Date.now();
|
||||
if (remainingMs <= 0) {
|
||||
disableTraceDueToExpiry();
|
||||
return;
|
||||
}
|
||||
autoDisableTimer = setTimeout(() => {
|
||||
autoDisableTimer = null;
|
||||
disableTraceDueToExpiry();
|
||||
}, Math.min(remainingMs, 2_147_483_647));
|
||||
}
|
||||
|
||||
export function initTraceLog(baseDir: string): void {
|
||||
traceLogPath = path.join(baseDir, "trace.log");
|
||||
traceConfigPath = path.join(baseDir, TRACE_CONFIG_FILE);
|
||||
try {
|
||||
fs.mkdirSync(baseDir, { recursive: true });
|
||||
cleanupOldBackup(traceLogPath);
|
||||
if (!fs.existsSync(traceLogPath)) {
|
||||
fs.writeFileSync(traceLogPath, "", "utf8");
|
||||
}
|
||||
rotateIfNeeded(traceLogPath);
|
||||
if (!fs.existsSync(traceLogPath)) {
|
||||
fs.writeFileSync(traceLogPath, "", "utf8");
|
||||
}
|
||||
traceConfig = loadTraceConfig();
|
||||
persistTraceConfig();
|
||||
fs.appendFileSync(traceLogPath, `=== Trace-Log Start: ${logTimestamp()} ===\n`, "utf8");
|
||||
} catch {
|
||||
traceLogPath = null;
|
||||
traceConfigPath = null;
|
||||
traceConfig = { ...DEFAULT_TRACE_CONFIG };
|
||||
return;
|
||||
}
|
||||
addLogListener(mainLogListener);
|
||||
scheduleAutoDisable();
|
||||
}
|
||||
|
||||
export function getTraceLogPath(): string | null {
|
||||
if (!traceLogPath) {
|
||||
return null;
|
||||
}
|
||||
return fs.existsSync(traceLogPath) ? traceLogPath : null;
|
||||
}
|
||||
|
||||
export function getTraceConfigPath(): string | null {
|
||||
if (!traceConfigPath) {
|
||||
return null;
|
||||
}
|
||||
return fs.existsSync(traceConfigPath) ? traceConfigPath : null;
|
||||
}
|
||||
|
||||
export function getTraceConfig(): SupportTraceConfig {
|
||||
return { ...traceConfig };
|
||||
}
|
||||
|
||||
export function updateTraceConfig(patch: Partial<SupportTraceConfig>): SupportTraceConfig {
|
||||
traceConfig = normalizeTraceConfig({
|
||||
...traceConfig,
|
||||
...patch,
|
||||
updatedAt: logTimestamp()
|
||||
});
|
||||
persistTraceConfig();
|
||||
scheduleAutoDisable();
|
||||
appendTraceLine(`${logTimestamp()} [INFO] [trace] Konfiguration aktualisiert${formatFields(traceConfig as unknown as Record<string, unknown>)}\n`);
|
||||
return getTraceConfig();
|
||||
}
|
||||
|
||||
export function setTraceEnabled(enabled: boolean, note = "", durationMs: number = TRACE_DEFAULT_AUTO_DISABLE_MS): SupportTraceConfig {
|
||||
const autoDisableAt = enabled && durationMs > 0
|
||||
? new Date(Date.now() + durationMs).toISOString()
|
||||
: null;
|
||||
const next = updateTraceConfig({ enabled, autoDisableAt });
|
||||
appendTraceLine(`${logTimestamp()} [INFO] [trace] Support-Trace ${enabled ? "aktiviert" : "deaktiviert"}${formatFields({ note, autoDisableAt })}\n`);
|
||||
return next;
|
||||
}
|
||||
|
||||
export function logTraceEvent(
|
||||
level: TraceLevel,
|
||||
category: string,
|
||||
message: string,
|
||||
fields?: Record<string, unknown>
|
||||
): void {
|
||||
if (!traceConfig.enabled) {
|
||||
return;
|
||||
}
|
||||
if (category === "audit" && !traceConfig.includeAudit) {
|
||||
return;
|
||||
}
|
||||
appendTraceLine(`${logTimestamp()} [${level}] [${category}] ${message}${formatFields(fields)}\n`);
|
||||
}
|
||||
|
||||
export function shutdownTraceLog(): void {
|
||||
removeLogListener(mainLogListener);
|
||||
clearAutoDisableTimer();
|
||||
if (!traceLogPath) {
|
||||
return;
|
||||
}
|
||||
if (flushTimer) {
|
||||
clearTimeout(flushTimer);
|
||||
flushTimer = null;
|
||||
}
|
||||
flushPending();
|
||||
try {
|
||||
fs.appendFileSync(traceLogPath, `=== Trace-Log Ende: ${logTimestamp()} ===\n`, "utf8");
|
||||
} catch {
|
||||
}
|
||||
traceLogPath = null;
|
||||
traceConfigPath = null;
|
||||
traceConfig = { ...DEFAULT_TRACE_CONFIG };
|
||||
}
|
||||
import fs from "node:fs";
|
||||
import { logTimestamp } from "./log-timestamp";
|
||||
import path from "node:path";
|
||||
import { addLogListener, removeLogListener } from "./logger";
|
||||
import type { SupportTraceConfig } from "../shared/types";
|
||||
|
||||
type TraceLevel = "INFO" | "WARN" | "ERROR";
|
||||
|
||||
const TRACE_LOG_FLUSH_INTERVAL_MS = 200;
|
||||
const TRACE_CONFIG_FILE = "trace_config.json";
|
||||
const TRACE_LOG_MAX_FILE_BYTES = Number(process.env.RD_TRACE_LOG_MAX_BYTES || 10 * 1024 * 1024);
|
||||
const TRACE_LOG_RETENTION_DAYS = Number(process.env.RD_TRACE_LOG_RETENTION_DAYS || 30);
|
||||
const TRACE_DEFAULT_AUTO_DISABLE_MS = Number(process.env.RD_TRACE_AUTO_DISABLE_MS || 2 * 60 * 60 * 1000);
|
||||
|
||||
const DEFAULT_TRACE_CONFIG: SupportTraceConfig = {
|
||||
enabled: false,
|
||||
includeMainLog: true,
|
||||
includeAudit: true,
|
||||
logDebugRequests: true,
|
||||
autoDisableAt: null,
|
||||
updatedAt: new Date(0).toISOString()
|
||||
};
|
||||
|
||||
let traceLogPath: string | null = null;
|
||||
let traceConfigPath: string | null = null;
|
||||
let traceConfig: SupportTraceConfig = { ...DEFAULT_TRACE_CONFIG };
|
||||
let pendingLines: string[] = [];
|
||||
let flushTimer: NodeJS.Timeout | null = null;
|
||||
let autoDisableTimer: NodeJS.Timeout | null = null;
|
||||
|
||||
function sanitizeFieldValue(value: unknown): string {
|
||||
if (value === undefined || value === null) {
|
||||
return "";
|
||||
}
|
||||
if (typeof value === "string") {
|
||||
return value.replace(/\r?\n/g, "\\n");
|
||||
}
|
||||
if (typeof value === "number" || typeof value === "boolean") {
|
||||
return String(value);
|
||||
}
|
||||
try {
|
||||
return JSON.stringify(value).replace(/\r?\n/g, "\\n");
|
||||
} catch {
|
||||
return String(value);
|
||||
}
|
||||
}
|
||||
|
||||
function formatFields(fields?: Record<string, unknown>): string {
|
||||
if (!fields) {
|
||||
return "";
|
||||
}
|
||||
const parts = Object.entries(fields)
|
||||
.filter(([, value]) => value !== undefined && value !== null && sanitizeFieldValue(value) !== "")
|
||||
.map(([key, value]) => `${key}=${sanitizeFieldValue(value)}`);
|
||||
return parts.length > 0 ? ` | ${parts.join(" | ")}` : "";
|
||||
}
|
||||
|
||||
function flushPending(): void {
|
||||
if (!traceLogPath || pendingLines.length === 0) {
|
||||
return;
|
||||
}
|
||||
const chunk = pendingLines.join("");
|
||||
pendingLines = [];
|
||||
try {
|
||||
fs.appendFileSync(traceLogPath, chunk, "utf8");
|
||||
} catch {
|
||||
}
|
||||
}
|
||||
|
||||
function rotateIfNeeded(filePath: string): void {
|
||||
try {
|
||||
const stat = fs.statSync(filePath);
|
||||
if (stat.size < TRACE_LOG_MAX_FILE_BYTES) {
|
||||
return;
|
||||
}
|
||||
const backup = `${filePath}.old`;
|
||||
try {
|
||||
fs.rmSync(backup, { force: true });
|
||||
} catch {
|
||||
}
|
||||
fs.renameSync(filePath, backup);
|
||||
} catch {
|
||||
}
|
||||
}
|
||||
|
||||
function cleanupOldBackup(filePath: string): void {
|
||||
const backup = `${filePath}.old`;
|
||||
try {
|
||||
const stat = fs.statSync(backup);
|
||||
const cutoff = Date.now() - TRACE_LOG_RETENTION_DAYS * 24 * 60 * 60 * 1000;
|
||||
if (stat.mtimeMs < cutoff) {
|
||||
fs.rmSync(backup, { force: true });
|
||||
}
|
||||
} catch {
|
||||
}
|
||||
}
|
||||
|
||||
function scheduleFlush(): void {
|
||||
if (flushTimer) {
|
||||
return;
|
||||
}
|
||||
flushTimer = setTimeout(() => {
|
||||
flushTimer = null;
|
||||
flushPending();
|
||||
}, TRACE_LOG_FLUSH_INTERVAL_MS);
|
||||
}
|
||||
|
||||
function appendTraceLine(line: string): void {
|
||||
if (!traceLogPath) {
|
||||
return;
|
||||
}
|
||||
rotateIfNeeded(traceLogPath);
|
||||
if (!fs.existsSync(traceLogPath)) {
|
||||
try {
|
||||
fs.writeFileSync(traceLogPath, "", "utf8");
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
}
|
||||
pendingLines.push(line);
|
||||
scheduleFlush();
|
||||
}
|
||||
|
||||
function normalizeTraceConfig(raw: unknown): SupportTraceConfig {
|
||||
if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
|
||||
return { ...DEFAULT_TRACE_CONFIG };
|
||||
}
|
||||
const value = raw as Partial<SupportTraceConfig>;
|
||||
return {
|
||||
enabled: Boolean(value.enabled),
|
||||
includeMainLog: value.includeMainLog === undefined ? DEFAULT_TRACE_CONFIG.includeMainLog : Boolean(value.includeMainLog),
|
||||
includeAudit: value.includeAudit === undefined ? DEFAULT_TRACE_CONFIG.includeAudit : Boolean(value.includeAudit),
|
||||
logDebugRequests: value.logDebugRequests === undefined ? DEFAULT_TRACE_CONFIG.logDebugRequests : Boolean(value.logDebugRequests),
|
||||
autoDisableAt: typeof value.autoDisableAt === "string" && value.autoDisableAt.trim()
|
||||
? value.autoDisableAt
|
||||
: null,
|
||||
updatedAt: typeof value.updatedAt === "string" && value.updatedAt.trim()
|
||||
? value.updatedAt
|
||||
: DEFAULT_TRACE_CONFIG.updatedAt
|
||||
};
|
||||
}
|
||||
|
||||
function loadTraceConfig(): SupportTraceConfig {
|
||||
if (!traceConfigPath) {
|
||||
return { ...DEFAULT_TRACE_CONFIG };
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(fs.readFileSync(traceConfigPath, "utf8")) as unknown;
|
||||
return normalizeTraceConfig(parsed);
|
||||
} catch {
|
||||
return { ...DEFAULT_TRACE_CONFIG };
|
||||
}
|
||||
}
|
||||
|
||||
function persistTraceConfig(): void {
|
||||
if (!traceConfigPath) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
fs.writeFileSync(traceConfigPath, `${JSON.stringify(traceConfig, null, 2)}\n`, "utf8");
|
||||
} catch {
|
||||
}
|
||||
}
|
||||
|
||||
const mainLogListener = (line: string): void => {
|
||||
if (!traceConfig.enabled || !traceConfig.includeMainLog) {
|
||||
return;
|
||||
}
|
||||
appendTraceLine(line);
|
||||
};
|
||||
|
||||
function clearAutoDisableTimer(): void {
|
||||
if (autoDisableTimer) {
|
||||
clearTimeout(autoDisableTimer);
|
||||
autoDisableTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
function disableTraceDueToExpiry(): void {
|
||||
clearAutoDisableTimer();
|
||||
if (!traceConfig.enabled) {
|
||||
return;
|
||||
}
|
||||
traceConfig = normalizeTraceConfig({
|
||||
...traceConfig,
|
||||
enabled: false,
|
||||
autoDisableAt: null,
|
||||
updatedAt: logTimestamp()
|
||||
});
|
||||
persistTraceConfig();
|
||||
appendTraceLine(`${logTimestamp()} [INFO] [trace] Support-Trace automatisch deaktiviert | reason=expired\n`);
|
||||
}
|
||||
|
||||
function scheduleAutoDisable(): void {
|
||||
clearAutoDisableTimer();
|
||||
if (!traceConfig.enabled || !traceConfig.autoDisableAt) {
|
||||
return;
|
||||
}
|
||||
const until = Date.parse(traceConfig.autoDisableAt);
|
||||
if (!Number.isFinite(until)) {
|
||||
return;
|
||||
}
|
||||
const remainingMs = until - Date.now();
|
||||
if (remainingMs <= 0) {
|
||||
disableTraceDueToExpiry();
|
||||
return;
|
||||
}
|
||||
autoDisableTimer = setTimeout(() => {
|
||||
autoDisableTimer = null;
|
||||
disableTraceDueToExpiry();
|
||||
}, Math.min(remainingMs, 2_147_483_647));
|
||||
}
|
||||
|
||||
export function initTraceLog(baseDir: string): void {
|
||||
traceLogPath = path.join(baseDir, "trace.log");
|
||||
traceConfigPath = path.join(baseDir, TRACE_CONFIG_FILE);
|
||||
try {
|
||||
fs.mkdirSync(baseDir, { recursive: true });
|
||||
cleanupOldBackup(traceLogPath);
|
||||
if (!fs.existsSync(traceLogPath)) {
|
||||
fs.writeFileSync(traceLogPath, "", "utf8");
|
||||
}
|
||||
rotateIfNeeded(traceLogPath);
|
||||
if (!fs.existsSync(traceLogPath)) {
|
||||
fs.writeFileSync(traceLogPath, "", "utf8");
|
||||
}
|
||||
traceConfig = loadTraceConfig();
|
||||
persistTraceConfig();
|
||||
fs.appendFileSync(traceLogPath, `=== Trace-Log Start: ${logTimestamp()} ===\n`, "utf8");
|
||||
} catch {
|
||||
traceLogPath = null;
|
||||
traceConfigPath = null;
|
||||
traceConfig = { ...DEFAULT_TRACE_CONFIG };
|
||||
return;
|
||||
}
|
||||
addLogListener(mainLogListener);
|
||||
scheduleAutoDisable();
|
||||
}
|
||||
|
||||
export function getTraceLogPath(): string | null {
|
||||
if (!traceLogPath) {
|
||||
return null;
|
||||
}
|
||||
return fs.existsSync(traceLogPath) ? traceLogPath : null;
|
||||
}
|
||||
|
||||
export function getTraceConfigPath(): string | null {
|
||||
if (!traceConfigPath) {
|
||||
return null;
|
||||
}
|
||||
return fs.existsSync(traceConfigPath) ? traceConfigPath : null;
|
||||
}
|
||||
|
||||
export function getTraceConfig(): SupportTraceConfig {
|
||||
return { ...traceConfig };
|
||||
}
|
||||
|
||||
export function updateTraceConfig(patch: Partial<SupportTraceConfig>): SupportTraceConfig {
|
||||
traceConfig = normalizeTraceConfig({
|
||||
...traceConfig,
|
||||
...patch,
|
||||
updatedAt: logTimestamp()
|
||||
});
|
||||
persistTraceConfig();
|
||||
scheduleAutoDisable();
|
||||
appendTraceLine(`${logTimestamp()} [INFO] [trace] Konfiguration aktualisiert${formatFields(traceConfig as unknown as Record<string, unknown>)}\n`);
|
||||
return getTraceConfig();
|
||||
}
|
||||
|
||||
export function setTraceEnabled(enabled: boolean, note = "", durationMs: number = TRACE_DEFAULT_AUTO_DISABLE_MS): SupportTraceConfig {
|
||||
const autoDisableAt = enabled && durationMs > 0
|
||||
? new Date(Date.now() + durationMs).toISOString()
|
||||
: null;
|
||||
const next = updateTraceConfig({ enabled, autoDisableAt });
|
||||
appendTraceLine(`${logTimestamp()} [INFO] [trace] Support-Trace ${enabled ? "aktiviert" : "deaktiviert"}${formatFields({ note, autoDisableAt })}\n`);
|
||||
return next;
|
||||
}
|
||||
|
||||
export function logTraceEvent(
|
||||
level: TraceLevel,
|
||||
category: string,
|
||||
message: string,
|
||||
fields?: Record<string, unknown>
|
||||
): void {
|
||||
if (!traceConfig.enabled) {
|
||||
return;
|
||||
}
|
||||
if (category === "audit" && !traceConfig.includeAudit) {
|
||||
return;
|
||||
}
|
||||
appendTraceLine(`${logTimestamp()} [${level}] [${category}] ${message}${formatFields(fields)}\n`);
|
||||
}
|
||||
|
||||
export function shutdownTraceLog(): void {
|
||||
removeLogListener(mainLogListener);
|
||||
clearAutoDisableTimer();
|
||||
if (!traceLogPath) {
|
||||
return;
|
||||
}
|
||||
if (flushTimer) {
|
||||
clearTimeout(flushTimer);
|
||||
flushTimer = null;
|
||||
}
|
||||
flushPending();
|
||||
try {
|
||||
fs.appendFileSync(traceLogPath, `=== Trace-Log Ende: ${logTimestamp()} ===\n`, "utf8");
|
||||
} catch {
|
||||
}
|
||||
traceLogPath = null;
|
||||
traceConfigPath = null;
|
||||
traceConfig = { ...DEFAULT_TRACE_CONFIG };
|
||||
}
|
||||
|
||||
@ -1,34 +1,34 @@
|
||||
export interface InstallResumeManager {
|
||||
isSessionRunning(): boolean;
|
||||
stop(options: { parkForRestart: boolean }): void;
|
||||
persistNowSync(): void;
|
||||
start(): Promise<void> | void;
|
||||
}
|
||||
|
||||
export async function runInstallWithResume<T extends { started: boolean }>(
|
||||
manager: InstallResumeManager,
|
||||
doInstall: () => Promise<T>
|
||||
): Promise<T> {
|
||||
const wasRunning = manager.isSessionRunning();
|
||||
if (wasRunning) {
|
||||
manager.stop({ parkForRestart: true });
|
||||
}
|
||||
manager.persistNowSync();
|
||||
|
||||
const resumeIfParked = async (): Promise<void> => {
|
||||
if (wasRunning && !manager.isSessionRunning()) {
|
||||
await manager.start();
|
||||
}
|
||||
};
|
||||
|
||||
try {
|
||||
const result = await doInstall();
|
||||
if (!result.started) {
|
||||
await resumeIfParked();
|
||||
}
|
||||
return result;
|
||||
} catch (error) {
|
||||
await resumeIfParked();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
export interface InstallResumeManager {
|
||||
isSessionRunning(): boolean;
|
||||
stop(options: { parkForRestart: boolean }): void;
|
||||
persistNowSync(): void;
|
||||
start(): Promise<void> | void;
|
||||
}
|
||||
|
||||
export async function runInstallWithResume<T extends { started: boolean }>(
|
||||
manager: InstallResumeManager,
|
||||
doInstall: () => Promise<T>
|
||||
): Promise<T> {
|
||||
const wasRunning = manager.isSessionRunning();
|
||||
if (wasRunning) {
|
||||
manager.stop({ parkForRestart: true });
|
||||
}
|
||||
manager.persistNowSync();
|
||||
|
||||
const resumeIfParked = async (): Promise<void> => {
|
||||
if (wasRunning && !manager.isSessionRunning()) {
|
||||
await manager.start();
|
||||
}
|
||||
};
|
||||
|
||||
try {
|
||||
const result = await doInstall();
|
||||
if (!result.started) {
|
||||
await resumeIfParked();
|
||||
}
|
||||
return result;
|
||||
} catch (error) {
|
||||
await resumeIfParked();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
2079
src/main/update.ts
2079
src/main/update.ts
File diff suppressed because it is too large
Load Diff
@ -1,309 +1,309 @@
|
||||
import path from "node:path";
|
||||
import { ParsedPackageInput } from "../shared/types";
|
||||
|
||||
function safeDecodeURIComponent(value: string): string {
|
||||
try {
|
||||
return decodeURIComponent(value);
|
||||
} catch {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
const WINDOWS_RESERVED_BASENAMES = new Set([
|
||||
"con", "prn", "aux", "nul",
|
||||
"com1", "com2", "com3", "com4", "com5", "com6", "com7", "com8", "com9",
|
||||
"lpt1", "lpt2", "lpt3", "lpt4", "lpt5", "lpt6", "lpt7", "lpt8", "lpt9"
|
||||
]);
|
||||
|
||||
export function compactErrorText(message: unknown, maxLen = 220): string {
|
||||
const raw = String(message ?? "").replace(/<[^>]+>/g, " ").replace(/\s+/g, " ").trim();
|
||||
if (!raw) {
|
||||
return "Unbekannter Fehler";
|
||||
}
|
||||
const safeMaxLen = Number.isFinite(maxLen) ? Math.max(4, Math.floor(maxLen)) : 220;
|
||||
if (raw.length <= safeMaxLen) {
|
||||
return raw;
|
||||
}
|
||||
return `${raw.slice(0, safeMaxLen - 3)}...`;
|
||||
}
|
||||
|
||||
export function sanitizeFilename(name: string): string {
|
||||
const cleaned = String(name || "")
|
||||
.replace(/\0/g, "")
|
||||
.replace(/[\\/:*?"<>|]/g, " ")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim();
|
||||
|
||||
let normalized = cleaned
|
||||
.replace(/^[.\s]+/g, "")
|
||||
.replace(/[.\s]+$/g, "")
|
||||
.trim();
|
||||
|
||||
if (!normalized || normalized === "." || normalized === ".." || /^\.+$/.test(normalized)) {
|
||||
return "Paket";
|
||||
}
|
||||
|
||||
const parsed = path.parse(normalized);
|
||||
const reservedBase = (parsed.name.split(".")[0] || parsed.name).toLowerCase();
|
||||
if (WINDOWS_RESERVED_BASENAMES.has(reservedBase)) {
|
||||
normalized = `${parsed.name.replace(/^([^.]*)/, "$1_")}${parsed.ext}`;
|
||||
}
|
||||
|
||||
return normalized || "Paket";
|
||||
}
|
||||
|
||||
export function isHttpLink(value: string): boolean {
|
||||
const text = String(value || "").trim();
|
||||
if (!text) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
const url = new URL(text);
|
||||
return (url.protocol === "http:" || url.protocol === "https:") && !!url.hostname;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function extractHttpLinksFromText(text: string): string[] {
|
||||
const matches = String(text || "").match(/https?:\/\/[^\s<>"']+/gi) ?? [];
|
||||
const seen = new Set<string>();
|
||||
const links: string[] = [];
|
||||
|
||||
for (const match of matches) {
|
||||
let candidate = String(match || "").trim();
|
||||
let openParen = 0;
|
||||
let closeParen = 0;
|
||||
let openBracket = 0;
|
||||
let closeBracket = 0;
|
||||
for (const char of candidate) {
|
||||
if (char === "(") {
|
||||
openParen += 1;
|
||||
} else if (char === ")") {
|
||||
closeParen += 1;
|
||||
} else if (char === "[") {
|
||||
openBracket += 1;
|
||||
} else if (char === "]") {
|
||||
closeBracket += 1;
|
||||
}
|
||||
}
|
||||
while (candidate.length > 0) {
|
||||
const lastChar = candidate[candidate.length - 1];
|
||||
if (![")", "]", ",", ".", "!", "?", ";", ":"].includes(lastChar)) {
|
||||
break;
|
||||
}
|
||||
if (lastChar === ")") {
|
||||
if (closeParen <= openParen) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (lastChar === "]") {
|
||||
if (closeBracket <= openBracket) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (lastChar === ")") {
|
||||
closeParen = Math.max(0, closeParen - 1);
|
||||
} else if (lastChar === "]") {
|
||||
closeBracket = Math.max(0, closeBracket - 1);
|
||||
}
|
||||
candidate = candidate.slice(0, -1);
|
||||
}
|
||||
if (!candidate || !isHttpLink(candidate) || seen.has(candidate)) {
|
||||
continue;
|
||||
}
|
||||
seen.add(candidate);
|
||||
links.push(candidate);
|
||||
}
|
||||
|
||||
return links;
|
||||
}
|
||||
|
||||
export function humanSize(bytes: number): string {
|
||||
const value = Number(bytes);
|
||||
if (!Number.isFinite(value) || value < 0) {
|
||||
return "0 B";
|
||||
}
|
||||
if (value < 1024) {
|
||||
return `${Math.round(value)} B`;
|
||||
}
|
||||
const units = ["KB", "MB", "GB", "TB"];
|
||||
let size = value / 1024;
|
||||
let unit = 0;
|
||||
while (size >= 1024 && unit < units.length - 1) {
|
||||
size /= 1024;
|
||||
unit += 1;
|
||||
}
|
||||
return `${size.toFixed(size < 10 ? 1 : 0)} ${units[unit]}`;
|
||||
}
|
||||
|
||||
export function filenameFromUrl(url: string): string {
|
||||
try {
|
||||
const parsed = new URL(url);
|
||||
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
|
||||
return "download.bin";
|
||||
}
|
||||
const queryName = parsed.searchParams.get("filename")
|
||||
|| parsed.searchParams.get("file")
|
||||
|| parsed.searchParams.get("name")
|
||||
|| parsed.searchParams.get("download")
|
||||
|| parsed.searchParams.get("title")
|
||||
|| "";
|
||||
const rawName = queryName || path.basename(parsed.pathname || "");
|
||||
const decoded = safeDecodeURIComponent(rawName || "").trim();
|
||||
const normalized = decoded
|
||||
.replace(/\.(rar|zip|7z|tar|gz|bz2|xz|iso|part\d+\.rar|r\d{2,3})\.html$/i, ".$1")
|
||||
.replace(/\.(mp4|mkv|avi|mp3|flac|srt)\.html$/i, ".$1");
|
||||
return sanitizeFilename(normalized || "download.bin");
|
||||
} catch {
|
||||
return "download.bin";
|
||||
}
|
||||
}
|
||||
|
||||
export function looksLikeOpaqueFilename(name: string): boolean {
|
||||
const cleaned = sanitizeFilename(name || "").toLowerCase();
|
||||
if (!cleaned || cleaned === "download.bin") {
|
||||
return true;
|
||||
}
|
||||
const parsed = path.parse(cleaned);
|
||||
return /^[a-f0-9]{24,}$/i.test(parsed.name || cleaned);
|
||||
}
|
||||
|
||||
export function inferPackageNameFromLinks(links: string[]): string {
|
||||
if (links.length === 0) {
|
||||
return "Paket";
|
||||
}
|
||||
const names = links.map((link) => filenameFromUrl(link).toLowerCase());
|
||||
const first = names[0];
|
||||
const match = first.match(/^([a-z0-9._\- ]{3,80}?)(?:\.|-|_)(?:part\d+|r\d{2}|s\d{2}e\d{2})/i);
|
||||
if (match) {
|
||||
return sanitizeFilename(match[1]);
|
||||
}
|
||||
return sanitizeFilename(path.parse(first).name || "Paket");
|
||||
}
|
||||
|
||||
export function uniquePreserveOrder(items: string[]): string[] {
|
||||
const seen = new Set<string>();
|
||||
const out: string[] = [];
|
||||
for (const item of items) {
|
||||
const trimmed = item.trim();
|
||||
if (!trimmed || seen.has(trimmed)) {
|
||||
continue;
|
||||
}
|
||||
seen.add(trimmed);
|
||||
out.push(trimmed);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function parsePackagesFromLinksText(rawText: string, defaultPackageName: string): ParsedPackageInput[] {
|
||||
const lines = String(rawText || "").split(/\r?\n/);
|
||||
const packages: ParsedPackageInput[] = [];
|
||||
let currentName = String(defaultPackageName || "").trim();
|
||||
let currentLinks: string[] = [];
|
||||
let currentFileNames: string[] = [];
|
||||
let pendingFileName = "";
|
||||
|
||||
const flush = (): void => {
|
||||
const links = uniquePreserveOrder(currentLinks.filter((line) => isHttpLink(line)));
|
||||
if (links.length > 0) {
|
||||
const normalizedCurrentName = String(currentName || "").trim();
|
||||
const fileNames = links.map((link) => {
|
||||
const firstIndex = currentLinks.findIndex((currentLink) => currentLink === link);
|
||||
return firstIndex >= 0 ? currentFileNames[firstIndex] || "" : "";
|
||||
});
|
||||
const nextPackage: ParsedPackageInput = {
|
||||
name: normalizedCurrentName
|
||||
? sanitizeFilename(normalizedCurrentName)
|
||||
: inferPackageNameFromLinks(links),
|
||||
links
|
||||
};
|
||||
if (fileNames.some((fileName) => fileName.trim().length > 0)) {
|
||||
nextPackage.fileNames = fileNames;
|
||||
}
|
||||
packages.push(nextPackage);
|
||||
}
|
||||
currentLinks = [];
|
||||
currentFileNames = [];
|
||||
pendingFileName = "";
|
||||
};
|
||||
|
||||
for (const line of lines) {
|
||||
const text = line.trim();
|
||||
if (!text) {
|
||||
continue;
|
||||
}
|
||||
const marker = text.match(/^#\s*package\s*:\s*(.+)$/i);
|
||||
if (marker) {
|
||||
flush();
|
||||
currentName = String(marker[1] || "").trim();
|
||||
pendingFileName = "";
|
||||
continue;
|
||||
}
|
||||
const fileMarker = text.match(/^#\s*file\s*:\s*(.+)$/i);
|
||||
if (fileMarker) {
|
||||
pendingFileName = sanitizeFilename(String(fileMarker[1] || "").trim());
|
||||
continue;
|
||||
}
|
||||
if (!isHttpLink(text)) {
|
||||
continue;
|
||||
}
|
||||
currentLinks.push(text);
|
||||
currentFileNames.push(pendingFileName);
|
||||
pendingFileName = "";
|
||||
}
|
||||
|
||||
flush();
|
||||
if (packages.length === 0) {
|
||||
return [];
|
||||
}
|
||||
return packages;
|
||||
}
|
||||
|
||||
export function ensureDirPath(baseDir: string, packageName: string): string {
|
||||
if (!path.isAbsolute(baseDir)) {
|
||||
throw new Error("baseDir muss ein absoluter Pfad sein");
|
||||
}
|
||||
return path.join(baseDir, sanitizeFilename(packageName));
|
||||
}
|
||||
|
||||
export function nowMs(): number {
|
||||
return Date.now();
|
||||
}
|
||||
|
||||
export function sleep(ms: number, signal?: AbortSignal): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (signal?.aborted) {
|
||||
reject(new Error(String(signal.reason || "aborted")));
|
||||
return;
|
||||
}
|
||||
const timer = setTimeout(() => {
|
||||
cleanup();
|
||||
resolve();
|
||||
}, ms);
|
||||
const onAbort = (): void => {
|
||||
clearTimeout(timer);
|
||||
cleanup();
|
||||
reject(new Error(String(signal?.reason || "aborted")));
|
||||
};
|
||||
const cleanup = (): void => {
|
||||
signal?.removeEventListener("abort", onAbort);
|
||||
};
|
||||
signal?.addEventListener("abort", onAbort, { once: true });
|
||||
});
|
||||
}
|
||||
|
||||
export function formatEta(seconds: number): string {
|
||||
if (!Number.isFinite(seconds) || seconds < 0) {
|
||||
return "--";
|
||||
}
|
||||
const s = Math.floor(seconds);
|
||||
const sec = s % 60;
|
||||
const minTotal = Math.floor(s / 60);
|
||||
const min = minTotal % 60;
|
||||
const hr = Math.floor(minTotal / 60);
|
||||
if (hr > 0) {
|
||||
return `${String(hr).padStart(2, "0")}:${String(min).padStart(2, "0")}:${String(sec).padStart(2, "0")}`;
|
||||
}
|
||||
return `${String(min).padStart(2, "0")}:${String(sec).padStart(2, "0")}`;
|
||||
}
|
||||
import path from "node:path";
|
||||
import { ParsedPackageInput } from "../shared/types";
|
||||
|
||||
function safeDecodeURIComponent(value: string): string {
|
||||
try {
|
||||
return decodeURIComponent(value);
|
||||
} catch {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
const WINDOWS_RESERVED_BASENAMES = new Set([
|
||||
"con", "prn", "aux", "nul",
|
||||
"com1", "com2", "com3", "com4", "com5", "com6", "com7", "com8", "com9",
|
||||
"lpt1", "lpt2", "lpt3", "lpt4", "lpt5", "lpt6", "lpt7", "lpt8", "lpt9"
|
||||
]);
|
||||
|
||||
export function compactErrorText(message: unknown, maxLen = 220): string {
|
||||
const raw = String(message ?? "").replace(/<[^>]+>/g, " ").replace(/\s+/g, " ").trim();
|
||||
if (!raw) {
|
||||
return "Unbekannter Fehler";
|
||||
}
|
||||
const safeMaxLen = Number.isFinite(maxLen) ? Math.max(4, Math.floor(maxLen)) : 220;
|
||||
if (raw.length <= safeMaxLen) {
|
||||
return raw;
|
||||
}
|
||||
return `${raw.slice(0, safeMaxLen - 3)}...`;
|
||||
}
|
||||
|
||||
export function sanitizeFilename(name: string): string {
|
||||
const cleaned = String(name || "")
|
||||
.replace(/\0/g, "")
|
||||
.replace(/[\\/:*?"<>|]/g, " ")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim();
|
||||
|
||||
let normalized = cleaned
|
||||
.replace(/^[.\s]+/g, "")
|
||||
.replace(/[.\s]+$/g, "")
|
||||
.trim();
|
||||
|
||||
if (!normalized || normalized === "." || normalized === ".." || /^\.+$/.test(normalized)) {
|
||||
return "Paket";
|
||||
}
|
||||
|
||||
const parsed = path.parse(normalized);
|
||||
const reservedBase = (parsed.name.split(".")[0] || parsed.name).toLowerCase();
|
||||
if (WINDOWS_RESERVED_BASENAMES.has(reservedBase)) {
|
||||
normalized = `${parsed.name.replace(/^([^.]*)/, "$1_")}${parsed.ext}`;
|
||||
}
|
||||
|
||||
return normalized || "Paket";
|
||||
}
|
||||
|
||||
export function isHttpLink(value: string): boolean {
|
||||
const text = String(value || "").trim();
|
||||
if (!text) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
const url = new URL(text);
|
||||
return (url.protocol === "http:" || url.protocol === "https:") && !!url.hostname;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function extractHttpLinksFromText(text: string): string[] {
|
||||
const matches = String(text || "").match(/https?:\/\/[^\s<>"']+/gi) ?? [];
|
||||
const seen = new Set<string>();
|
||||
const links: string[] = [];
|
||||
|
||||
for (const match of matches) {
|
||||
let candidate = String(match || "").trim();
|
||||
let openParen = 0;
|
||||
let closeParen = 0;
|
||||
let openBracket = 0;
|
||||
let closeBracket = 0;
|
||||
for (const char of candidate) {
|
||||
if (char === "(") {
|
||||
openParen += 1;
|
||||
} else if (char === ")") {
|
||||
closeParen += 1;
|
||||
} else if (char === "[") {
|
||||
openBracket += 1;
|
||||
} else if (char === "]") {
|
||||
closeBracket += 1;
|
||||
}
|
||||
}
|
||||
while (candidate.length > 0) {
|
||||
const lastChar = candidate[candidate.length - 1];
|
||||
if (![")", "]", ",", ".", "!", "?", ";", ":"].includes(lastChar)) {
|
||||
break;
|
||||
}
|
||||
if (lastChar === ")") {
|
||||
if (closeParen <= openParen) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (lastChar === "]") {
|
||||
if (closeBracket <= openBracket) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (lastChar === ")") {
|
||||
closeParen = Math.max(0, closeParen - 1);
|
||||
} else if (lastChar === "]") {
|
||||
closeBracket = Math.max(0, closeBracket - 1);
|
||||
}
|
||||
candidate = candidate.slice(0, -1);
|
||||
}
|
||||
if (!candidate || !isHttpLink(candidate) || seen.has(candidate)) {
|
||||
continue;
|
||||
}
|
||||
seen.add(candidate);
|
||||
links.push(candidate);
|
||||
}
|
||||
|
||||
return links;
|
||||
}
|
||||
|
||||
export function humanSize(bytes: number): string {
|
||||
const value = Number(bytes);
|
||||
if (!Number.isFinite(value) || value < 0) {
|
||||
return "0 B";
|
||||
}
|
||||
if (value < 1024) {
|
||||
return `${Math.round(value)} B`;
|
||||
}
|
||||
const units = ["KB", "MB", "GB", "TB"];
|
||||
let size = value / 1024;
|
||||
let unit = 0;
|
||||
while (size >= 1024 && unit < units.length - 1) {
|
||||
size /= 1024;
|
||||
unit += 1;
|
||||
}
|
||||
return `${size.toFixed(size < 10 ? 1 : 0)} ${units[unit]}`;
|
||||
}
|
||||
|
||||
export function filenameFromUrl(url: string): string {
|
||||
try {
|
||||
const parsed = new URL(url);
|
||||
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
|
||||
return "download.bin";
|
||||
}
|
||||
const queryName = parsed.searchParams.get("filename")
|
||||
|| parsed.searchParams.get("file")
|
||||
|| parsed.searchParams.get("name")
|
||||
|| parsed.searchParams.get("download")
|
||||
|| parsed.searchParams.get("title")
|
||||
|| "";
|
||||
const rawName = queryName || path.basename(parsed.pathname || "");
|
||||
const decoded = safeDecodeURIComponent(rawName || "").trim();
|
||||
const normalized = decoded
|
||||
.replace(/\.(rar|zip|7z|tar|gz|bz2|xz|iso|part\d+\.rar|r\d{2,3})\.html$/i, ".$1")
|
||||
.replace(/\.(mp4|mkv|avi|mp3|flac|srt)\.html$/i, ".$1");
|
||||
return sanitizeFilename(normalized || "download.bin");
|
||||
} catch {
|
||||
return "download.bin";
|
||||
}
|
||||
}
|
||||
|
||||
export function looksLikeOpaqueFilename(name: string): boolean {
|
||||
const cleaned = sanitizeFilename(name || "").toLowerCase();
|
||||
if (!cleaned || cleaned === "download.bin") {
|
||||
return true;
|
||||
}
|
||||
const parsed = path.parse(cleaned);
|
||||
return /^[a-f0-9]{24,}$/i.test(parsed.name || cleaned);
|
||||
}
|
||||
|
||||
export function inferPackageNameFromLinks(links: string[]): string {
|
||||
if (links.length === 0) {
|
||||
return "Paket";
|
||||
}
|
||||
const names = links.map((link) => filenameFromUrl(link).toLowerCase());
|
||||
const first = names[0];
|
||||
const match = first.match(/^([a-z0-9._\- ]{3,80}?)(?:\.|-|_)(?:part\d+|r\d{2}|s\d{2}e\d{2})/i);
|
||||
if (match) {
|
||||
return sanitizeFilename(match[1]);
|
||||
}
|
||||
return sanitizeFilename(path.parse(first).name || "Paket");
|
||||
}
|
||||
|
||||
export function uniquePreserveOrder(items: string[]): string[] {
|
||||
const seen = new Set<string>();
|
||||
const out: string[] = [];
|
||||
for (const item of items) {
|
||||
const trimmed = item.trim();
|
||||
if (!trimmed || seen.has(trimmed)) {
|
||||
continue;
|
||||
}
|
||||
seen.add(trimmed);
|
||||
out.push(trimmed);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function parsePackagesFromLinksText(rawText: string, defaultPackageName: string): ParsedPackageInput[] {
|
||||
const lines = String(rawText || "").split(/\r?\n/);
|
||||
const packages: ParsedPackageInput[] = [];
|
||||
let currentName = String(defaultPackageName || "").trim();
|
||||
let currentLinks: string[] = [];
|
||||
let currentFileNames: string[] = [];
|
||||
let pendingFileName = "";
|
||||
|
||||
const flush = (): void => {
|
||||
const links = uniquePreserveOrder(currentLinks.filter((line) => isHttpLink(line)));
|
||||
if (links.length > 0) {
|
||||
const normalizedCurrentName = String(currentName || "").trim();
|
||||
const fileNames = links.map((link) => {
|
||||
const firstIndex = currentLinks.findIndex((currentLink) => currentLink === link);
|
||||
return firstIndex >= 0 ? currentFileNames[firstIndex] || "" : "";
|
||||
});
|
||||
const nextPackage: ParsedPackageInput = {
|
||||
name: normalizedCurrentName
|
||||
? sanitizeFilename(normalizedCurrentName)
|
||||
: inferPackageNameFromLinks(links),
|
||||
links
|
||||
};
|
||||
if (fileNames.some((fileName) => fileName.trim().length > 0)) {
|
||||
nextPackage.fileNames = fileNames;
|
||||
}
|
||||
packages.push(nextPackage);
|
||||
}
|
||||
currentLinks = [];
|
||||
currentFileNames = [];
|
||||
pendingFileName = "";
|
||||
};
|
||||
|
||||
for (const line of lines) {
|
||||
const text = line.trim();
|
||||
if (!text) {
|
||||
continue;
|
||||
}
|
||||
const marker = text.match(/^#\s*package\s*:\s*(.+)$/i);
|
||||
if (marker) {
|
||||
flush();
|
||||
currentName = String(marker[1] || "").trim();
|
||||
pendingFileName = "";
|
||||
continue;
|
||||
}
|
||||
const fileMarker = text.match(/^#\s*file\s*:\s*(.+)$/i);
|
||||
if (fileMarker) {
|
||||
pendingFileName = sanitizeFilename(String(fileMarker[1] || "").trim());
|
||||
continue;
|
||||
}
|
||||
if (!isHttpLink(text)) {
|
||||
continue;
|
||||
}
|
||||
currentLinks.push(text);
|
||||
currentFileNames.push(pendingFileName);
|
||||
pendingFileName = "";
|
||||
}
|
||||
|
||||
flush();
|
||||
if (packages.length === 0) {
|
||||
return [];
|
||||
}
|
||||
return packages;
|
||||
}
|
||||
|
||||
export function ensureDirPath(baseDir: string, packageName: string): string {
|
||||
if (!path.isAbsolute(baseDir)) {
|
||||
throw new Error("baseDir muss ein absoluter Pfad sein");
|
||||
}
|
||||
return path.join(baseDir, sanitizeFilename(packageName));
|
||||
}
|
||||
|
||||
export function nowMs(): number {
|
||||
return Date.now();
|
||||
}
|
||||
|
||||
export function sleep(ms: number, signal?: AbortSignal): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (signal?.aborted) {
|
||||
reject(new Error(String(signal.reason || "aborted")));
|
||||
return;
|
||||
}
|
||||
const timer = setTimeout(() => {
|
||||
cleanup();
|
||||
resolve();
|
||||
}, ms);
|
||||
const onAbort = (): void => {
|
||||
clearTimeout(timer);
|
||||
cleanup();
|
||||
reject(new Error(String(signal?.reason || "aborted")));
|
||||
};
|
||||
const cleanup = (): void => {
|
||||
signal?.removeEventListener("abort", onAbort);
|
||||
};
|
||||
signal?.addEventListener("abort", onAbort, { once: true });
|
||||
});
|
||||
}
|
||||
|
||||
export function formatEta(seconds: number): string {
|
||||
if (!Number.isFinite(seconds) || seconds < 0) {
|
||||
return "--";
|
||||
}
|
||||
const s = Math.floor(seconds);
|
||||
const sec = s % 60;
|
||||
const minTotal = Math.floor(s / 60);
|
||||
const min = minTotal % 60;
|
||||
const hr = Math.floor(minTotal / 60);
|
||||
if (hr > 0) {
|
||||
return `${String(hr).padStart(2, "0")}:${String(min).padStart(2, "0")}:${String(sec).padStart(2, "0")}`;
|
||||
}
|
||||
return `${String(min).padStart(2, "0")}:${String(sec).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -1,325 +1,325 @@
|
||||
import fs from "node:fs";
|
||||
import { spawnSync } from "node:child_process";
|
||||
|
||||
export interface WindowsHostEvent {
|
||||
timeCreated: string;
|
||||
id: number;
|
||||
providerName: string;
|
||||
levelDisplayName: string;
|
||||
message: string;
|
||||
bugcheckCode?: string;
|
||||
bugcheckCodeHex?: string;
|
||||
reportId?: string;
|
||||
}
|
||||
|
||||
export interface WindowsHostDumpFile {
|
||||
name: string;
|
||||
fullName: string;
|
||||
length: number;
|
||||
lastWriteTime: string;
|
||||
}
|
||||
|
||||
export interface WindowsCrashControlInfo {
|
||||
crashDumpEnabled: number | null;
|
||||
minidumpDir: string;
|
||||
dumpFile: string;
|
||||
overwrite: number | null;
|
||||
logEvent: number | null;
|
||||
autoReboot: number | null;
|
||||
}
|
||||
|
||||
export interface WindowsHostDiagnostics {
|
||||
collectedAt: string;
|
||||
supported: boolean;
|
||||
platform: string;
|
||||
crashControl: WindowsCrashControlInfo | null;
|
||||
recentKernelPower: WindowsHostEvent[];
|
||||
recentWerKernel: WindowsHostEvent[];
|
||||
recentKernelDump: WindowsHostEvent[];
|
||||
recentAppCrashes: WindowsHostEvent[];
|
||||
recentMinidumps: WindowsHostDumpFile[];
|
||||
assessmentHints: string[];
|
||||
errors: string[];
|
||||
}
|
||||
|
||||
const CACHE_TTL_MS = 15_000;
|
||||
|
||||
let cachedAt = 0;
|
||||
let cachedValue: WindowsHostDiagnostics | null = null;
|
||||
|
||||
function createEmptyDiagnostics(): WindowsHostDiagnostics {
|
||||
return {
|
||||
collectedAt: new Date().toISOString(),
|
||||
supported: process.platform === "win32",
|
||||
platform: process.platform,
|
||||
crashControl: null,
|
||||
recentKernelPower: [],
|
||||
recentWerKernel: [],
|
||||
recentKernelDump: [],
|
||||
recentAppCrashes: [],
|
||||
recentMinidumps: [],
|
||||
assessmentHints: [],
|
||||
errors: []
|
||||
};
|
||||
}
|
||||
|
||||
function runPowerShellJson(script: string): unknown {
|
||||
const result = spawnSync(
|
||||
process.env.ComSpec && process.env.ComSpec.toLowerCase().includes("pwsh") ? process.env.ComSpec : "powershell.exe",
|
||||
["-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-Command", script],
|
||||
{
|
||||
encoding: "utf8",
|
||||
timeout: 20_000,
|
||||
windowsHide: true,
|
||||
stdio: ["ignore", "pipe", "pipe"]
|
||||
}
|
||||
);
|
||||
|
||||
if (result.status !== 0) {
|
||||
const errorText = String(result.stderr || result.stdout || "").trim() || `PowerShell exited with code ${result.status}`;
|
||||
throw new Error(errorText);
|
||||
}
|
||||
|
||||
const text = String(result.stdout || "").trim();
|
||||
if (!text) {
|
||||
return null;
|
||||
}
|
||||
return JSON.parse(text) as unknown;
|
||||
}
|
||||
|
||||
function asRecord(value: unknown): Record<string, unknown> | null {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
return null;
|
||||
}
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function asString(value: unknown): string {
|
||||
return typeof value === "string" ? value : value === undefined || value === null ? "" : String(value);
|
||||
}
|
||||
|
||||
function asNumber(value: unknown): number | null {
|
||||
const parsed = Number(value);
|
||||
return Number.isFinite(parsed) ? parsed : null;
|
||||
}
|
||||
|
||||
function normalizeEvent(value: unknown): WindowsHostEvent | null {
|
||||
const record = asRecord(value);
|
||||
if (!record) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
timeCreated: asString(record.TimeCreated),
|
||||
id: asNumber(record.Id) || 0,
|
||||
providerName: asString(record.ProviderName),
|
||||
levelDisplayName: asString(record.LevelDisplayName),
|
||||
message: asString(record.Message),
|
||||
bugcheckCode: asString(record.BugcheckCode),
|
||||
bugcheckCodeHex: asString(record.BugcheckCodeHex),
|
||||
reportId: asString(record.ReportId)
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeDumpFile(value: unknown): WindowsHostDumpFile | null {
|
||||
const record = asRecord(value);
|
||||
if (!record) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
name: asString(record.Name),
|
||||
fullName: asString(record.FullName),
|
||||
length: asNumber(record.Length) || 0,
|
||||
lastWriteTime: asString(record.LastWriteTime)
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeCrashControl(value: unknown): WindowsCrashControlInfo | null {
|
||||
const record = asRecord(value);
|
||||
if (!record) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
crashDumpEnabled: asNumber(record.CrashDumpEnabled),
|
||||
minidumpDir: asString(record.MinidumpDir),
|
||||
dumpFile: asString(record.DumpFile),
|
||||
overwrite: asNumber(record.Overwrite),
|
||||
logEvent: asNumber(record.LogEvent),
|
||||
autoReboot: asNumber(record.AutoReboot)
|
||||
};
|
||||
}
|
||||
|
||||
function pushHints(diagnostics: WindowsHostDiagnostics): void {
|
||||
if (diagnostics.recentKernelPower.some((entry) => String(entry.bugcheckCode || "").trim() === "0")) {
|
||||
diagnostics.assessmentHints.push("Kernel-Power 41 mit BugcheckCode 0 deutet eher auf Freeze, Watchdog oder harten Reset als auf einen sauber erfassten klassischen BSOD hin.");
|
||||
}
|
||||
if (diagnostics.recentWerKernel.some((entry) => /watchdog/i.test(entry.message))) {
|
||||
diagnostics.assessmentHints.push("WER-Kernel meldet WATCHDOG-Live-Dumps. Das spricht eher fuer Kernel-, Treiber- oder Hardware-Stalls als fuer einen normalen User-Mode-App-Crash.");
|
||||
}
|
||||
if (diagnostics.recentAppCrashes.length === 0) {
|
||||
diagnostics.assessmentHints.push("Keine passenden Application-Error- oder Windows-Error-Reporting-Eintraege fuer den Downloader/Electron in den letzten Tagen gefunden.");
|
||||
}
|
||||
if (diagnostics.recentMinidumps.length === 0) {
|
||||
diagnostics.assessmentHints.push("Keine aktuellen Minidumps gefunden. Falls der Server erneut abstuerzt, sollte geprueft werden, ob Windows den Dump wirklich schreiben darf.");
|
||||
}
|
||||
}
|
||||
|
||||
function loadFromPowerShell(): WindowsHostDiagnostics {
|
||||
const script = String.raw`
|
||||
$ErrorActionPreference = "SilentlyContinue"
|
||||
|
||||
function Convert-EventRecord($eventRecord) {
|
||||
$map = @{}
|
||||
try {
|
||||
[xml]$xml = $eventRecord.ToXml()
|
||||
foreach ($node in $xml.Event.EventData.Data) {
|
||||
if ($node.Name) {
|
||||
$map[$node.Name] = [string]$node.'#text'
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
}
|
||||
|
||||
$reportId = ""
|
||||
if ([string]$eventRecord.Message -match "ReportId\s+([^,\r\n]+)") {
|
||||
$reportId = $Matches[1]
|
||||
}
|
||||
|
||||
[PSCustomObject]@{
|
||||
TimeCreated = if ($eventRecord.TimeCreated) { $eventRecord.TimeCreated.ToUniversalTime().ToString("o") } else { "" }
|
||||
Id = [int]$eventRecord.Id
|
||||
ProviderName = [string]$eventRecord.ProviderName
|
||||
LevelDisplayName = [string]$eventRecord.LevelDisplayName
|
||||
Message = [string]$eventRecord.Message
|
||||
BugcheckCode = if ($map.ContainsKey("BugcheckCode")) { [string]$map["BugcheckCode"] } else { "" }
|
||||
BugcheckCodeHex = if ($map.ContainsKey("BugcheckCode") -and [int64]$map["BugcheckCode"] -gt 0) { ("0x{0:X}" -f [int64]$map["BugcheckCode"]) } else { "" }
|
||||
ReportId = $reportId
|
||||
}
|
||||
}
|
||||
|
||||
$startTime = (Get-Date).AddDays(-7)
|
||||
$crashControl = Get-ItemProperty "HKLM:\SYSTEM\CurrentControlSet\Control\CrashControl"
|
||||
|
||||
$kernelPower = @(
|
||||
Get-WinEvent -FilterHashtable @{ LogName = "System"; Id = 41; StartTime = $startTime } -MaxEvents 5 |
|
||||
ForEach-Object { Convert-EventRecord $_ }
|
||||
)
|
||||
|
||||
$werKernel = @(
|
||||
Get-WinEvent -FilterHashtable @{ LogName = "Microsoft-Windows-WerKernel/Operational"; StartTime = $startTime } -MaxEvents 30 |
|
||||
Where-Object { $_.Message -match "WATCHDOG|dump|bugcheck|blue|memory" } |
|
||||
Select-Object -First 10 |
|
||||
ForEach-Object { Convert-EventRecord $_ }
|
||||
)
|
||||
|
||||
$kernelDump = @(
|
||||
Get-WinEvent -FilterHashtable @{ LogName = "Microsoft-Windows-Kernel-Dump/Operational"; StartTime = $startTime } -MaxEvents 20 |
|
||||
Select-Object -First 10 |
|
||||
ForEach-Object { Convert-EventRecord $_ }
|
||||
)
|
||||
|
||||
$appCrashes = @(
|
||||
Get-WinEvent -FilterHashtable @{ LogName = "Application"; StartTime = $startTime } -MaxEvents 100 |
|
||||
Where-Object {
|
||||
($_.ProviderName -eq "Application Error" -or $_.ProviderName -eq "Windows Error Reporting") -and
|
||||
($_.Message -match "Real-Debrid-Downloader|electron|node\.exe|main\.js")
|
||||
} |
|
||||
Select-Object -First 10 |
|
||||
ForEach-Object { Convert-EventRecord $_ }
|
||||
)
|
||||
|
||||
$dumpFiles = @()
|
||||
foreach ($dir in @("C:\Windows\Minidump", "C:\Windows\Minidumps")) {
|
||||
if (Test-Path $dir) {
|
||||
$dumpFiles += Get-ChildItem -Path $dir -File |
|
||||
Sort-Object LastWriteTime -Descending |
|
||||
Select-Object -First 10 |
|
||||
ForEach-Object {
|
||||
[PSCustomObject]@{
|
||||
Name = $_.Name
|
||||
FullName = $_.FullName
|
||||
Length = [int64]$_.Length
|
||||
LastWriteTime = $_.LastWriteTimeUtc.ToString("o")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[PSCustomObject]@{
|
||||
CrashControl = [PSCustomObject]@{
|
||||
CrashDumpEnabled = if ($null -ne $crashControl.CrashDumpEnabled) { [int]$crashControl.CrashDumpEnabled } else { $null }
|
||||
MinidumpDir = [string]$crashControl.MinidumpDir
|
||||
DumpFile = [string]$crashControl.DumpFile
|
||||
Overwrite = if ($null -ne $crashControl.Overwrite) { [int]$crashControl.Overwrite } else { $null }
|
||||
LogEvent = if ($null -ne $crashControl.LogEvent) { [int]$crashControl.LogEvent } else { $null }
|
||||
AutoReboot = if ($null -ne $crashControl.AutoReboot) { [int]$crashControl.AutoReboot } else { $null }
|
||||
}
|
||||
RecentKernelPower = @($kernelPower)
|
||||
RecentWerKernel = @($werKernel)
|
||||
RecentKernelDump = @($kernelDump)
|
||||
RecentAppCrashes = @($appCrashes)
|
||||
RecentMinidumps = @($dumpFiles)
|
||||
} | ConvertTo-Json -Depth 6 -Compress
|
||||
`;
|
||||
|
||||
const raw = runPowerShellJson(script);
|
||||
const parsed = asRecord(raw);
|
||||
const diagnostics = createEmptyDiagnostics();
|
||||
diagnostics.crashControl = normalizeCrashControl(parsed?.CrashControl ?? null);
|
||||
diagnostics.recentKernelPower = Array.isArray(parsed?.RecentKernelPower) ? parsed!.RecentKernelPower.map(normalizeEvent).filter(Boolean) as WindowsHostEvent[] : [];
|
||||
diagnostics.recentWerKernel = Array.isArray(parsed?.RecentWerKernel) ? parsed!.RecentWerKernel.map(normalizeEvent).filter(Boolean) as WindowsHostEvent[] : [];
|
||||
diagnostics.recentKernelDump = Array.isArray(parsed?.RecentKernelDump) ? parsed!.RecentKernelDump.map(normalizeEvent).filter(Boolean) as WindowsHostEvent[] : [];
|
||||
diagnostics.recentAppCrashes = Array.isArray(parsed?.RecentAppCrashes) ? parsed!.RecentAppCrashes.map(normalizeEvent).filter(Boolean) as WindowsHostEvent[] : [];
|
||||
diagnostics.recentMinidumps = Array.isArray(parsed?.RecentMinidumps) ? parsed!.RecentMinidumps.map(normalizeDumpFile).filter(Boolean) as WindowsHostDumpFile[] : [];
|
||||
diagnostics.collectedAt = new Date().toISOString();
|
||||
pushHints(diagnostics);
|
||||
return diagnostics;
|
||||
}
|
||||
|
||||
export function getWindowsHostDiagnostics(forceRefresh = false): WindowsHostDiagnostics {
|
||||
if (!forceRefresh && cachedValue && Date.now() - cachedAt < CACHE_TTL_MS) {
|
||||
return cachedValue;
|
||||
}
|
||||
|
||||
const diagnostics = createEmptyDiagnostics();
|
||||
if (process.platform !== "win32") {
|
||||
diagnostics.assessmentHints.push("Windows-Host-Diagnose ist nur unter Windows verfuegbar.");
|
||||
cachedAt = Date.now();
|
||||
cachedValue = diagnostics;
|
||||
return diagnostics;
|
||||
}
|
||||
|
||||
try {
|
||||
const loaded = loadFromPowerShell();
|
||||
cachedAt = Date.now();
|
||||
cachedValue = loaded;
|
||||
return loaded;
|
||||
} catch (error) {
|
||||
diagnostics.errors.push(String(error instanceof Error ? error.message : error));
|
||||
diagnostics.assessmentHints.push("Host-Diagnose konnte nicht vollstaendig geladen werden.");
|
||||
cachedAt = Date.now();
|
||||
cachedValue = diagnostics;
|
||||
return diagnostics;
|
||||
}
|
||||
}
|
||||
|
||||
export function getCachedWindowsHostDiagnostics(): WindowsHostDiagnostics | null {
|
||||
return cachedValue;
|
||||
}
|
||||
|
||||
export function resetWindowsHostDiagnosticsCache(): void {
|
||||
cachedAt = 0;
|
||||
cachedValue = null;
|
||||
}
|
||||
|
||||
export function hasRecentWindowsMinidumps(): boolean {
|
||||
for (const dir of ["C:\\Windows\\Minidump", "C:\\Windows\\Minidumps"]) {
|
||||
try {
|
||||
const entries = fs.readdirSync(dir, { withFileTypes: true });
|
||||
if (entries.some((entry) => entry.isFile())) {
|
||||
return true;
|
||||
}
|
||||
} catch {
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
import fs from "node:fs";
|
||||
import { spawnSync } from "node:child_process";
|
||||
|
||||
export interface WindowsHostEvent {
|
||||
timeCreated: string;
|
||||
id: number;
|
||||
providerName: string;
|
||||
levelDisplayName: string;
|
||||
message: string;
|
||||
bugcheckCode?: string;
|
||||
bugcheckCodeHex?: string;
|
||||
reportId?: string;
|
||||
}
|
||||
|
||||
export interface WindowsHostDumpFile {
|
||||
name: string;
|
||||
fullName: string;
|
||||
length: number;
|
||||
lastWriteTime: string;
|
||||
}
|
||||
|
||||
export interface WindowsCrashControlInfo {
|
||||
crashDumpEnabled: number | null;
|
||||
minidumpDir: string;
|
||||
dumpFile: string;
|
||||
overwrite: number | null;
|
||||
logEvent: number | null;
|
||||
autoReboot: number | null;
|
||||
}
|
||||
|
||||
export interface WindowsHostDiagnostics {
|
||||
collectedAt: string;
|
||||
supported: boolean;
|
||||
platform: string;
|
||||
crashControl: WindowsCrashControlInfo | null;
|
||||
recentKernelPower: WindowsHostEvent[];
|
||||
recentWerKernel: WindowsHostEvent[];
|
||||
recentKernelDump: WindowsHostEvent[];
|
||||
recentAppCrashes: WindowsHostEvent[];
|
||||
recentMinidumps: WindowsHostDumpFile[];
|
||||
assessmentHints: string[];
|
||||
errors: string[];
|
||||
}
|
||||
|
||||
const CACHE_TTL_MS = 15_000;
|
||||
|
||||
let cachedAt = 0;
|
||||
let cachedValue: WindowsHostDiagnostics | null = null;
|
||||
|
||||
function createEmptyDiagnostics(): WindowsHostDiagnostics {
|
||||
return {
|
||||
collectedAt: new Date().toISOString(),
|
||||
supported: process.platform === "win32",
|
||||
platform: process.platform,
|
||||
crashControl: null,
|
||||
recentKernelPower: [],
|
||||
recentWerKernel: [],
|
||||
recentKernelDump: [],
|
||||
recentAppCrashes: [],
|
||||
recentMinidumps: [],
|
||||
assessmentHints: [],
|
||||
errors: []
|
||||
};
|
||||
}
|
||||
|
||||
function runPowerShellJson(script: string): unknown {
|
||||
const result = spawnSync(
|
||||
process.env.ComSpec && process.env.ComSpec.toLowerCase().includes("pwsh") ? process.env.ComSpec : "powershell.exe",
|
||||
["-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-Command", script],
|
||||
{
|
||||
encoding: "utf8",
|
||||
timeout: 20_000,
|
||||
windowsHide: true,
|
||||
stdio: ["ignore", "pipe", "pipe"]
|
||||
}
|
||||
);
|
||||
|
||||
if (result.status !== 0) {
|
||||
const errorText = String(result.stderr || result.stdout || "").trim() || `PowerShell exited with code ${result.status}`;
|
||||
throw new Error(errorText);
|
||||
}
|
||||
|
||||
const text = String(result.stdout || "").trim();
|
||||
if (!text) {
|
||||
return null;
|
||||
}
|
||||
return JSON.parse(text) as unknown;
|
||||
}
|
||||
|
||||
function asRecord(value: unknown): Record<string, unknown> | null {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
return null;
|
||||
}
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function asString(value: unknown): string {
|
||||
return typeof value === "string" ? value : value === undefined || value === null ? "" : String(value);
|
||||
}
|
||||
|
||||
function asNumber(value: unknown): number | null {
|
||||
const parsed = Number(value);
|
||||
return Number.isFinite(parsed) ? parsed : null;
|
||||
}
|
||||
|
||||
function normalizeEvent(value: unknown): WindowsHostEvent | null {
|
||||
const record = asRecord(value);
|
||||
if (!record) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
timeCreated: asString(record.TimeCreated),
|
||||
id: asNumber(record.Id) || 0,
|
||||
providerName: asString(record.ProviderName),
|
||||
levelDisplayName: asString(record.LevelDisplayName),
|
||||
message: asString(record.Message),
|
||||
bugcheckCode: asString(record.BugcheckCode),
|
||||
bugcheckCodeHex: asString(record.BugcheckCodeHex),
|
||||
reportId: asString(record.ReportId)
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeDumpFile(value: unknown): WindowsHostDumpFile | null {
|
||||
const record = asRecord(value);
|
||||
if (!record) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
name: asString(record.Name),
|
||||
fullName: asString(record.FullName),
|
||||
length: asNumber(record.Length) || 0,
|
||||
lastWriteTime: asString(record.LastWriteTime)
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeCrashControl(value: unknown): WindowsCrashControlInfo | null {
|
||||
const record = asRecord(value);
|
||||
if (!record) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
crashDumpEnabled: asNumber(record.CrashDumpEnabled),
|
||||
minidumpDir: asString(record.MinidumpDir),
|
||||
dumpFile: asString(record.DumpFile),
|
||||
overwrite: asNumber(record.Overwrite),
|
||||
logEvent: asNumber(record.LogEvent),
|
||||
autoReboot: asNumber(record.AutoReboot)
|
||||
};
|
||||
}
|
||||
|
||||
function pushHints(diagnostics: WindowsHostDiagnostics): void {
|
||||
if (diagnostics.recentKernelPower.some((entry) => String(entry.bugcheckCode || "").trim() === "0")) {
|
||||
diagnostics.assessmentHints.push("Kernel-Power 41 mit BugcheckCode 0 deutet eher auf Freeze, Watchdog oder harten Reset als auf einen sauber erfassten klassischen BSOD hin.");
|
||||
}
|
||||
if (diagnostics.recentWerKernel.some((entry) => /watchdog/i.test(entry.message))) {
|
||||
diagnostics.assessmentHints.push("WER-Kernel meldet WATCHDOG-Live-Dumps. Das spricht eher fuer Kernel-, Treiber- oder Hardware-Stalls als fuer einen normalen User-Mode-App-Crash.");
|
||||
}
|
||||
if (diagnostics.recentAppCrashes.length === 0) {
|
||||
diagnostics.assessmentHints.push("Keine passenden Application-Error- oder Windows-Error-Reporting-Eintraege fuer den Downloader/Electron in den letzten Tagen gefunden.");
|
||||
}
|
||||
if (diagnostics.recentMinidumps.length === 0) {
|
||||
diagnostics.assessmentHints.push("Keine aktuellen Minidumps gefunden. Falls der Server erneut abstuerzt, sollte geprueft werden, ob Windows den Dump wirklich schreiben darf.");
|
||||
}
|
||||
}
|
||||
|
||||
function loadFromPowerShell(): WindowsHostDiagnostics {
|
||||
const script = String.raw`
|
||||
$ErrorActionPreference = "SilentlyContinue"
|
||||
|
||||
function Convert-EventRecord($eventRecord) {
|
||||
$map = @{}
|
||||
try {
|
||||
[xml]$xml = $eventRecord.ToXml()
|
||||
foreach ($node in $xml.Event.EventData.Data) {
|
||||
if ($node.Name) {
|
||||
$map[$node.Name] = [string]$node.'#text'
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
}
|
||||
|
||||
$reportId = ""
|
||||
if ([string]$eventRecord.Message -match "ReportId\s+([^,\r\n]+)") {
|
||||
$reportId = $Matches[1]
|
||||
}
|
||||
|
||||
[PSCustomObject]@{
|
||||
TimeCreated = if ($eventRecord.TimeCreated) { $eventRecord.TimeCreated.ToUniversalTime().ToString("o") } else { "" }
|
||||
Id = [int]$eventRecord.Id
|
||||
ProviderName = [string]$eventRecord.ProviderName
|
||||
LevelDisplayName = [string]$eventRecord.LevelDisplayName
|
||||
Message = [string]$eventRecord.Message
|
||||
BugcheckCode = if ($map.ContainsKey("BugcheckCode")) { [string]$map["BugcheckCode"] } else { "" }
|
||||
BugcheckCodeHex = if ($map.ContainsKey("BugcheckCode") -and [int64]$map["BugcheckCode"] -gt 0) { ("0x{0:X}" -f [int64]$map["BugcheckCode"]) } else { "" }
|
||||
ReportId = $reportId
|
||||
}
|
||||
}
|
||||
|
||||
$startTime = (Get-Date).AddDays(-7)
|
||||
$crashControl = Get-ItemProperty "HKLM:\SYSTEM\CurrentControlSet\Control\CrashControl"
|
||||
|
||||
$kernelPower = @(
|
||||
Get-WinEvent -FilterHashtable @{ LogName = "System"; Id = 41; StartTime = $startTime } -MaxEvents 5 |
|
||||
ForEach-Object { Convert-EventRecord $_ }
|
||||
)
|
||||
|
||||
$werKernel = @(
|
||||
Get-WinEvent -FilterHashtable @{ LogName = "Microsoft-Windows-WerKernel/Operational"; StartTime = $startTime } -MaxEvents 30 |
|
||||
Where-Object { $_.Message -match "WATCHDOG|dump|bugcheck|blue|memory" } |
|
||||
Select-Object -First 10 |
|
||||
ForEach-Object { Convert-EventRecord $_ }
|
||||
)
|
||||
|
||||
$kernelDump = @(
|
||||
Get-WinEvent -FilterHashtable @{ LogName = "Microsoft-Windows-Kernel-Dump/Operational"; StartTime = $startTime } -MaxEvents 20 |
|
||||
Select-Object -First 10 |
|
||||
ForEach-Object { Convert-EventRecord $_ }
|
||||
)
|
||||
|
||||
$appCrashes = @(
|
||||
Get-WinEvent -FilterHashtable @{ LogName = "Application"; StartTime = $startTime } -MaxEvents 100 |
|
||||
Where-Object {
|
||||
($_.ProviderName -eq "Application Error" -or $_.ProviderName -eq "Windows Error Reporting") -and
|
||||
($_.Message -match "Real-Debrid-Downloader|electron|node\.exe|main\.js")
|
||||
} |
|
||||
Select-Object -First 10 |
|
||||
ForEach-Object { Convert-EventRecord $_ }
|
||||
)
|
||||
|
||||
$dumpFiles = @()
|
||||
foreach ($dir in @("C:\Windows\Minidump", "C:\Windows\Minidumps")) {
|
||||
if (Test-Path $dir) {
|
||||
$dumpFiles += Get-ChildItem -Path $dir -File |
|
||||
Sort-Object LastWriteTime -Descending |
|
||||
Select-Object -First 10 |
|
||||
ForEach-Object {
|
||||
[PSCustomObject]@{
|
||||
Name = $_.Name
|
||||
FullName = $_.FullName
|
||||
Length = [int64]$_.Length
|
||||
LastWriteTime = $_.LastWriteTimeUtc.ToString("o")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[PSCustomObject]@{
|
||||
CrashControl = [PSCustomObject]@{
|
||||
CrashDumpEnabled = if ($null -ne $crashControl.CrashDumpEnabled) { [int]$crashControl.CrashDumpEnabled } else { $null }
|
||||
MinidumpDir = [string]$crashControl.MinidumpDir
|
||||
DumpFile = [string]$crashControl.DumpFile
|
||||
Overwrite = if ($null -ne $crashControl.Overwrite) { [int]$crashControl.Overwrite } else { $null }
|
||||
LogEvent = if ($null -ne $crashControl.LogEvent) { [int]$crashControl.LogEvent } else { $null }
|
||||
AutoReboot = if ($null -ne $crashControl.AutoReboot) { [int]$crashControl.AutoReboot } else { $null }
|
||||
}
|
||||
RecentKernelPower = @($kernelPower)
|
||||
RecentWerKernel = @($werKernel)
|
||||
RecentKernelDump = @($kernelDump)
|
||||
RecentAppCrashes = @($appCrashes)
|
||||
RecentMinidumps = @($dumpFiles)
|
||||
} | ConvertTo-Json -Depth 6 -Compress
|
||||
`;
|
||||
|
||||
const raw = runPowerShellJson(script);
|
||||
const parsed = asRecord(raw);
|
||||
const diagnostics = createEmptyDiagnostics();
|
||||
diagnostics.crashControl = normalizeCrashControl(parsed?.CrashControl ?? null);
|
||||
diagnostics.recentKernelPower = Array.isArray(parsed?.RecentKernelPower) ? parsed!.RecentKernelPower.map(normalizeEvent).filter(Boolean) as WindowsHostEvent[] : [];
|
||||
diagnostics.recentWerKernel = Array.isArray(parsed?.RecentWerKernel) ? parsed!.RecentWerKernel.map(normalizeEvent).filter(Boolean) as WindowsHostEvent[] : [];
|
||||
diagnostics.recentKernelDump = Array.isArray(parsed?.RecentKernelDump) ? parsed!.RecentKernelDump.map(normalizeEvent).filter(Boolean) as WindowsHostEvent[] : [];
|
||||
diagnostics.recentAppCrashes = Array.isArray(parsed?.RecentAppCrashes) ? parsed!.RecentAppCrashes.map(normalizeEvent).filter(Boolean) as WindowsHostEvent[] : [];
|
||||
diagnostics.recentMinidumps = Array.isArray(parsed?.RecentMinidumps) ? parsed!.RecentMinidumps.map(normalizeDumpFile).filter(Boolean) as WindowsHostDumpFile[] : [];
|
||||
diagnostics.collectedAt = new Date().toISOString();
|
||||
pushHints(diagnostics);
|
||||
return diagnostics;
|
||||
}
|
||||
|
||||
export function getWindowsHostDiagnostics(forceRefresh = false): WindowsHostDiagnostics {
|
||||
if (!forceRefresh && cachedValue && Date.now() - cachedAt < CACHE_TTL_MS) {
|
||||
return cachedValue;
|
||||
}
|
||||
|
||||
const diagnostics = createEmptyDiagnostics();
|
||||
if (process.platform !== "win32") {
|
||||
diagnostics.assessmentHints.push("Windows-Host-Diagnose ist nur unter Windows verfuegbar.");
|
||||
cachedAt = Date.now();
|
||||
cachedValue = diagnostics;
|
||||
return diagnostics;
|
||||
}
|
||||
|
||||
try {
|
||||
const loaded = loadFromPowerShell();
|
||||
cachedAt = Date.now();
|
||||
cachedValue = loaded;
|
||||
return loaded;
|
||||
} catch (error) {
|
||||
diagnostics.errors.push(String(error instanceof Error ? error.message : error));
|
||||
diagnostics.assessmentHints.push("Host-Diagnose konnte nicht vollstaendig geladen werden.");
|
||||
cachedAt = Date.now();
|
||||
cachedValue = diagnostics;
|
||||
return diagnostics;
|
||||
}
|
||||
}
|
||||
|
||||
export function getCachedWindowsHostDiagnostics(): WindowsHostDiagnostics | null {
|
||||
return cachedValue;
|
||||
}
|
||||
|
||||
export function resetWindowsHostDiagnosticsCache(): void {
|
||||
cachedAt = 0;
|
||||
cachedValue = null;
|
||||
}
|
||||
|
||||
export function hasRecentWindowsMinidumps(): boolean {
|
||||
for (const dir of ["C:\\Windows\\Minidump", "C:\\Windows\\Minidumps"]) {
|
||||
try {
|
||||
const entries = fs.readdirSync(dir, { withFileTypes: true });
|
||||
if (entries.some((entry) => entry.isFile())) {
|
||||
return true;
|
||||
}
|
||||
} catch {
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
@ -1,126 +1,124 @@
|
||||
import { contextBridge, ipcRenderer } from "electron";
|
||||
import {
|
||||
AddLinksPayload,
|
||||
AllDebridHostInfo,
|
||||
AppSettings,
|
||||
DebridAccountStatus,
|
||||
DebridLinkHostLimitInfo,
|
||||
DebridProvider,
|
||||
DuplicatePolicy,
|
||||
EnableRemoteDiagnosticsInput,
|
||||
HistoryEntry,
|
||||
PackagePriority,
|
||||
RemoteDiagnosticsInfo,
|
||||
RendererErrorReport,
|
||||
SessionStats,
|
||||
StartConflictEntry,
|
||||
StartConflictResolutionResult,
|
||||
UiSnapshot,
|
||||
UpdateCheckResult,
|
||||
UpdateInstallProgress
|
||||
} from "../shared/types";
|
||||
import { IPC_CHANNELS } from "../shared/ipc";
|
||||
import { ElectronApi } from "../shared/preload-api";
|
||||
|
||||
const api: ElectronApi = {
|
||||
getSnapshot: (): Promise<UiSnapshot> => ipcRenderer.invoke(IPC_CHANNELS.GET_SNAPSHOT),
|
||||
getVersion: (): Promise<string> => ipcRenderer.invoke(IPC_CHANNELS.GET_VERSION),
|
||||
checkUpdates: (): Promise<UpdateCheckResult> => ipcRenderer.invoke(IPC_CHANNELS.CHECK_UPDATES),
|
||||
installUpdate: () => ipcRenderer.invoke(IPC_CHANNELS.INSTALL_UPDATE),
|
||||
openExternal: (url: string): Promise<boolean> => ipcRenderer.invoke(IPC_CHANNELS.OPEN_EXTERNAL, url),
|
||||
updateSettings: (settings: Partial<AppSettings>): Promise<AppSettings> => ipcRenderer.invoke(IPC_CHANNELS.UPDATE_SETTINGS, settings),
|
||||
resetProviderDailyUsage: (provider: DebridProvider): Promise<AppSettings> => ipcRenderer.invoke(IPC_CHANNELS.RESET_PROVIDER_DAILY_USAGE, provider),
|
||||
resetDebridLinkApiKeyDailyUsage: (keyId: string): Promise<AppSettings> => ipcRenderer.invoke(IPC_CHANNELS.RESET_DEBRID_LINK_API_KEY_DAILY_USAGE, keyId),
|
||||
addLinks: (payload: AddLinksPayload): Promise<{ addedPackages: number; addedLinks: number; invalidCount: number }> =>
|
||||
ipcRenderer.invoke(IPC_CHANNELS.ADD_LINKS, payload),
|
||||
addContainers: (filePaths: string[]): Promise<{ addedPackages: number; addedLinks: number }> =>
|
||||
ipcRenderer.invoke(IPC_CHANNELS.ADD_CONTAINERS, filePaths),
|
||||
getStartConflicts: (): Promise<StartConflictEntry[]> => ipcRenderer.invoke(IPC_CHANNELS.GET_START_CONFLICTS),
|
||||
resolveStartConflict: (packageId: string, policy: DuplicatePolicy): Promise<StartConflictResolutionResult> =>
|
||||
ipcRenderer.invoke(IPC_CHANNELS.RESOLVE_START_CONFLICT, packageId, policy),
|
||||
clearAll: (): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.CLEAR_ALL),
|
||||
start: (): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.START),
|
||||
startPackages: (packageIds: string[]): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.START_PACKAGES, packageIds),
|
||||
stop: (): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.STOP),
|
||||
togglePause: (): Promise<boolean> => ipcRenderer.invoke(IPC_CHANNELS.TOGGLE_PAUSE),
|
||||
cancelPackage: (packageId: string): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.CANCEL_PACKAGE, packageId),
|
||||
renamePackage: (packageId: string, newName: string): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.RENAME_PACKAGE, packageId, newName),
|
||||
reorderPackages: (packageIds: string[]): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.REORDER_PACKAGES, packageIds),
|
||||
removeItem: (itemId: string): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.REMOVE_ITEM, itemId),
|
||||
togglePackage: (packageId: string): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.TOGGLE_PACKAGE, packageId),
|
||||
exportPackageSelection: (packageIds: string[]) => ipcRenderer.invoke(IPC_CHANNELS.EXPORT_PACKAGE_SELECTION, packageIds),
|
||||
exportItemSelection: (itemIds: string[]) => ipcRenderer.invoke(IPC_CHANNELS.EXPORT_ITEM_SELECTION, itemIds),
|
||||
exportQueue: (): Promise<{ saved: boolean }> => ipcRenderer.invoke(IPC_CHANNELS.EXPORT_QUEUE),
|
||||
importQueue: (json: string): Promise<{ addedPackages: number; addedLinks: number }> => ipcRenderer.invoke(IPC_CHANNELS.IMPORT_QUEUE, json),
|
||||
toggleClipboard: (): Promise<boolean> => ipcRenderer.invoke(IPC_CHANNELS.TOGGLE_CLIPBOARD),
|
||||
pickFolder: (): Promise<string | null> => ipcRenderer.invoke(IPC_CHANNELS.PICK_FOLDER),
|
||||
pickContainers: (): Promise<string[]> => ipcRenderer.invoke(IPC_CHANNELS.PICK_CONTAINERS),
|
||||
getSessionStats: (): Promise<SessionStats> => ipcRenderer.invoke(IPC_CHANNELS.GET_SESSION_STATS),
|
||||
resetSessionStats: (): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.RESET_SESSION_STATS),
|
||||
resetDownloadStats: (): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.RESET_DOWNLOAD_STATS),
|
||||
restart: (): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.RESTART),
|
||||
quit: (): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.QUIT),
|
||||
import { contextBridge, ipcRenderer } from "electron";
|
||||
import {
|
||||
AddLinksPayload,
|
||||
AllDebridHostInfo,
|
||||
AppSettings,
|
||||
DebridAccountStatus,
|
||||
DebridLinkHostLimitInfo,
|
||||
DebridProvider,
|
||||
DuplicatePolicy,
|
||||
EnableRemoteDiagnosticsInput,
|
||||
HistoryEntry,
|
||||
PackagePriority,
|
||||
RemoteDiagnosticsInfo,
|
||||
RendererErrorReport,
|
||||
SessionStats,
|
||||
StartConflictEntry,
|
||||
StartConflictResolutionResult,
|
||||
UiSnapshot,
|
||||
UpdateCheckResult,
|
||||
UpdateInstallProgress
|
||||
} from "../shared/types";
|
||||
import { IPC_CHANNELS } from "../shared/ipc";
|
||||
import { ElectronApi } from "../shared/preload-api";
|
||||
|
||||
const api: ElectronApi = {
|
||||
getSnapshot: (): Promise<UiSnapshot> => ipcRenderer.invoke(IPC_CHANNELS.GET_SNAPSHOT),
|
||||
getVersion: (): Promise<string> => ipcRenderer.invoke(IPC_CHANNELS.GET_VERSION),
|
||||
checkUpdates: (): Promise<UpdateCheckResult> => ipcRenderer.invoke(IPC_CHANNELS.CHECK_UPDATES),
|
||||
installUpdate: () => ipcRenderer.invoke(IPC_CHANNELS.INSTALL_UPDATE),
|
||||
openExternal: (url: string): Promise<boolean> => ipcRenderer.invoke(IPC_CHANNELS.OPEN_EXTERNAL, url),
|
||||
updateSettings: (settings: Partial<AppSettings>): Promise<AppSettings> => ipcRenderer.invoke(IPC_CHANNELS.UPDATE_SETTINGS, settings),
|
||||
resetProviderDailyUsage: (provider: DebridProvider): Promise<AppSettings> => ipcRenderer.invoke(IPC_CHANNELS.RESET_PROVIDER_DAILY_USAGE, provider),
|
||||
resetDebridLinkApiKeyDailyUsage: (keyId: string): Promise<AppSettings> => ipcRenderer.invoke(IPC_CHANNELS.RESET_DEBRID_LINK_API_KEY_DAILY_USAGE, keyId),
|
||||
addLinks: (payload: AddLinksPayload): Promise<{ addedPackages: number; addedLinks: number; invalidCount: number }> =>
|
||||
ipcRenderer.invoke(IPC_CHANNELS.ADD_LINKS, payload),
|
||||
addContainers: (filePaths: string[]): Promise<{ addedPackages: number; addedLinks: number }> =>
|
||||
ipcRenderer.invoke(IPC_CHANNELS.ADD_CONTAINERS, filePaths),
|
||||
getStartConflicts: (): Promise<StartConflictEntry[]> => ipcRenderer.invoke(IPC_CHANNELS.GET_START_CONFLICTS),
|
||||
resolveStartConflict: (packageId: string, policy: DuplicatePolicy): Promise<StartConflictResolutionResult> =>
|
||||
ipcRenderer.invoke(IPC_CHANNELS.RESOLVE_START_CONFLICT, packageId, policy),
|
||||
clearAll: (): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.CLEAR_ALL),
|
||||
start: (): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.START),
|
||||
startPackages: (packageIds: string[]): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.START_PACKAGES, packageIds),
|
||||
stop: (): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.STOP),
|
||||
togglePause: (): Promise<boolean> => ipcRenderer.invoke(IPC_CHANNELS.TOGGLE_PAUSE),
|
||||
cancelPackage: (packageId: string): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.CANCEL_PACKAGE, packageId),
|
||||
renamePackage: (packageId: string, newName: string): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.RENAME_PACKAGE, packageId, newName),
|
||||
reorderPackages: (packageIds: string[]): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.REORDER_PACKAGES, packageIds),
|
||||
removeItem: (itemId: string): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.REMOVE_ITEM, itemId),
|
||||
togglePackage: (packageId: string): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.TOGGLE_PACKAGE, packageId),
|
||||
exportPackageSelection: (packageIds: string[]) => ipcRenderer.invoke(IPC_CHANNELS.EXPORT_PACKAGE_SELECTION, packageIds),
|
||||
exportItemSelection: (itemIds: string[]) => ipcRenderer.invoke(IPC_CHANNELS.EXPORT_ITEM_SELECTION, itemIds),
|
||||
exportQueue: (): Promise<{ saved: boolean }> => ipcRenderer.invoke(IPC_CHANNELS.EXPORT_QUEUE),
|
||||
importQueue: (json: string): Promise<{ addedPackages: number; addedLinks: number }> => ipcRenderer.invoke(IPC_CHANNELS.IMPORT_QUEUE, json),
|
||||
toggleClipboard: (): Promise<boolean> => ipcRenderer.invoke(IPC_CHANNELS.TOGGLE_CLIPBOARD),
|
||||
pickFolder: (): Promise<string | null> => ipcRenderer.invoke(IPC_CHANNELS.PICK_FOLDER),
|
||||
pickContainers: (): Promise<string[]> => ipcRenderer.invoke(IPC_CHANNELS.PICK_CONTAINERS),
|
||||
getSessionStats: (): Promise<SessionStats> => ipcRenderer.invoke(IPC_CHANNELS.GET_SESSION_STATS),
|
||||
resetSessionStats: (): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.RESET_SESSION_STATS),
|
||||
resetDownloadStats: (): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.RESET_DOWNLOAD_STATS),
|
||||
restart: (): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.RESTART),
|
||||
quit: (): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.QUIT),
|
||||
exportBackup: (): Promise<{ saved: boolean }> => ipcRenderer.invoke(IPC_CHANNELS.EXPORT_BACKUP),
|
||||
importBackup: (): Promise<{ restored: boolean; relaunch: boolean; message: string }> => ipcRenderer.invoke(IPC_CHANNELS.IMPORT_BACKUP),
|
||||
exportOnlineBackup: (): Promise<{ key: string }> => ipcRenderer.invoke(IPC_CHANNELS.EXPORT_ONLINE_BACKUP),
|
||||
importOnlineBackup: (key: string): Promise<{ restored: boolean; relaunch: false; message: string }> => ipcRenderer.invoke(IPC_CHANNELS.IMPORT_ONLINE_BACKUP, key),
|
||||
exportSupportBundle: (): Promise<{ saved: boolean; filePath?: string }> => ipcRenderer.invoke(IPC_CHANNELS.EXPORT_SUPPORT_BUNDLE),
|
||||
openLog: (): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.OPEN_LOG),
|
||||
openAuditLog: (): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.OPEN_AUDIT_LOG),
|
||||
openRenameLog: (): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.OPEN_RENAME_LOG),
|
||||
openSessionLog: (): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.OPEN_SESSION_LOG),
|
||||
openTraceLog: (): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.OPEN_TRACE_LOG),
|
||||
openPackageLog: (packageId: string): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.OPEN_PACKAGE_LOG, packageId),
|
||||
openItemLog: (itemId: string): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.OPEN_ITEM_LOG, itemId),
|
||||
getDebugSetupCheck: () => ipcRenderer.invoke(IPC_CHANNELS.GET_DEBUG_SETUP_CHECK),
|
||||
getRecentErrors: () => ipcRenderer.invoke(IPC_CHANNELS.GET_RECENT_ERRORS),
|
||||
testNotification: (url: string, mention: string) => ipcRenderer.invoke(IPC_CHANNELS.TEST_NOTIFY, url, mention),
|
||||
getTraceConfig: () => ipcRenderer.invoke(IPC_CHANNELS.GET_TRACE_CONFIG),
|
||||
setTraceEnabled: (enabled: boolean, note?: string, durationMinutes?: number) => ipcRenderer.invoke(IPC_CHANNELS.SET_TRACE_ENABLED, enabled, note, durationMinutes),
|
||||
rotateDebugToken: (): Promise<{ path: string }> => ipcRenderer.invoke(IPC_CHANNELS.ROTATE_DEBUG_TOKEN),
|
||||
getRemoteDiagnostics: (): Promise<RemoteDiagnosticsInfo> => ipcRenderer.invoke(IPC_CHANNELS.GET_REMOTE_DIAGNOSTICS),
|
||||
enableRemoteDiagnostics: (input: EnableRemoteDiagnosticsInput): Promise<RemoteDiagnosticsInfo> => ipcRenderer.invoke(IPC_CHANNELS.ENABLE_REMOTE_DIAGNOSTICS, input),
|
||||
disableRemoteDiagnostics: (): Promise<RemoteDiagnosticsInfo> => ipcRenderer.invoke(IPC_CHANNELS.DISABLE_REMOTE_DIAGNOSTICS),
|
||||
rotateRemoteDiagnosticsToken: (): Promise<RemoteDiagnosticsInfo> => ipcRenderer.invoke(IPC_CHANNELS.ROTATE_REMOTE_DIAGNOSTICS_TOKEN),
|
||||
openRealDebridLogin: (): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.OPEN_REALDEBRID_LOGIN),
|
||||
openAllDebridLogin: (): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.OPEN_ALLDEBRID_LOGIN),
|
||||
importBestDebridCookies: (): Promise<number> => ipcRenderer.invoke(IPC_CHANNELS.IMPORT_BESTDEBRID_COOKIES),
|
||||
getAllDebridHostInfo: (): Promise<AllDebridHostInfo> => ipcRenderer.invoke(IPC_CHANNELS.GET_ALLDEBRID_HOST_INFO),
|
||||
getDebridLinkHostLimits: (): Promise<DebridLinkHostLimitInfo[]> => ipcRenderer.invoke(IPC_CHANNELS.GET_DEBRIDLINK_HOST_LIMITS),
|
||||
checkDebridAccounts: (): Promise<DebridAccountStatus[]> => ipcRenderer.invoke(IPC_CHANNELS.CHECK_DEBRID_ACCOUNTS),
|
||||
checkMegaDebridAccount: (login: string, password: string): Promise<DebridAccountStatus | null> => ipcRenderer.invoke(IPC_CHANNELS.CHECK_MEGA_DEBRID_ACCOUNT, login, password),
|
||||
retryExtraction: (packageId: string): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.RETRY_EXTRACTION, packageId),
|
||||
extractNow: (packageId: string): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.EXTRACT_NOW, packageId),
|
||||
resetPackage: (packageId: string): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.RESET_PACKAGE, packageId),
|
||||
getHistory: (): Promise<HistoryEntry[]> => ipcRenderer.invoke(IPC_CHANNELS.GET_HISTORY),
|
||||
clearHistory: (): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.CLEAR_HISTORY),
|
||||
removeHistoryEntry: (entryId: string): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.REMOVE_HISTORY_ENTRY, entryId),
|
||||
setPackagePriority: (packageId: string, priority: PackagePriority): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.SET_PACKAGE_PRIORITY, packageId, priority),
|
||||
skipItems: (itemIds: string[]): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.SKIP_ITEMS, itemIds),
|
||||
resetItems: (itemIds: string[]): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.RESET_ITEMS, itemIds),
|
||||
startItems: (itemIds: string[]): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.START_ITEMS, itemIds),
|
||||
reportRendererError: (report: RendererErrorReport): void => ipcRenderer.send(IPC_CHANNELS.LOG_RENDERER_ERROR, report),
|
||||
onStateUpdate: (callback: (snapshot: UiSnapshot) => void): (() => void) => {
|
||||
const listener = (_event: unknown, snapshot: UiSnapshot): void => callback(snapshot);
|
||||
ipcRenderer.on(IPC_CHANNELS.STATE_UPDATE, listener);
|
||||
return () => {
|
||||
ipcRenderer.removeListener(IPC_CHANNELS.STATE_UPDATE, listener);
|
||||
};
|
||||
},
|
||||
onClipboardDetected: (callback: (links: string[]) => void): (() => void) => {
|
||||
const listener = (_event: unknown, links: string[]): void => callback(links);
|
||||
ipcRenderer.on(IPC_CHANNELS.CLIPBOARD_DETECTED, listener);
|
||||
return () => {
|
||||
ipcRenderer.removeListener(IPC_CHANNELS.CLIPBOARD_DETECTED, listener);
|
||||
};
|
||||
},
|
||||
onUpdateInstallProgress: (callback: (progress: UpdateInstallProgress) => void): (() => void) => {
|
||||
const listener = (_event: unknown, progress: UpdateInstallProgress): void => callback(progress);
|
||||
ipcRenderer.on(IPC_CHANNELS.UPDATE_INSTALL_PROGRESS, listener);
|
||||
return () => {
|
||||
ipcRenderer.removeListener(IPC_CHANNELS.UPDATE_INSTALL_PROGRESS, listener);
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
contextBridge.exposeInMainWorld("rd", api);
|
||||
exportSupportBundle: (): Promise<{ saved: boolean; filePath?: string }> => ipcRenderer.invoke(IPC_CHANNELS.EXPORT_SUPPORT_BUNDLE),
|
||||
openLog: (): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.OPEN_LOG),
|
||||
openAuditLog: (): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.OPEN_AUDIT_LOG),
|
||||
openRenameLog: (): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.OPEN_RENAME_LOG),
|
||||
openSessionLog: (): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.OPEN_SESSION_LOG),
|
||||
openTraceLog: (): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.OPEN_TRACE_LOG),
|
||||
openPackageLog: (packageId: string): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.OPEN_PACKAGE_LOG, packageId),
|
||||
openItemLog: (itemId: string): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.OPEN_ITEM_LOG, itemId),
|
||||
getDebugSetupCheck: () => ipcRenderer.invoke(IPC_CHANNELS.GET_DEBUG_SETUP_CHECK),
|
||||
getRecentErrors: () => ipcRenderer.invoke(IPC_CHANNELS.GET_RECENT_ERRORS),
|
||||
testNotification: (url: string, mention: string) => ipcRenderer.invoke(IPC_CHANNELS.TEST_NOTIFY, url, mention),
|
||||
getTraceConfig: () => ipcRenderer.invoke(IPC_CHANNELS.GET_TRACE_CONFIG),
|
||||
setTraceEnabled: (enabled: boolean, note?: string, durationMinutes?: number) => ipcRenderer.invoke(IPC_CHANNELS.SET_TRACE_ENABLED, enabled, note, durationMinutes),
|
||||
rotateDebugToken: (): Promise<{ path: string }> => ipcRenderer.invoke(IPC_CHANNELS.ROTATE_DEBUG_TOKEN),
|
||||
getRemoteDiagnostics: (): Promise<RemoteDiagnosticsInfo> => ipcRenderer.invoke(IPC_CHANNELS.GET_REMOTE_DIAGNOSTICS),
|
||||
enableRemoteDiagnostics: (input: EnableRemoteDiagnosticsInput): Promise<RemoteDiagnosticsInfo> => ipcRenderer.invoke(IPC_CHANNELS.ENABLE_REMOTE_DIAGNOSTICS, input),
|
||||
disableRemoteDiagnostics: (): Promise<RemoteDiagnosticsInfo> => ipcRenderer.invoke(IPC_CHANNELS.DISABLE_REMOTE_DIAGNOSTICS),
|
||||
rotateRemoteDiagnosticsToken: (): Promise<RemoteDiagnosticsInfo> => ipcRenderer.invoke(IPC_CHANNELS.ROTATE_REMOTE_DIAGNOSTICS_TOKEN),
|
||||
openRealDebridLogin: (): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.OPEN_REALDEBRID_LOGIN),
|
||||
openAllDebridLogin: (): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.OPEN_ALLDEBRID_LOGIN),
|
||||
importBestDebridCookies: (): Promise<number> => ipcRenderer.invoke(IPC_CHANNELS.IMPORT_BESTDEBRID_COOKIES),
|
||||
getAllDebridHostInfo: (): Promise<AllDebridHostInfo> => ipcRenderer.invoke(IPC_CHANNELS.GET_ALLDEBRID_HOST_INFO),
|
||||
getDebridLinkHostLimits: (): Promise<DebridLinkHostLimitInfo[]> => ipcRenderer.invoke(IPC_CHANNELS.GET_DEBRIDLINK_HOST_LIMITS),
|
||||
checkDebridAccounts: (): Promise<DebridAccountStatus[]> => ipcRenderer.invoke(IPC_CHANNELS.CHECK_DEBRID_ACCOUNTS),
|
||||
checkMegaDebridAccount: (login: string, password: string): Promise<DebridAccountStatus | null> => ipcRenderer.invoke(IPC_CHANNELS.CHECK_MEGA_DEBRID_ACCOUNT, login, password),
|
||||
retryExtraction: (packageId: string): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.RETRY_EXTRACTION, packageId),
|
||||
extractNow: (packageId: string): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.EXTRACT_NOW, packageId),
|
||||
resetPackage: (packageId: string): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.RESET_PACKAGE, packageId),
|
||||
getHistory: (): Promise<HistoryEntry[]> => ipcRenderer.invoke(IPC_CHANNELS.GET_HISTORY),
|
||||
clearHistory: (): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.CLEAR_HISTORY),
|
||||
removeHistoryEntry: (entryId: string): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.REMOVE_HISTORY_ENTRY, entryId),
|
||||
setPackagePriority: (packageId: string, priority: PackagePriority): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.SET_PACKAGE_PRIORITY, packageId, priority),
|
||||
skipItems: (itemIds: string[]): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.SKIP_ITEMS, itemIds),
|
||||
resetItems: (itemIds: string[]): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.RESET_ITEMS, itemIds),
|
||||
startItems: (itemIds: string[]): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.START_ITEMS, itemIds),
|
||||
reportRendererError: (report: RendererErrorReport): void => ipcRenderer.send(IPC_CHANNELS.LOG_RENDERER_ERROR, report),
|
||||
onStateUpdate: (callback: (snapshot: UiSnapshot) => void): (() => void) => {
|
||||
const listener = (_event: unknown, snapshot: UiSnapshot): void => callback(snapshot);
|
||||
ipcRenderer.on(IPC_CHANNELS.STATE_UPDATE, listener);
|
||||
return () => {
|
||||
ipcRenderer.removeListener(IPC_CHANNELS.STATE_UPDATE, listener);
|
||||
};
|
||||
},
|
||||
onClipboardDetected: (callback: (links: string[]) => void): (() => void) => {
|
||||
const listener = (_event: unknown, links: string[]): void => callback(links);
|
||||
ipcRenderer.on(IPC_CHANNELS.CLIPBOARD_DETECTED, listener);
|
||||
return () => {
|
||||
ipcRenderer.removeListener(IPC_CHANNELS.CLIPBOARD_DETECTED, listener);
|
||||
};
|
||||
},
|
||||
onUpdateInstallProgress: (callback: (progress: UpdateInstallProgress) => void): (() => void) => {
|
||||
const listener = (_event: unknown, progress: UpdateInstallProgress): void => callback(progress);
|
||||
ipcRenderer.on(IPC_CHANNELS.UPDATE_INSTALL_PROGRESS, listener);
|
||||
return () => {
|
||||
ipcRenderer.removeListener(IPC_CHANNELS.UPDATE_INSTALL_PROGRESS, listener);
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
contextBridge.exposeInMainWorld("rd", api);
|
||||
|
||||
14675
src/renderer/App.tsx
14675
src/renderer/App.tsx
File diff suppressed because it is too large
Load Diff
@ -1,94 +1,94 @@
|
||||
import React from "react";
|
||||
|
||||
interface ErrorBoundaryProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
interface ErrorBoundaryState {
|
||||
hasError: boolean;
|
||||
message: string;
|
||||
}
|
||||
|
||||
// Catches render-time errors in the component tree so a crash shows a minimal
|
||||
// recovery surface instead of a silent white screen, and forwards the error to
|
||||
// the main process log. Kept deliberately dead-simple and state-independent: an
|
||||
// error inside the error path is how you get a second white screen or a loop.
|
||||
export class ErrorBoundary extends React.Component<ErrorBoundaryProps, ErrorBoundaryState> {
|
||||
constructor(props: ErrorBoundaryProps) {
|
||||
super(props);
|
||||
this.state = { hasError: false, message: "" };
|
||||
}
|
||||
|
||||
static getDerivedStateFromError(error: unknown): ErrorBoundaryState {
|
||||
return { hasError: true, message: error instanceof Error ? error.message : String(error) };
|
||||
}
|
||||
|
||||
componentDidCatch(error: unknown, info: React.ErrorInfo): void {
|
||||
try {
|
||||
window.rd?.reportRendererError({
|
||||
kind: "react",
|
||||
message: error instanceof Error ? error.message : String(error),
|
||||
stack: error instanceof Error ? error.stack : undefined,
|
||||
componentStack: info?.componentStack || undefined
|
||||
});
|
||||
} catch {
|
||||
}
|
||||
}
|
||||
|
||||
private handleReload = (): void => {
|
||||
window.location.reload();
|
||||
};
|
||||
|
||||
render(): React.ReactNode {
|
||||
if (!this.state.hasError) {
|
||||
return this.props.children;
|
||||
}
|
||||
const overlay: React.CSSProperties = {
|
||||
position: "fixed",
|
||||
inset: 0,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
gap: 16,
|
||||
padding: 32,
|
||||
background: "#070b14",
|
||||
color: "#e6edf6",
|
||||
fontFamily: "Segoe UI, system-ui, sans-serif",
|
||||
textAlign: "center"
|
||||
};
|
||||
const pre: React.CSSProperties = {
|
||||
maxWidth: 640,
|
||||
maxHeight: 200,
|
||||
overflow: "auto",
|
||||
padding: 12,
|
||||
background: "#0d1422",
|
||||
border: "1px solid #243049",
|
||||
borderRadius: 6,
|
||||
color: "#ff9a8c",
|
||||
fontSize: 12,
|
||||
whiteSpace: "pre-wrap",
|
||||
textAlign: "left"
|
||||
};
|
||||
const button: React.CSSProperties = {
|
||||
padding: "8px 20px",
|
||||
background: "#2d5cff",
|
||||
color: "#fff",
|
||||
border: "none",
|
||||
borderRadius: 6,
|
||||
cursor: "pointer",
|
||||
fontSize: 14
|
||||
};
|
||||
return (
|
||||
<div style={overlay}>
|
||||
<h1 style={{ margin: 0, fontSize: 20 }}>Die Oberfläche hat einen Fehler ausgelöst</h1>
|
||||
<p style={{ margin: 0, maxWidth: 560, color: "#9aa7bd" }}>
|
||||
Die Anzeige wurde gestoppt, um Datenverlust zu vermeiden. Die laufenden Downloads im
|
||||
Hintergrund sind nicht betroffen. Der Fehler wurde ins Log geschrieben.
|
||||
</p>
|
||||
<pre style={pre}>{this.state.message}</pre>
|
||||
<button type="button" style={button} onClick={this.handleReload}>Oberfläche neu laden</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
import React from "react";
|
||||
|
||||
interface ErrorBoundaryProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
interface ErrorBoundaryState {
|
||||
hasError: boolean;
|
||||
message: string;
|
||||
}
|
||||
|
||||
// Catches render-time errors in the component tree so a crash shows a minimal
|
||||
// recovery surface instead of a silent white screen, and forwards the error to
|
||||
// the main process log. Kept deliberately dead-simple and state-independent: an
|
||||
// error inside the error path is how you get a second white screen or a loop.
|
||||
export class ErrorBoundary extends React.Component<ErrorBoundaryProps, ErrorBoundaryState> {
|
||||
constructor(props: ErrorBoundaryProps) {
|
||||
super(props);
|
||||
this.state = { hasError: false, message: "" };
|
||||
}
|
||||
|
||||
static getDerivedStateFromError(error: unknown): ErrorBoundaryState {
|
||||
return { hasError: true, message: error instanceof Error ? error.message : String(error) };
|
||||
}
|
||||
|
||||
componentDidCatch(error: unknown, info: React.ErrorInfo): void {
|
||||
try {
|
||||
window.rd?.reportRendererError({
|
||||
kind: "react",
|
||||
message: error instanceof Error ? error.message : String(error),
|
||||
stack: error instanceof Error ? error.stack : undefined,
|
||||
componentStack: info?.componentStack || undefined
|
||||
});
|
||||
} catch {
|
||||
}
|
||||
}
|
||||
|
||||
private handleReload = (): void => {
|
||||
window.location.reload();
|
||||
};
|
||||
|
||||
render(): React.ReactNode {
|
||||
if (!this.state.hasError) {
|
||||
return this.props.children;
|
||||
}
|
||||
const overlay: React.CSSProperties = {
|
||||
position: "fixed",
|
||||
inset: 0,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
gap: 16,
|
||||
padding: 32,
|
||||
background: "#070b14",
|
||||
color: "#e6edf6",
|
||||
fontFamily: "Segoe UI, system-ui, sans-serif",
|
||||
textAlign: "center"
|
||||
};
|
||||
const pre: React.CSSProperties = {
|
||||
maxWidth: 640,
|
||||
maxHeight: 200,
|
||||
overflow: "auto",
|
||||
padding: 12,
|
||||
background: "#0d1422",
|
||||
border: "1px solid #243049",
|
||||
borderRadius: 6,
|
||||
color: "#ff9a8c",
|
||||
fontSize: 12,
|
||||
whiteSpace: "pre-wrap",
|
||||
textAlign: "left"
|
||||
};
|
||||
const button: React.CSSProperties = {
|
||||
padding: "8px 20px",
|
||||
background: "#2d5cff",
|
||||
color: "#fff",
|
||||
border: "none",
|
||||
borderRadius: 6,
|
||||
cursor: "pointer",
|
||||
fontSize: 14
|
||||
};
|
||||
return (
|
||||
<div style={overlay}>
|
||||
<h1 style={{ margin: 0, fontSize: 20 }}>Die Oberfläche hat einen Fehler ausgelöst</h1>
|
||||
<p style={{ margin: 0, maxWidth: 560, color: "#9aa7bd" }}>
|
||||
Die Anzeige wurde gestoppt, um Datenverlust zu vermeiden. Die laufenden Downloads im
|
||||
Hintergrund sind nicht betroffen. Der Fehler wurde ins Log geschrieben.
|
||||
</p>
|
||||
<pre style={pre}>{this.state.message}</pre>
|
||||
<button type="button" style={button} onClick={this.handleReload}>Oberfläche neu laden</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,12 +1,12 @@
|
||||
<!doctype html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Multi Debrid Downloader</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="./main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
<!doctype html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Multi Debrid Downloader</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="./main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@ -1,48 +1,48 @@
|
||||
import React from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { App } from "./App";
|
||||
import { ErrorBoundary } from "./error-boundary";
|
||||
import "./styles.css";
|
||||
|
||||
// Forward otherwise-silent renderer failures (uncaught errors, unhandled promise
|
||||
// rejections) to the main process log. Without this, a renderer crash leaves no
|
||||
// trace anywhere on an unattended server.
|
||||
function reportRendererError(report: Parameters<typeof window.rd.reportRendererError>[0]): void {
|
||||
try {
|
||||
window.rd?.reportRendererError(report);
|
||||
} catch {
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener("error", (event) => {
|
||||
reportRendererError({
|
||||
kind: "error",
|
||||
message: event.message || String(event.error || "Unbekannter Fehler"),
|
||||
stack: event.error instanceof Error ? event.error.stack : undefined,
|
||||
source: event.filename || undefined,
|
||||
line: typeof event.lineno === "number" ? event.lineno : undefined,
|
||||
column: typeof event.colno === "number" ? event.colno : undefined
|
||||
});
|
||||
});
|
||||
|
||||
window.addEventListener("unhandledrejection", (event) => {
|
||||
const reason = event.reason;
|
||||
reportRendererError({
|
||||
kind: "unhandledrejection",
|
||||
message: reason instanceof Error ? reason.message : String(reason),
|
||||
stack: reason instanceof Error ? reason.stack : undefined
|
||||
});
|
||||
});
|
||||
|
||||
const rootElement = document.getElementById("root");
|
||||
if (!rootElement) {
|
||||
throw new Error("Root element fehlt");
|
||||
}
|
||||
|
||||
createRoot(rootElement).render(
|
||||
<React.StrictMode>
|
||||
<ErrorBoundary>
|
||||
<App />
|
||||
</ErrorBoundary>
|
||||
</React.StrictMode>
|
||||
);
|
||||
import React from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { App } from "./App";
|
||||
import { ErrorBoundary } from "./error-boundary";
|
||||
import "./styles.css";
|
||||
|
||||
// Forward otherwise-silent renderer failures (uncaught errors, unhandled promise
|
||||
// rejections) to the main process log. Without this, a renderer crash leaves no
|
||||
// trace anywhere on an unattended server.
|
||||
function reportRendererError(report: Parameters<typeof window.rd.reportRendererError>[0]): void {
|
||||
try {
|
||||
window.rd?.reportRendererError(report);
|
||||
} catch {
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener("error", (event) => {
|
||||
reportRendererError({
|
||||
kind: "error",
|
||||
message: event.message || String(event.error || "Unbekannter Fehler"),
|
||||
stack: event.error instanceof Error ? event.error.stack : undefined,
|
||||
source: event.filename || undefined,
|
||||
line: typeof event.lineno === "number" ? event.lineno : undefined,
|
||||
column: typeof event.colno === "number" ? event.colno : undefined
|
||||
});
|
||||
});
|
||||
|
||||
window.addEventListener("unhandledrejection", (event) => {
|
||||
const reason = event.reason;
|
||||
reportRendererError({
|
||||
kind: "unhandledrejection",
|
||||
message: reason instanceof Error ? reason.message : String(reason),
|
||||
stack: reason instanceof Error ? reason.stack : undefined
|
||||
});
|
||||
});
|
||||
|
||||
const rootElement = document.getElementById("root");
|
||||
if (!rootElement) {
|
||||
throw new Error("Root element fehlt");
|
||||
}
|
||||
|
||||
createRoot(rootElement).render(
|
||||
<React.StrictMode>
|
||||
<ErrorBoundary>
|
||||
<App />
|
||||
</ErrorBoundary>
|
||||
</React.StrictMode>
|
||||
);
|
||||
|
||||
@ -1,61 +1,61 @@
|
||||
import type { DownloadItem, DownloadStatus, PackageEntry } from "../shared/types";
|
||||
|
||||
const ACTIVE_PACKAGE_STATUSES = new Set<DownloadStatus>(["downloading", "validating", "integrity_check", "extracting"]);
|
||||
|
||||
export function reorderPackageOrderByDrop(order: string[], draggedPackageId: string, targetPackageId: string): string[] {
|
||||
const fromIndex = order.indexOf(draggedPackageId);
|
||||
const toIndex = order.indexOf(targetPackageId);
|
||||
if (fromIndex < 0 || toIndex < 0 || fromIndex === toIndex) {
|
||||
return order;
|
||||
}
|
||||
const next = [...order];
|
||||
const [dragged] = next.splice(fromIndex, 1);
|
||||
const insertIndex = Math.max(0, Math.min(next.length, toIndex));
|
||||
next.splice(insertIndex, 0, dragged);
|
||||
return next;
|
||||
}
|
||||
|
||||
export function sortPackageOrderByName(order: string[], packages: Record<string, PackageEntry>, descending: boolean): string[] {
|
||||
const sorted = [...order];
|
||||
sorted.sort((a, b) => {
|
||||
const nameA = (packages[a]?.name ?? "").toLowerCase();
|
||||
const nameB = (packages[b]?.name ?? "").toLowerCase();
|
||||
const cmp = nameA.localeCompare(nameB, undefined, { numeric: true, sensitivity: "base" });
|
||||
return descending ? -cmp : cmp;
|
||||
});
|
||||
return sorted;
|
||||
}
|
||||
|
||||
export function sortPackagesForDisplay(
|
||||
packages: PackageEntry[],
|
||||
itemsById: Record<string, DownloadItem>,
|
||||
running: boolean,
|
||||
autoSortPackagesByProgress: boolean
|
||||
): PackageEntry[] {
|
||||
if (!running || !autoSortPackagesByProgress || packages.length <= 1) {
|
||||
return packages;
|
||||
}
|
||||
|
||||
const active: PackageEntry[] = [];
|
||||
const rest: PackageEntry[] = [];
|
||||
|
||||
// Float packages that have an active item to the top, but keep BOTH groups in
|
||||
// their original (queue) order. Earlier this sorted the active group by live
|
||||
// completedRatio/downloadedBytes — which change on every progress tick (every
|
||||
// 150-700ms), so active packages visibly reshuffled the whole time. A package
|
||||
// entering/leaving the active bucket is a real, discrete event (start/finish);
|
||||
// ranking *within* the bucket by live bytes was pure jitter nobody needs.
|
||||
for (const pkg of packages) {
|
||||
const hasActive = pkg.itemIds.some((id) => {
|
||||
const item = itemsById[id];
|
||||
return item != null && ACTIVE_PACKAGE_STATUSES.has(item.status);
|
||||
});
|
||||
(hasActive ? active : rest).push(pkg);
|
||||
}
|
||||
|
||||
if (active.length === 0 || active.length === packages.length) {
|
||||
return packages;
|
||||
}
|
||||
|
||||
return [...active, ...rest];
|
||||
}
|
||||
import type { DownloadItem, DownloadStatus, PackageEntry } from "../shared/types";
|
||||
|
||||
const ACTIVE_PACKAGE_STATUSES = new Set<DownloadStatus>(["downloading", "validating", "integrity_check", "extracting"]);
|
||||
|
||||
export function reorderPackageOrderByDrop(order: string[], draggedPackageId: string, targetPackageId: string): string[] {
|
||||
const fromIndex = order.indexOf(draggedPackageId);
|
||||
const toIndex = order.indexOf(targetPackageId);
|
||||
if (fromIndex < 0 || toIndex < 0 || fromIndex === toIndex) {
|
||||
return order;
|
||||
}
|
||||
const next = [...order];
|
||||
const [dragged] = next.splice(fromIndex, 1);
|
||||
const insertIndex = Math.max(0, Math.min(next.length, toIndex));
|
||||
next.splice(insertIndex, 0, dragged);
|
||||
return next;
|
||||
}
|
||||
|
||||
export function sortPackageOrderByName(order: string[], packages: Record<string, PackageEntry>, descending: boolean): string[] {
|
||||
const sorted = [...order];
|
||||
sorted.sort((a, b) => {
|
||||
const nameA = (packages[a]?.name ?? "").toLowerCase();
|
||||
const nameB = (packages[b]?.name ?? "").toLowerCase();
|
||||
const cmp = nameA.localeCompare(nameB, undefined, { numeric: true, sensitivity: "base" });
|
||||
return descending ? -cmp : cmp;
|
||||
});
|
||||
return sorted;
|
||||
}
|
||||
|
||||
export function sortPackagesForDisplay(
|
||||
packages: PackageEntry[],
|
||||
itemsById: Record<string, DownloadItem>,
|
||||
running: boolean,
|
||||
autoSortPackagesByProgress: boolean
|
||||
): PackageEntry[] {
|
||||
if (!running || !autoSortPackagesByProgress || packages.length <= 1) {
|
||||
return packages;
|
||||
}
|
||||
|
||||
const active: PackageEntry[] = [];
|
||||
const rest: PackageEntry[] = [];
|
||||
|
||||
// Float packages that have an active item to the top, but keep BOTH groups in
|
||||
// their original (queue) order. Earlier this sorted the active group by live
|
||||
// completedRatio/downloadedBytes — which change on every progress tick (every
|
||||
// 150-700ms), so active packages visibly reshuffled the whole time. A package
|
||||
// entering/leaving the active bucket is a real, discrete event (start/finish);
|
||||
// ranking *within* the bucket by live bytes was pure jitter nobody needs.
|
||||
for (const pkg of packages) {
|
||||
const hasActive = pkg.itemIds.some((id) => {
|
||||
const item = itemsById[id];
|
||||
return item != null && ACTIVE_PACKAGE_STATUSES.has(item.status);
|
||||
});
|
||||
(hasActive ? active : rest).push(pkg);
|
||||
}
|
||||
|
||||
if (active.length === 0 || active.length === packages.length) {
|
||||
return packages;
|
||||
}
|
||||
|
||||
return [...active, ...rest];
|
||||
}
|
||||
|
||||
@ -1,27 +1,27 @@
|
||||
import type { SessionState } from "../shared/types";
|
||||
|
||||
/**
|
||||
* Drop selected ids whose package OR item no longer exists in the session.
|
||||
* The selection set mixes package and item ids; when entries vanish (delta
|
||||
* removal, backup-driven session swap, completed-cleanup) a stale id would
|
||||
* otherwise inflate the selection count and the "(N)" action labels and keep
|
||||
* "multi" styling alive for ghosts.
|
||||
*
|
||||
* Returns the SAME set instance when nothing changed, so callers can use it
|
||||
* directly as a React state updater without forcing a re-render.
|
||||
*/
|
||||
export function pruneSelection(
|
||||
selected: ReadonlySet<string>,
|
||||
session: Pick<SessionState, "packages" | "items">
|
||||
): Set<string> {
|
||||
if (selected.size === 0) {
|
||||
return selected as Set<string>;
|
||||
}
|
||||
const next = new Set<string>();
|
||||
for (const id of selected) {
|
||||
if (session.packages[id] || session.items[id]) {
|
||||
next.add(id);
|
||||
}
|
||||
}
|
||||
return next.size === selected.size ? (selected as Set<string>) : next;
|
||||
}
|
||||
import type { SessionState } from "../shared/types";
|
||||
|
||||
/**
|
||||
* Drop selected ids whose package OR item no longer exists in the session.
|
||||
* The selection set mixes package and item ids; when entries vanish (delta
|
||||
* removal, backup-driven session swap, completed-cleanup) a stale id would
|
||||
* otherwise inflate the selection count and the "(N)" action labels and keep
|
||||
* "multi" styling alive for ghosts.
|
||||
*
|
||||
* Returns the SAME set instance when nothing changed, so callers can use it
|
||||
* directly as a React state updater without forcing a re-render.
|
||||
*/
|
||||
export function pruneSelection(
|
||||
selected: ReadonlySet<string>,
|
||||
session: Pick<SessionState, "packages" | "items">
|
||||
): Set<string> {
|
||||
if (selected.size === 0) {
|
||||
return selected as Set<string>;
|
||||
}
|
||||
const next = new Set<string>();
|
||||
for (const id of selected) {
|
||||
if (session.packages[id] || session.items[id]) {
|
||||
next.add(id);
|
||||
}
|
||||
}
|
||||
return next.size === selected.size ? (selected as Set<string>) : next;
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
18
src/renderer/vite-env.d.ts
vendored
18
src/renderer/vite-env.d.ts
vendored
@ -1,9 +1,9 @@
|
||||
import type { ElectronApi } from "../shared/preload-api";
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
rd: ElectronApi;
|
||||
}
|
||||
}
|
||||
|
||||
export {};
|
||||
import type { ElectronApi } from "../shared/preload-api";
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
rd: ElectronApi;
|
||||
}
|
||||
}
|
||||
|
||||
export {};
|
||||
|
||||
@ -1,66 +1,66 @@
|
||||
export interface DebridLinkApiKeyEntry {
|
||||
id: string;
|
||||
token: string;
|
||||
index: number;
|
||||
label: string;
|
||||
masked: string;
|
||||
}
|
||||
|
||||
const FNV64_OFFSET_BASIS = 0xcbf29ce484222325n;
|
||||
const FNV64_PRIME = 0x100000001b3n;
|
||||
const FNV64_MASK = 0xffffffffffffffffn;
|
||||
|
||||
function fnv1a64(text: string): string {
|
||||
let hash = FNV64_OFFSET_BASIS;
|
||||
for (const char of text) {
|
||||
hash ^= BigInt(char.codePointAt(0) || 0);
|
||||
hash = (hash * FNV64_PRIME) & FNV64_MASK;
|
||||
}
|
||||
return hash.toString(36);
|
||||
}
|
||||
|
||||
export function maskDebridLinkApiKey(token: string): string {
|
||||
const trimmed = token.trim();
|
||||
if (!trimmed) {
|
||||
return "Nicht hinterlegt";
|
||||
}
|
||||
if (trimmed.length <= 6) {
|
||||
return "*".repeat(trimmed.length);
|
||||
}
|
||||
return `${trimmed.slice(0, 3)}${"*".repeat(Math.max(4, trimmed.length - 6))}${trimmed.slice(-3)}`;
|
||||
}
|
||||
|
||||
export function getDebridLinkApiKeyId(token: string): string {
|
||||
return `dlk_${fnv1a64(token.trim())}`;
|
||||
}
|
||||
|
||||
export function getDebridLinkApiKeyLabel(index: number): string {
|
||||
return `Key ${index + 1}`;
|
||||
}
|
||||
|
||||
export function parseDebridLinkApiKeys(raw: string): DebridLinkApiKeyEntry[] {
|
||||
const seen = new Set<string>();
|
||||
const tokens = String(raw || "")
|
||||
.split(/[\n,]+/)
|
||||
.map((entry) => entry.trim())
|
||||
.filter(Boolean)
|
||||
.filter((token) => {
|
||||
if (seen.has(token)) {
|
||||
return false;
|
||||
}
|
||||
seen.add(token);
|
||||
return true;
|
||||
});
|
||||
|
||||
return tokens.map((token, index) => ({
|
||||
id: getDebridLinkApiKeyId(token),
|
||||
token,
|
||||
index,
|
||||
label: getDebridLinkApiKeyLabel(index),
|
||||
masked: maskDebridLinkApiKey(token)
|
||||
}));
|
||||
}
|
||||
|
||||
export function getDebridLinkApiKeyIds(raw: string): string[] {
|
||||
return parseDebridLinkApiKeys(raw).map((entry) => entry.id);
|
||||
}
|
||||
export interface DebridLinkApiKeyEntry {
|
||||
id: string;
|
||||
token: string;
|
||||
index: number;
|
||||
label: string;
|
||||
masked: string;
|
||||
}
|
||||
|
||||
const FNV64_OFFSET_BASIS = 0xcbf29ce484222325n;
|
||||
const FNV64_PRIME = 0x100000001b3n;
|
||||
const FNV64_MASK = 0xffffffffffffffffn;
|
||||
|
||||
function fnv1a64(text: string): string {
|
||||
let hash = FNV64_OFFSET_BASIS;
|
||||
for (const char of text) {
|
||||
hash ^= BigInt(char.codePointAt(0) || 0);
|
||||
hash = (hash * FNV64_PRIME) & FNV64_MASK;
|
||||
}
|
||||
return hash.toString(36);
|
||||
}
|
||||
|
||||
export function maskDebridLinkApiKey(token: string): string {
|
||||
const trimmed = token.trim();
|
||||
if (!trimmed) {
|
||||
return "Nicht hinterlegt";
|
||||
}
|
||||
if (trimmed.length <= 6) {
|
||||
return "*".repeat(trimmed.length);
|
||||
}
|
||||
return `${trimmed.slice(0, 3)}${"*".repeat(Math.max(4, trimmed.length - 6))}${trimmed.slice(-3)}`;
|
||||
}
|
||||
|
||||
export function getDebridLinkApiKeyId(token: string): string {
|
||||
return `dlk_${fnv1a64(token.trim())}`;
|
||||
}
|
||||
|
||||
export function getDebridLinkApiKeyLabel(index: number): string {
|
||||
return `Key ${index + 1}`;
|
||||
}
|
||||
|
||||
export function parseDebridLinkApiKeys(raw: string): DebridLinkApiKeyEntry[] {
|
||||
const seen = new Set<string>();
|
||||
const tokens = String(raw || "")
|
||||
.split(/[\n,]+/)
|
||||
.map((entry) => entry.trim())
|
||||
.filter(Boolean)
|
||||
.filter((token) => {
|
||||
if (seen.has(token)) {
|
||||
return false;
|
||||
}
|
||||
seen.add(token);
|
||||
return true;
|
||||
});
|
||||
|
||||
return tokens.map((token, index) => ({
|
||||
id: getDebridLinkApiKeyId(token),
|
||||
token,
|
||||
index,
|
||||
label: getDebridLinkApiKeyLabel(index),
|
||||
masked: maskDebridLinkApiKey(token)
|
||||
}));
|
||||
}
|
||||
|
||||
export function getDebridLinkApiKeyIds(raw: string): string[] {
|
||||
return parseDebridLinkApiKeys(raw).map((entry) => entry.id);
|
||||
}
|
||||
|
||||
@ -1,79 +1,77 @@
|
||||
export const IPC_CHANNELS = {
|
||||
GET_SNAPSHOT: "app:get-snapshot",
|
||||
GET_VERSION: "app:get-version",
|
||||
CHECK_UPDATES: "app:check-updates",
|
||||
INSTALL_UPDATE: "app:install-update",
|
||||
UPDATE_INSTALL_PROGRESS: "app:update-install-progress",
|
||||
OPEN_EXTERNAL: "app:open-external",
|
||||
UPDATE_SETTINGS: "app:update-settings",
|
||||
RESET_PROVIDER_DAILY_USAGE: "app:reset-provider-daily-usage",
|
||||
RESET_DEBRID_LINK_API_KEY_DAILY_USAGE: "app:reset-debrid-link-api-key-daily-usage",
|
||||
ADD_LINKS: "queue:add-links",
|
||||
ADD_CONTAINERS: "queue:add-containers",
|
||||
GET_START_CONFLICTS: "queue:get-start-conflicts",
|
||||
RESOLVE_START_CONFLICT: "queue:resolve-start-conflict",
|
||||
CLEAR_ALL: "queue:clear-all",
|
||||
START: "queue:start",
|
||||
START_PACKAGES: "queue:start-packages",
|
||||
STOP: "queue:stop",
|
||||
TOGGLE_PAUSE: "queue:toggle-pause",
|
||||
CANCEL_PACKAGE: "queue:cancel-package",
|
||||
RENAME_PACKAGE: "queue:rename-package",
|
||||
REORDER_PACKAGES: "queue:reorder-packages",
|
||||
REMOVE_ITEM: "queue:remove-item",
|
||||
TOGGLE_PACKAGE: "queue:toggle-package",
|
||||
EXPORT_PACKAGE_SELECTION: "queue:export-package-selection",
|
||||
EXPORT_ITEM_SELECTION: "queue:export-item-selection",
|
||||
EXPORT_QUEUE: "queue:export",
|
||||
IMPORT_QUEUE: "queue:import",
|
||||
PICK_FOLDER: "dialog:pick-folder",
|
||||
PICK_CONTAINERS: "dialog:pick-containers",
|
||||
STATE_UPDATE: "state:update",
|
||||
CLIPBOARD_DETECTED: "clipboard:detected",
|
||||
TOGGLE_CLIPBOARD: "clipboard:toggle",
|
||||
GET_SESSION_STATS: "stats:get-session-stats",
|
||||
RESET_SESSION_STATS: "stats:reset-session",
|
||||
RESET_DOWNLOAD_STATS: "stats:reset-download",
|
||||
RESTART: "app:restart",
|
||||
QUIT: "app:quit",
|
||||
export const IPC_CHANNELS = {
|
||||
GET_SNAPSHOT: "app:get-snapshot",
|
||||
GET_VERSION: "app:get-version",
|
||||
CHECK_UPDATES: "app:check-updates",
|
||||
INSTALL_UPDATE: "app:install-update",
|
||||
UPDATE_INSTALL_PROGRESS: "app:update-install-progress",
|
||||
OPEN_EXTERNAL: "app:open-external",
|
||||
UPDATE_SETTINGS: "app:update-settings",
|
||||
RESET_PROVIDER_DAILY_USAGE: "app:reset-provider-daily-usage",
|
||||
RESET_DEBRID_LINK_API_KEY_DAILY_USAGE: "app:reset-debrid-link-api-key-daily-usage",
|
||||
ADD_LINKS: "queue:add-links",
|
||||
ADD_CONTAINERS: "queue:add-containers",
|
||||
GET_START_CONFLICTS: "queue:get-start-conflicts",
|
||||
RESOLVE_START_CONFLICT: "queue:resolve-start-conflict",
|
||||
CLEAR_ALL: "queue:clear-all",
|
||||
START: "queue:start",
|
||||
START_PACKAGES: "queue:start-packages",
|
||||
STOP: "queue:stop",
|
||||
TOGGLE_PAUSE: "queue:toggle-pause",
|
||||
CANCEL_PACKAGE: "queue:cancel-package",
|
||||
RENAME_PACKAGE: "queue:rename-package",
|
||||
REORDER_PACKAGES: "queue:reorder-packages",
|
||||
REMOVE_ITEM: "queue:remove-item",
|
||||
TOGGLE_PACKAGE: "queue:toggle-package",
|
||||
EXPORT_PACKAGE_SELECTION: "queue:export-package-selection",
|
||||
EXPORT_ITEM_SELECTION: "queue:export-item-selection",
|
||||
EXPORT_QUEUE: "queue:export",
|
||||
IMPORT_QUEUE: "queue:import",
|
||||
PICK_FOLDER: "dialog:pick-folder",
|
||||
PICK_CONTAINERS: "dialog:pick-containers",
|
||||
STATE_UPDATE: "state:update",
|
||||
CLIPBOARD_DETECTED: "clipboard:detected",
|
||||
TOGGLE_CLIPBOARD: "clipboard:toggle",
|
||||
GET_SESSION_STATS: "stats:get-session-stats",
|
||||
RESET_SESSION_STATS: "stats:reset-session",
|
||||
RESET_DOWNLOAD_STATS: "stats:reset-download",
|
||||
RESTART: "app:restart",
|
||||
QUIT: "app:quit",
|
||||
EXPORT_BACKUP: "app:export-backup",
|
||||
IMPORT_BACKUP: "app:import-backup",
|
||||
EXPORT_ONLINE_BACKUP: "app:export-online-backup",
|
||||
IMPORT_ONLINE_BACKUP: "app:import-online-backup",
|
||||
EXPORT_SUPPORT_BUNDLE: "app:export-support-bundle",
|
||||
OPEN_LOG: "app:open-log",
|
||||
OPEN_AUDIT_LOG: "app:open-audit-log",
|
||||
OPEN_RENAME_LOG: "app:open-rename-log",
|
||||
OPEN_SESSION_LOG: "app:open-session-log",
|
||||
OPEN_TRACE_LOG: "app:open-trace-log",
|
||||
OPEN_PACKAGE_LOG: "app:open-package-log",
|
||||
OPEN_ITEM_LOG: "app:open-item-log",
|
||||
GET_DEBUG_SETUP_CHECK: "app:get-debug-setup-check",
|
||||
GET_RECENT_ERRORS: "app:get-recent-errors",
|
||||
TEST_NOTIFY: "app:test-notify",
|
||||
GET_TRACE_CONFIG: "app:get-trace-config",
|
||||
SET_TRACE_ENABLED: "app:set-trace-enabled",
|
||||
ROTATE_DEBUG_TOKEN: "app:rotate-debug-token",
|
||||
GET_REMOTE_DIAGNOSTICS: "app:get-remote-diagnostics",
|
||||
ENABLE_REMOTE_DIAGNOSTICS: "app:enable-remote-diagnostics",
|
||||
DISABLE_REMOTE_DIAGNOSTICS: "app:disable-remote-diagnostics",
|
||||
ROTATE_REMOTE_DIAGNOSTICS_TOKEN: "app:rotate-remote-diagnostics-token",
|
||||
OPEN_REALDEBRID_LOGIN: "app:open-realdebrid-login",
|
||||
OPEN_ALLDEBRID_LOGIN: "app:open-alldebrid-login",
|
||||
IMPORT_BESTDEBRID_COOKIES: "app:import-bestdebrid-cookies",
|
||||
GET_ALLDEBRID_HOST_INFO: "app:get-alldebrid-host-info",
|
||||
GET_DEBRIDLINK_HOST_LIMITS: "app:get-debridlink-host-limits",
|
||||
CHECK_DEBRID_ACCOUNTS: "app:check-debrid-accounts",
|
||||
CHECK_MEGA_DEBRID_ACCOUNT: "app:check-mega-debrid-account",
|
||||
RETRY_EXTRACTION: "queue:retry-extraction",
|
||||
EXTRACT_NOW: "queue:extract-now",
|
||||
RESET_PACKAGE: "queue:reset-package",
|
||||
GET_HISTORY: "history:get",
|
||||
CLEAR_HISTORY: "history:clear",
|
||||
REMOVE_HISTORY_ENTRY: "history:remove-entry",
|
||||
SET_PACKAGE_PRIORITY: "queue:set-package-priority",
|
||||
SKIP_ITEMS: "queue:skip-items",
|
||||
RESET_ITEMS: "queue:reset-items",
|
||||
START_ITEMS: "queue:start-items",
|
||||
LOG_RENDERER_ERROR: "log:renderer-error"
|
||||
} as const;
|
||||
EXPORT_SUPPORT_BUNDLE: "app:export-support-bundle",
|
||||
OPEN_LOG: "app:open-log",
|
||||
OPEN_AUDIT_LOG: "app:open-audit-log",
|
||||
OPEN_RENAME_LOG: "app:open-rename-log",
|
||||
OPEN_SESSION_LOG: "app:open-session-log",
|
||||
OPEN_TRACE_LOG: "app:open-trace-log",
|
||||
OPEN_PACKAGE_LOG: "app:open-package-log",
|
||||
OPEN_ITEM_LOG: "app:open-item-log",
|
||||
GET_DEBUG_SETUP_CHECK: "app:get-debug-setup-check",
|
||||
GET_RECENT_ERRORS: "app:get-recent-errors",
|
||||
TEST_NOTIFY: "app:test-notify",
|
||||
GET_TRACE_CONFIG: "app:get-trace-config",
|
||||
SET_TRACE_ENABLED: "app:set-trace-enabled",
|
||||
ROTATE_DEBUG_TOKEN: "app:rotate-debug-token",
|
||||
GET_REMOTE_DIAGNOSTICS: "app:get-remote-diagnostics",
|
||||
ENABLE_REMOTE_DIAGNOSTICS: "app:enable-remote-diagnostics",
|
||||
DISABLE_REMOTE_DIAGNOSTICS: "app:disable-remote-diagnostics",
|
||||
ROTATE_REMOTE_DIAGNOSTICS_TOKEN: "app:rotate-remote-diagnostics-token",
|
||||
OPEN_REALDEBRID_LOGIN: "app:open-realdebrid-login",
|
||||
OPEN_ALLDEBRID_LOGIN: "app:open-alldebrid-login",
|
||||
IMPORT_BESTDEBRID_COOKIES: "app:import-bestdebrid-cookies",
|
||||
GET_ALLDEBRID_HOST_INFO: "app:get-alldebrid-host-info",
|
||||
GET_DEBRIDLINK_HOST_LIMITS: "app:get-debridlink-host-limits",
|
||||
CHECK_DEBRID_ACCOUNTS: "app:check-debrid-accounts",
|
||||
CHECK_MEGA_DEBRID_ACCOUNT: "app:check-mega-debrid-account",
|
||||
RETRY_EXTRACTION: "queue:retry-extraction",
|
||||
EXTRACT_NOW: "queue:extract-now",
|
||||
RESET_PACKAGE: "queue:reset-package",
|
||||
GET_HISTORY: "history:get",
|
||||
CLEAR_HISTORY: "history:clear",
|
||||
REMOVE_HISTORY_ENTRY: "history:remove-entry",
|
||||
SET_PACKAGE_PRIORITY: "queue:set-package-priority",
|
||||
SKIP_ITEMS: "queue:skip-items",
|
||||
RESET_ITEMS: "queue:reset-items",
|
||||
START_ITEMS: "queue:start-items",
|
||||
LOG_RENDERER_ERROR: "log:renderer-error"
|
||||
} as const;
|
||||
|
||||
@ -1,90 +1,90 @@
|
||||
export interface MegaDebridAccountEntry {
|
||||
id: string;
|
||||
login: string;
|
||||
password: string;
|
||||
index: number;
|
||||
label: string;
|
||||
maskedLogin: string;
|
||||
}
|
||||
|
||||
const FNV64_OFFSET_BASIS = 0xcbf29ce484222325n;
|
||||
const FNV64_PRIME = 0x100000001b3n;
|
||||
const FNV64_MASK = 0xffffffffffffffffn;
|
||||
|
||||
function fnv1a64(text: string): string {
|
||||
let hash = FNV64_OFFSET_BASIS;
|
||||
for (const char of text) {
|
||||
hash ^= BigInt(char.codePointAt(0) || 0);
|
||||
hash = (hash * FNV64_PRIME) & FNV64_MASK;
|
||||
}
|
||||
return hash.toString(36);
|
||||
}
|
||||
|
||||
export function getMegaDebridAccountId(login: string): string {
|
||||
return `mda_${fnv1a64(login.trim().toLowerCase())}`;
|
||||
}
|
||||
|
||||
export function maskMegaDebridLogin(login: string): string {
|
||||
const trimmed = login.trim();
|
||||
if (!trimmed) {
|
||||
return "Nicht hinterlegt";
|
||||
}
|
||||
if (trimmed.length <= 4) {
|
||||
return `${trimmed[0]}${"*".repeat(trimmed.length - 1)}`;
|
||||
}
|
||||
return `${trimmed.slice(0, 2)}${"*".repeat(Math.max(3, trimmed.length - 4))}${trimmed.slice(-2)}`;
|
||||
}
|
||||
|
||||
export function getMegaDebridAccountLabel(index: number): string {
|
||||
return `Account ${index + 1}`;
|
||||
}
|
||||
|
||||
export function parseMegaDebridAccounts(raw: string, legacyPassword = ""): MegaDebridAccountEntry[] {
|
||||
const seen = new Set<string>();
|
||||
const lines = String(raw || "")
|
||||
.split(/\n+/)
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
const entries: MegaDebridAccountEntry[] = [];
|
||||
for (const line of lines) {
|
||||
const colonIdx = line.indexOf(":");
|
||||
let login: string;
|
||||
let password: string;
|
||||
if (colonIdx >= 0) {
|
||||
login = line.slice(0, colonIdx).trim();
|
||||
password = line.slice(colonIdx + 1).trim();
|
||||
} else {
|
||||
login = line;
|
||||
password = legacyPassword;
|
||||
}
|
||||
if (!login || !password) {
|
||||
continue;
|
||||
}
|
||||
const key = login.toLowerCase();
|
||||
if (seen.has(key)) {
|
||||
continue;
|
||||
}
|
||||
seen.add(key);
|
||||
entries.push({
|
||||
id: getMegaDebridAccountId(login),
|
||||
login,
|
||||
password,
|
||||
index: entries.length,
|
||||
label: getMegaDebridAccountLabel(entries.length),
|
||||
maskedLogin: maskMegaDebridLogin(login)
|
||||
});
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
export function serializeMegaDebridAccounts(accounts: { login: string; password: string }[]): string {
|
||||
return accounts
|
||||
.filter((a) => a.login.trim() && a.password.trim())
|
||||
.map((a) => `${a.login.trim()}:${a.password.trim()}`)
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
export function getMegaDebridAccountIds(raw: string, legacyPassword = ""): string[] {
|
||||
return parseMegaDebridAccounts(raw, legacyPassword).map((entry) => entry.id);
|
||||
}
|
||||
export interface MegaDebridAccountEntry {
|
||||
id: string;
|
||||
login: string;
|
||||
password: string;
|
||||
index: number;
|
||||
label: string;
|
||||
maskedLogin: string;
|
||||
}
|
||||
|
||||
const FNV64_OFFSET_BASIS = 0xcbf29ce484222325n;
|
||||
const FNV64_PRIME = 0x100000001b3n;
|
||||
const FNV64_MASK = 0xffffffffffffffffn;
|
||||
|
||||
function fnv1a64(text: string): string {
|
||||
let hash = FNV64_OFFSET_BASIS;
|
||||
for (const char of text) {
|
||||
hash ^= BigInt(char.codePointAt(0) || 0);
|
||||
hash = (hash * FNV64_PRIME) & FNV64_MASK;
|
||||
}
|
||||
return hash.toString(36);
|
||||
}
|
||||
|
||||
export function getMegaDebridAccountId(login: string): string {
|
||||
return `mda_${fnv1a64(login.trim().toLowerCase())}`;
|
||||
}
|
||||
|
||||
export function maskMegaDebridLogin(login: string): string {
|
||||
const trimmed = login.trim();
|
||||
if (!trimmed) {
|
||||
return "Nicht hinterlegt";
|
||||
}
|
||||
if (trimmed.length <= 4) {
|
||||
return `${trimmed[0]}${"*".repeat(trimmed.length - 1)}`;
|
||||
}
|
||||
return `${trimmed.slice(0, 2)}${"*".repeat(Math.max(3, trimmed.length - 4))}${trimmed.slice(-2)}`;
|
||||
}
|
||||
|
||||
export function getMegaDebridAccountLabel(index: number): string {
|
||||
return `Account ${index + 1}`;
|
||||
}
|
||||
|
||||
export function parseMegaDebridAccounts(raw: string, legacyPassword = ""): MegaDebridAccountEntry[] {
|
||||
const seen = new Set<string>();
|
||||
const lines = String(raw || "")
|
||||
.split(/\n+/)
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
const entries: MegaDebridAccountEntry[] = [];
|
||||
for (const line of lines) {
|
||||
const colonIdx = line.indexOf(":");
|
||||
let login: string;
|
||||
let password: string;
|
||||
if (colonIdx >= 0) {
|
||||
login = line.slice(0, colonIdx).trim();
|
||||
password = line.slice(colonIdx + 1).trim();
|
||||
} else {
|
||||
login = line;
|
||||
password = legacyPassword;
|
||||
}
|
||||
if (!login || !password) {
|
||||
continue;
|
||||
}
|
||||
const key = login.toLowerCase();
|
||||
if (seen.has(key)) {
|
||||
continue;
|
||||
}
|
||||
seen.add(key);
|
||||
entries.push({
|
||||
id: getMegaDebridAccountId(login),
|
||||
login,
|
||||
password,
|
||||
index: entries.length,
|
||||
label: getMegaDebridAccountLabel(entries.length),
|
||||
maskedLogin: maskMegaDebridLogin(login)
|
||||
});
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
export function serializeMegaDebridAccounts(accounts: { login: string; password: string }[]): string {
|
||||
return accounts
|
||||
.filter((a) => a.login.trim() && a.password.trim())
|
||||
.map((a) => `${a.login.trim()}:${a.password.trim()}`)
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
export function getMegaDebridAccountIds(raw: string, legacyPassword = ""): string[] {
|
||||
return parseMegaDebridAccounts(raw, legacyPassword).map((entry) => entry.id);
|
||||
}
|
||||
|
||||
@ -1,24 +1,24 @@
|
||||
export function isMegaDebridResolveFailure(errorText: string): boolean {
|
||||
const text = String(errorText || "").toLowerCase();
|
||||
return /supprim/.test(text)
|
||||
|| text.includes("introuvable")
|
||||
|| text.includes("n'existe plus")
|
||||
|| text.includes("n existe plus")
|
||||
|| text.includes("fichier inexistant");
|
||||
}
|
||||
|
||||
export function isMegaDebridTransientResolveFailure(errorText: string): boolean {
|
||||
const text = String(errorText || "").toLowerCase();
|
||||
return isMegaDebridResolveFailure(text)
|
||||
|| text.includes("datei beim hoster gerade nicht abrufbar")
|
||||
|| text.includes("datei beim hoster nicht gefunden");
|
||||
}
|
||||
|
||||
export function germanMegaDebridResolveReason(errorText: string): string {
|
||||
const text = String(errorText || "").toLowerCase();
|
||||
if (text.includes("datei beim hoster nicht gefunden")
|
||||
|| text.includes("introuvable") || text.includes("fichier inexistant") || text.includes("n'existe plus") || text.includes("n existe plus")) {
|
||||
return "Datei beim Hoster nicht gefunden";
|
||||
}
|
||||
return "Datei beim Hoster gerade nicht abrufbar";
|
||||
}
|
||||
export function isMegaDebridResolveFailure(errorText: string): boolean {
|
||||
const text = String(errorText || "").toLowerCase();
|
||||
return /supprim/.test(text)
|
||||
|| text.includes("introuvable")
|
||||
|| text.includes("n'existe plus")
|
||||
|| text.includes("n existe plus")
|
||||
|| text.includes("fichier inexistant");
|
||||
}
|
||||
|
||||
export function isMegaDebridTransientResolveFailure(errorText: string): boolean {
|
||||
const text = String(errorText || "").toLowerCase();
|
||||
return isMegaDebridResolveFailure(text)
|
||||
|| text.includes("datei beim hoster gerade nicht abrufbar")
|
||||
|| text.includes("datei beim hoster nicht gefunden");
|
||||
}
|
||||
|
||||
export function germanMegaDebridResolveReason(errorText: string): string {
|
||||
const text = String(errorText || "").toLowerCase();
|
||||
if (text.includes("datei beim hoster nicht gefunden")
|
||||
|| text.includes("introuvable") || text.includes("fichier inexistant") || text.includes("n'existe plus") || text.includes("n existe plus")) {
|
||||
return "Datei beim Hoster nicht gefunden";
|
||||
}
|
||||
return "Datei beim Hoster gerade nicht abrufbar";
|
||||
}
|
||||
|
||||
@ -1,103 +1,101 @@
|
||||
import type {
|
||||
AddLinksPayload,
|
||||
AllDebridHostInfo,
|
||||
AppSettings,
|
||||
DebridAccountStatus,
|
||||
DebugSetupCheckResult,
|
||||
DebridLinkHostLimitInfo,
|
||||
DebridProvider,
|
||||
DuplicatePolicy,
|
||||
EnableRemoteDiagnosticsInput,
|
||||
HistoryEntry,
|
||||
PackagePriority,
|
||||
RemoteDiagnosticsInfo,
|
||||
RendererErrorReport,
|
||||
SessionStats,
|
||||
StartConflictEntry,
|
||||
StartConflictResolutionResult,
|
||||
SupportTraceConfig,
|
||||
UiSnapshot,
|
||||
UpdateCheckResult,
|
||||
UpdateInstallProgress,
|
||||
UpdateInstallResult
|
||||
} from "./types";
|
||||
|
||||
export interface ElectronApi {
|
||||
getSnapshot: () => Promise<UiSnapshot>;
|
||||
getVersion: () => Promise<string>;
|
||||
checkUpdates: () => Promise<UpdateCheckResult>;
|
||||
installUpdate: () => Promise<UpdateInstallResult>;
|
||||
openExternal: (url: string) => Promise<boolean>;
|
||||
updateSettings: (settings: Partial<AppSettings>) => Promise<AppSettings>;
|
||||
resetProviderDailyUsage: (provider: DebridProvider) => Promise<AppSettings>;
|
||||
resetDebridLinkApiKeyDailyUsage: (keyId: string) => Promise<AppSettings>;
|
||||
addLinks: (payload: AddLinksPayload) => Promise<{ addedPackages: number; addedLinks: number; invalidCount: number }>;
|
||||
addContainers: (filePaths: string[]) => Promise<{ addedPackages: number; addedLinks: number }>;
|
||||
getStartConflicts: () => Promise<StartConflictEntry[]>;
|
||||
resolveStartConflict: (packageId: string, policy: DuplicatePolicy) => Promise<StartConflictResolutionResult>;
|
||||
clearAll: () => Promise<void>;
|
||||
start: () => Promise<void>;
|
||||
startPackages: (packageIds: string[]) => Promise<void>;
|
||||
stop: () => Promise<void>;
|
||||
togglePause: () => Promise<boolean>;
|
||||
cancelPackage: (packageId: string) => Promise<void>;
|
||||
renamePackage: (packageId: string, newName: string) => Promise<void>;
|
||||
reorderPackages: (packageIds: string[]) => Promise<void>;
|
||||
removeItem: (itemId: string) => Promise<void>;
|
||||
togglePackage: (packageId: string) => Promise<void>;
|
||||
exportPackageSelection: (packageIds: string[]) => Promise<{ saved: boolean; packageCount: number; linkCount: number; filePath?: string }>;
|
||||
exportItemSelection: (itemIds: string[]) => Promise<{ saved: boolean; packageCount: number; linkCount: number; filePath?: string }>;
|
||||
exportQueue: () => Promise<{ saved: boolean }>;
|
||||
importQueue: (json: string) => Promise<{ addedPackages: number; addedLinks: number }>;
|
||||
toggleClipboard: () => Promise<boolean>;
|
||||
pickFolder: () => Promise<string | null>;
|
||||
pickContainers: () => Promise<string[]>;
|
||||
getSessionStats: () => Promise<SessionStats>;
|
||||
resetSessionStats: () => Promise<void>;
|
||||
resetDownloadStats: () => Promise<void>;
|
||||
restart: () => Promise<void>;
|
||||
quit: () => Promise<void>;
|
||||
import type {
|
||||
AddLinksPayload,
|
||||
AllDebridHostInfo,
|
||||
AppSettings,
|
||||
DebridAccountStatus,
|
||||
DebugSetupCheckResult,
|
||||
DebridLinkHostLimitInfo,
|
||||
DebridProvider,
|
||||
DuplicatePolicy,
|
||||
EnableRemoteDiagnosticsInput,
|
||||
HistoryEntry,
|
||||
PackagePriority,
|
||||
RemoteDiagnosticsInfo,
|
||||
RendererErrorReport,
|
||||
SessionStats,
|
||||
StartConflictEntry,
|
||||
StartConflictResolutionResult,
|
||||
SupportTraceConfig,
|
||||
UiSnapshot,
|
||||
UpdateCheckResult,
|
||||
UpdateInstallProgress,
|
||||
UpdateInstallResult
|
||||
} from "./types";
|
||||
|
||||
export interface ElectronApi {
|
||||
getSnapshot: () => Promise<UiSnapshot>;
|
||||
getVersion: () => Promise<string>;
|
||||
checkUpdates: () => Promise<UpdateCheckResult>;
|
||||
installUpdate: () => Promise<UpdateInstallResult>;
|
||||
openExternal: (url: string) => Promise<boolean>;
|
||||
updateSettings: (settings: Partial<AppSettings>) => Promise<AppSettings>;
|
||||
resetProviderDailyUsage: (provider: DebridProvider) => Promise<AppSettings>;
|
||||
resetDebridLinkApiKeyDailyUsage: (keyId: string) => Promise<AppSettings>;
|
||||
addLinks: (payload: AddLinksPayload) => Promise<{ addedPackages: number; addedLinks: number; invalidCount: number }>;
|
||||
addContainers: (filePaths: string[]) => Promise<{ addedPackages: number; addedLinks: number }>;
|
||||
getStartConflicts: () => Promise<StartConflictEntry[]>;
|
||||
resolveStartConflict: (packageId: string, policy: DuplicatePolicy) => Promise<StartConflictResolutionResult>;
|
||||
clearAll: () => Promise<void>;
|
||||
start: () => Promise<void>;
|
||||
startPackages: (packageIds: string[]) => Promise<void>;
|
||||
stop: () => Promise<void>;
|
||||
togglePause: () => Promise<boolean>;
|
||||
cancelPackage: (packageId: string) => Promise<void>;
|
||||
renamePackage: (packageId: string, newName: string) => Promise<void>;
|
||||
reorderPackages: (packageIds: string[]) => Promise<void>;
|
||||
removeItem: (itemId: string) => Promise<void>;
|
||||
togglePackage: (packageId: string) => Promise<void>;
|
||||
exportPackageSelection: (packageIds: string[]) => Promise<{ saved: boolean; packageCount: number; linkCount: number; filePath?: string }>;
|
||||
exportItemSelection: (itemIds: string[]) => Promise<{ saved: boolean; packageCount: number; linkCount: number; filePath?: string }>;
|
||||
exportQueue: () => Promise<{ saved: boolean }>;
|
||||
importQueue: (json: string) => Promise<{ addedPackages: number; addedLinks: number }>;
|
||||
toggleClipboard: () => Promise<boolean>;
|
||||
pickFolder: () => Promise<string | null>;
|
||||
pickContainers: () => Promise<string[]>;
|
||||
getSessionStats: () => Promise<SessionStats>;
|
||||
resetSessionStats: () => Promise<void>;
|
||||
resetDownloadStats: () => Promise<void>;
|
||||
restart: () => Promise<void>;
|
||||
quit: () => Promise<void>;
|
||||
exportBackup: () => Promise<{ saved: boolean }>;
|
||||
importBackup: () => Promise<{ restored: boolean; relaunch: boolean; message: string }>;
|
||||
exportOnlineBackup: () => Promise<{ key: string }>;
|
||||
importOnlineBackup: (key: string) => Promise<{ restored: boolean; relaunch: false; message: string }>;
|
||||
exportSupportBundle: () => Promise<{ saved: boolean; filePath?: string }>;
|
||||
openLog: () => Promise<void>;
|
||||
openAuditLog: () => Promise<void>;
|
||||
openRenameLog: () => Promise<void>;
|
||||
openSessionLog: () => Promise<void>;
|
||||
openTraceLog: () => Promise<void>;
|
||||
openPackageLog: (packageId: string) => Promise<void>;
|
||||
openItemLog: (itemId: string) => Promise<void>;
|
||||
getDebugSetupCheck: () => Promise<DebugSetupCheckResult>;
|
||||
getRecentErrors: () => Promise<Array<{ ts: string; level: string; message: string }>>;
|
||||
testNotification: (url: string, mention: string) => Promise<boolean>;
|
||||
getTraceConfig: () => Promise<SupportTraceConfig>;
|
||||
setTraceEnabled: (enabled: boolean, note?: string, durationMinutes?: number) => Promise<SupportTraceConfig>;
|
||||
rotateDebugToken: () => Promise<{ path: string }>;
|
||||
getRemoteDiagnostics: () => Promise<RemoteDiagnosticsInfo>;
|
||||
enableRemoteDiagnostics: (input: EnableRemoteDiagnosticsInput) => Promise<RemoteDiagnosticsInfo>;
|
||||
disableRemoteDiagnostics: () => Promise<RemoteDiagnosticsInfo>;
|
||||
rotateRemoteDiagnosticsToken: () => Promise<RemoteDiagnosticsInfo>;
|
||||
openRealDebridLogin: () => Promise<void>;
|
||||
openAllDebridLogin: () => Promise<void>;
|
||||
importBestDebridCookies: () => Promise<number>;
|
||||
getAllDebridHostInfo: () => Promise<AllDebridHostInfo>;
|
||||
getDebridLinkHostLimits: () => Promise<DebridLinkHostLimitInfo[]>;
|
||||
checkDebridAccounts: () => Promise<DebridAccountStatus[]>;
|
||||
checkMegaDebridAccount: (login: string, password: string) => Promise<DebridAccountStatus | null>;
|
||||
retryExtraction: (packageId: string) => Promise<void>;
|
||||
extractNow: (packageId: string) => Promise<void>;
|
||||
resetPackage: (packageId: string) => Promise<void>;
|
||||
getHistory: () => Promise<HistoryEntry[]>;
|
||||
clearHistory: () => Promise<void>;
|
||||
removeHistoryEntry: (entryId: string) => Promise<void>;
|
||||
setPackagePriority: (packageId: string, priority: PackagePriority) => Promise<void>;
|
||||
skipItems: (itemIds: string[]) => Promise<void>;
|
||||
resetItems: (itemIds: string[]) => Promise<void>;
|
||||
startItems: (itemIds: string[]) => Promise<void>;
|
||||
reportRendererError: (report: RendererErrorReport) => void;
|
||||
onStateUpdate: (callback: (snapshot: UiSnapshot) => void) => () => void;
|
||||
onClipboardDetected: (callback: (links: string[]) => void) => () => void;
|
||||
onUpdateInstallProgress: (callback: (progress: UpdateInstallProgress) => void) => () => void;
|
||||
}
|
||||
exportSupportBundle: () => Promise<{ saved: boolean; filePath?: string }>;
|
||||
openLog: () => Promise<void>;
|
||||
openAuditLog: () => Promise<void>;
|
||||
openRenameLog: () => Promise<void>;
|
||||
openSessionLog: () => Promise<void>;
|
||||
openTraceLog: () => Promise<void>;
|
||||
openPackageLog: (packageId: string) => Promise<void>;
|
||||
openItemLog: (itemId: string) => Promise<void>;
|
||||
getDebugSetupCheck: () => Promise<DebugSetupCheckResult>;
|
||||
getRecentErrors: () => Promise<Array<{ ts: string; level: string; message: string }>>;
|
||||
testNotification: (url: string, mention: string) => Promise<boolean>;
|
||||
getTraceConfig: () => Promise<SupportTraceConfig>;
|
||||
setTraceEnabled: (enabled: boolean, note?: string, durationMinutes?: number) => Promise<SupportTraceConfig>;
|
||||
rotateDebugToken: () => Promise<{ path: string }>;
|
||||
getRemoteDiagnostics: () => Promise<RemoteDiagnosticsInfo>;
|
||||
enableRemoteDiagnostics: (input: EnableRemoteDiagnosticsInput) => Promise<RemoteDiagnosticsInfo>;
|
||||
disableRemoteDiagnostics: () => Promise<RemoteDiagnosticsInfo>;
|
||||
rotateRemoteDiagnosticsToken: () => Promise<RemoteDiagnosticsInfo>;
|
||||
openRealDebridLogin: () => Promise<void>;
|
||||
openAllDebridLogin: () => Promise<void>;
|
||||
importBestDebridCookies: () => Promise<number>;
|
||||
getAllDebridHostInfo: () => Promise<AllDebridHostInfo>;
|
||||
getDebridLinkHostLimits: () => Promise<DebridLinkHostLimitInfo[]>;
|
||||
checkDebridAccounts: () => Promise<DebridAccountStatus[]>;
|
||||
checkMegaDebridAccount: (login: string, password: string) => Promise<DebridAccountStatus | null>;
|
||||
retryExtraction: (packageId: string) => Promise<void>;
|
||||
extractNow: (packageId: string) => Promise<void>;
|
||||
resetPackage: (packageId: string) => Promise<void>;
|
||||
getHistory: () => Promise<HistoryEntry[]>;
|
||||
clearHistory: () => Promise<void>;
|
||||
removeHistoryEntry: (entryId: string) => Promise<void>;
|
||||
setPackagePriority: (packageId: string, priority: PackagePriority) => Promise<void>;
|
||||
skipItems: (itemIds: string[]) => Promise<void>;
|
||||
resetItems: (itemIds: string[]) => Promise<void>;
|
||||
startItems: (itemIds: string[]) => Promise<void>;
|
||||
reportRendererError: (report: RendererErrorReport) => void;
|
||||
onStateUpdate: (callback: (snapshot: UiSnapshot) => void) => () => void;
|
||||
onClipboardDetected: (callback: (links: string[]) => void) => () => void;
|
||||
onUpdateInstallProgress: (callback: (progress: UpdateInstallProgress) => void) => () => void;
|
||||
}
|
||||
|
||||
@ -1,329 +1,329 @@
|
||||
import type { AppSettings, DebridProvider } from "./types";
|
||||
|
||||
export type ProviderByteMap = Partial<Record<DebridProvider, number>>;
|
||||
export type DebridLinkKeyByteMap = Record<string, number>;
|
||||
|
||||
type ProviderDailySettings =
|
||||
Pick<AppSettings, "providerDailyLimitBytes" | "providerDailyUsageBytes" | "providerDailyUsageDay">
|
||||
& Partial<Pick<AppSettings, "debridLinkApiKeyDailyLimitBytes" | "debridLinkApiKeyDailyUsageBytes">>
|
||||
& Partial<Pick<AppSettings, "megaDebridDisabledAccountIds" | "megaDebridAccountDailyLimitBytes" | "megaDebridAccountDailyUsageBytes">>;
|
||||
|
||||
type ProviderUsageSettings =
|
||||
ProviderDailySettings
|
||||
& Partial<Pick<AppSettings, "providerTotalUsageBytes" | "debridLinkApiKeyTotalUsageBytes">>
|
||||
& Partial<Pick<AppSettings, "megaDebridAccountTotalUsageBytes">>;
|
||||
|
||||
function normalizePositiveBytes(value: unknown): number {
|
||||
const numeric = Number(value);
|
||||
if (!Number.isFinite(numeric) || numeric <= 0) {
|
||||
return 0;
|
||||
}
|
||||
return Math.floor(numeric);
|
||||
}
|
||||
|
||||
export function getProviderUsageDayKey(epochMs = Date.now()): string {
|
||||
const current = new Date(epochMs);
|
||||
const year = current.getFullYear();
|
||||
const month = String(current.getMonth() + 1).padStart(2, "0");
|
||||
const day = String(current.getDate()).padStart(2, "0");
|
||||
return `${year}-${month}-${day}`;
|
||||
}
|
||||
|
||||
export function getProviderDailyLimitBytes(settings: ProviderDailySettings, provider: DebridProvider): number {
|
||||
return normalizePositiveBytes(settings.providerDailyLimitBytes?.[provider]);
|
||||
}
|
||||
|
||||
export function getProviderDailyUsageBytes(
|
||||
settings: ProviderDailySettings,
|
||||
provider: DebridProvider,
|
||||
epochMs = Date.now()
|
||||
): number {
|
||||
if (settings.providerDailyUsageDay !== getProviderUsageDayKey(epochMs)) {
|
||||
return 0;
|
||||
}
|
||||
return normalizePositiveBytes(settings.providerDailyUsageBytes?.[provider]);
|
||||
}
|
||||
|
||||
export function getProviderDailyRemainingBytes(
|
||||
settings: ProviderDailySettings,
|
||||
provider: DebridProvider,
|
||||
epochMs = Date.now()
|
||||
): number | null {
|
||||
const limit = getProviderDailyLimitBytes(settings, provider);
|
||||
if (limit <= 0) {
|
||||
return null;
|
||||
}
|
||||
return Math.max(0, limit - getProviderDailyUsageBytes(settings, provider, epochMs));
|
||||
}
|
||||
|
||||
export function isProviderDailyLimitReached(
|
||||
settings: ProviderDailySettings,
|
||||
provider: DebridProvider,
|
||||
epochMs = Date.now()
|
||||
): boolean {
|
||||
const limit = getProviderDailyLimitBytes(settings, provider);
|
||||
return limit > 0 && getProviderDailyUsageBytes(settings, provider, epochMs) >= limit;
|
||||
}
|
||||
|
||||
export function getProviderTotalUsageBytes(settings: ProviderUsageSettings, provider: DebridProvider): number {
|
||||
return normalizePositiveBytes(settings.providerTotalUsageBytes?.[provider]);
|
||||
}
|
||||
|
||||
export function resetProviderDailyUsage(
|
||||
settings: ProviderDailySettings,
|
||||
provider?: DebridProvider,
|
||||
epochMs = Date.now()
|
||||
): Pick<AppSettings, "providerDailyUsageDay" | "providerDailyUsageBytes"> {
|
||||
const dayKey = getProviderUsageDayKey(epochMs);
|
||||
if (!provider) {
|
||||
return {
|
||||
providerDailyUsageDay: dayKey,
|
||||
providerDailyUsageBytes: {}
|
||||
};
|
||||
}
|
||||
|
||||
const nextUsageBytes = settings.providerDailyUsageDay === dayKey
|
||||
? { ...(settings.providerDailyUsageBytes || {}) }
|
||||
: {};
|
||||
delete nextUsageBytes[provider];
|
||||
|
||||
return {
|
||||
providerDailyUsageDay: dayKey,
|
||||
providerDailyUsageBytes: nextUsageBytes
|
||||
};
|
||||
}
|
||||
|
||||
export function addProviderDailyUsageBytes(
|
||||
settings: ProviderDailySettings,
|
||||
provider: DebridProvider,
|
||||
byteDelta: number,
|
||||
epochMs = Date.now()
|
||||
): Pick<AppSettings, "providerDailyUsageDay" | "providerDailyUsageBytes"> {
|
||||
const increment = normalizePositiveBytes(byteDelta);
|
||||
const dayKey = getProviderUsageDayKey(epochMs);
|
||||
const currentUsageBytes = settings.providerDailyUsageDay === dayKey
|
||||
? { ...(settings.providerDailyUsageBytes || {}) }
|
||||
: {};
|
||||
if (increment <= 0) {
|
||||
return {
|
||||
providerDailyUsageDay: dayKey,
|
||||
providerDailyUsageBytes: currentUsageBytes
|
||||
};
|
||||
}
|
||||
|
||||
const nextUsageBytes = currentUsageBytes;
|
||||
nextUsageBytes[provider] = normalizePositiveBytes(nextUsageBytes[provider]) + increment;
|
||||
|
||||
return {
|
||||
providerDailyUsageDay: dayKey,
|
||||
providerDailyUsageBytes: nextUsageBytes
|
||||
};
|
||||
}
|
||||
|
||||
export function addProviderTotalUsageBytes(
|
||||
settings: ProviderUsageSettings,
|
||||
provider: DebridProvider,
|
||||
byteDelta: number
|
||||
): Pick<AppSettings, "providerTotalUsageBytes"> {
|
||||
const increment = normalizePositiveBytes(byteDelta);
|
||||
const currentUsageBytes = { ...(settings.providerTotalUsageBytes || {}) };
|
||||
if (increment <= 0) {
|
||||
return {
|
||||
providerTotalUsageBytes: currentUsageBytes
|
||||
};
|
||||
}
|
||||
|
||||
currentUsageBytes[provider] = normalizePositiveBytes(currentUsageBytes[provider]) + increment;
|
||||
|
||||
return {
|
||||
providerTotalUsageBytes: currentUsageBytes
|
||||
};
|
||||
}
|
||||
|
||||
export function getDebridLinkApiKeyDailyLimitBytes(settings: ProviderDailySettings, keyId: string): number {
|
||||
return normalizePositiveBytes(settings.debridLinkApiKeyDailyLimitBytes?.[keyId]);
|
||||
}
|
||||
|
||||
export function getDebridLinkApiKeyDailyUsageBytes(
|
||||
settings: ProviderDailySettings,
|
||||
keyId: string,
|
||||
epochMs = Date.now()
|
||||
): number {
|
||||
if (settings.providerDailyUsageDay !== getProviderUsageDayKey(epochMs)) {
|
||||
return 0;
|
||||
}
|
||||
return normalizePositiveBytes(settings.debridLinkApiKeyDailyUsageBytes?.[keyId]);
|
||||
}
|
||||
|
||||
export function getDebridLinkApiKeyDailyRemainingBytes(
|
||||
settings: ProviderDailySettings,
|
||||
keyId: string,
|
||||
epochMs = Date.now()
|
||||
): number | null {
|
||||
const limit = getDebridLinkApiKeyDailyLimitBytes(settings, keyId);
|
||||
if (limit <= 0) {
|
||||
return null;
|
||||
}
|
||||
return Math.max(0, limit - getDebridLinkApiKeyDailyUsageBytes(settings, keyId, epochMs));
|
||||
}
|
||||
|
||||
export function isDebridLinkApiKeyDailyLimitReached(
|
||||
settings: ProviderDailySettings,
|
||||
keyId: string,
|
||||
epochMs = Date.now()
|
||||
): boolean {
|
||||
const limit = getDebridLinkApiKeyDailyLimitBytes(settings, keyId);
|
||||
return limit > 0 && getDebridLinkApiKeyDailyUsageBytes(settings, keyId, epochMs) >= limit;
|
||||
}
|
||||
|
||||
export function getDebridLinkApiKeyTotalUsageBytes(settings: ProviderUsageSettings, keyId: string): number {
|
||||
return normalizePositiveBytes(settings.debridLinkApiKeyTotalUsageBytes?.[keyId]);
|
||||
}
|
||||
|
||||
export function resetDebridLinkApiKeyDailyUsage(
|
||||
settings: ProviderDailySettings,
|
||||
keyId?: string,
|
||||
epochMs = Date.now()
|
||||
): Pick<AppSettings, "providerDailyUsageDay" | "debridLinkApiKeyDailyUsageBytes"> {
|
||||
const dayKey = getProviderUsageDayKey(epochMs);
|
||||
if (!keyId) {
|
||||
return {
|
||||
providerDailyUsageDay: dayKey,
|
||||
debridLinkApiKeyDailyUsageBytes: {}
|
||||
};
|
||||
}
|
||||
|
||||
const nextUsageBytes = settings.providerDailyUsageDay === dayKey
|
||||
? { ...(settings.debridLinkApiKeyDailyUsageBytes || {}) }
|
||||
: {};
|
||||
delete nextUsageBytes[keyId];
|
||||
|
||||
return {
|
||||
providerDailyUsageDay: dayKey,
|
||||
debridLinkApiKeyDailyUsageBytes: nextUsageBytes
|
||||
};
|
||||
}
|
||||
|
||||
export function addDebridLinkApiKeyDailyUsageBytes(
|
||||
settings: ProviderDailySettings,
|
||||
keyId: string,
|
||||
byteDelta: number,
|
||||
epochMs = Date.now()
|
||||
): Pick<AppSettings, "providerDailyUsageDay" | "debridLinkApiKeyDailyUsageBytes"> {
|
||||
const increment = normalizePositiveBytes(byteDelta);
|
||||
const dayKey = getProviderUsageDayKey(epochMs);
|
||||
const currentUsageBytes = settings.providerDailyUsageDay === dayKey
|
||||
? { ...(settings.debridLinkApiKeyDailyUsageBytes || {}) }
|
||||
: {};
|
||||
if (increment <= 0) {
|
||||
return {
|
||||
providerDailyUsageDay: dayKey,
|
||||
debridLinkApiKeyDailyUsageBytes: currentUsageBytes
|
||||
};
|
||||
}
|
||||
|
||||
currentUsageBytes[keyId] = normalizePositiveBytes(currentUsageBytes[keyId]) + increment;
|
||||
|
||||
return {
|
||||
providerDailyUsageDay: dayKey,
|
||||
debridLinkApiKeyDailyUsageBytes: currentUsageBytes
|
||||
};
|
||||
}
|
||||
|
||||
export function addDebridLinkApiKeyTotalUsageBytes(
|
||||
settings: ProviderUsageSettings,
|
||||
keyId: string,
|
||||
byteDelta: number
|
||||
): Pick<AppSettings, "debridLinkApiKeyTotalUsageBytes"> {
|
||||
const increment = normalizePositiveBytes(byteDelta);
|
||||
const currentUsageBytes = { ...(settings.debridLinkApiKeyTotalUsageBytes || {}) };
|
||||
if (increment <= 0) {
|
||||
return {
|
||||
debridLinkApiKeyTotalUsageBytes: currentUsageBytes
|
||||
};
|
||||
}
|
||||
|
||||
currentUsageBytes[keyId] = normalizePositiveBytes(currentUsageBytes[keyId]) + increment;
|
||||
|
||||
return {
|
||||
debridLinkApiKeyTotalUsageBytes: currentUsageBytes
|
||||
};
|
||||
}
|
||||
|
||||
export function isMegaDebridAccountDisabled(settings: ProviderDailySettings, accountId: string): boolean {
|
||||
return Array.isArray(settings.megaDebridDisabledAccountIds) && settings.megaDebridDisabledAccountIds.includes(accountId);
|
||||
}
|
||||
|
||||
export function getMegaDebridAccountDailyLimitBytes(settings: ProviderDailySettings, accountId: string): number {
|
||||
return normalizePositiveBytes(settings.megaDebridAccountDailyLimitBytes?.[accountId]);
|
||||
}
|
||||
|
||||
export function getMegaDebridAccountDailyUsageBytes(
|
||||
settings: ProviderDailySettings,
|
||||
accountId: string,
|
||||
epochMs = Date.now()
|
||||
): number {
|
||||
if (settings.providerDailyUsageDay !== getProviderUsageDayKey(epochMs)) {
|
||||
return 0;
|
||||
}
|
||||
return normalizePositiveBytes(settings.megaDebridAccountDailyUsageBytes?.[accountId]);
|
||||
}
|
||||
|
||||
export function isMegaDebridAccountDailyLimitReached(
|
||||
settings: ProviderDailySettings,
|
||||
accountId: string,
|
||||
epochMs = Date.now()
|
||||
): boolean {
|
||||
const limit = getMegaDebridAccountDailyLimitBytes(settings, accountId);
|
||||
return limit > 0 && getMegaDebridAccountDailyUsageBytes(settings, accountId, epochMs) >= limit;
|
||||
}
|
||||
|
||||
export function getMegaDebridAccountTotalUsageBytes(settings: ProviderUsageSettings, accountId: string): number {
|
||||
return normalizePositiveBytes(settings.megaDebridAccountTotalUsageBytes?.[accountId]);
|
||||
}
|
||||
|
||||
export function addMegaDebridAccountDailyUsageBytes(
|
||||
settings: ProviderDailySettings,
|
||||
accountId: string,
|
||||
byteDelta: number,
|
||||
epochMs = Date.now()
|
||||
): Pick<AppSettings, "providerDailyUsageDay" | "megaDebridAccountDailyUsageBytes"> {
|
||||
const increment = normalizePositiveBytes(byteDelta);
|
||||
const dayKey = getProviderUsageDayKey(epochMs);
|
||||
const currentUsageBytes = settings.providerDailyUsageDay === dayKey
|
||||
? { ...(settings.megaDebridAccountDailyUsageBytes || {}) }
|
||||
: {};
|
||||
if (increment <= 0) {
|
||||
return {
|
||||
providerDailyUsageDay: dayKey,
|
||||
megaDebridAccountDailyUsageBytes: currentUsageBytes
|
||||
};
|
||||
}
|
||||
|
||||
currentUsageBytes[accountId] = normalizePositiveBytes(currentUsageBytes[accountId]) + increment;
|
||||
|
||||
return {
|
||||
providerDailyUsageDay: dayKey,
|
||||
megaDebridAccountDailyUsageBytes: currentUsageBytes
|
||||
};
|
||||
}
|
||||
|
||||
export function addMegaDebridAccountTotalUsageBytes(
|
||||
settings: ProviderUsageSettings,
|
||||
accountId: string,
|
||||
byteDelta: number
|
||||
): Pick<AppSettings, "megaDebridAccountTotalUsageBytes"> {
|
||||
const increment = normalizePositiveBytes(byteDelta);
|
||||
const currentUsageBytes = { ...(settings.megaDebridAccountTotalUsageBytes || {}) };
|
||||
if (increment <= 0) {
|
||||
return {
|
||||
megaDebridAccountTotalUsageBytes: currentUsageBytes
|
||||
};
|
||||
}
|
||||
|
||||
currentUsageBytes[accountId] = normalizePositiveBytes(currentUsageBytes[accountId]) + increment;
|
||||
|
||||
return {
|
||||
megaDebridAccountTotalUsageBytes: currentUsageBytes
|
||||
};
|
||||
}
|
||||
import type { AppSettings, DebridProvider } from "./types";
|
||||
|
||||
export type ProviderByteMap = Partial<Record<DebridProvider, number>>;
|
||||
export type DebridLinkKeyByteMap = Record<string, number>;
|
||||
|
||||
type ProviderDailySettings =
|
||||
Pick<AppSettings, "providerDailyLimitBytes" | "providerDailyUsageBytes" | "providerDailyUsageDay">
|
||||
& Partial<Pick<AppSettings, "debridLinkApiKeyDailyLimitBytes" | "debridLinkApiKeyDailyUsageBytes">>
|
||||
& Partial<Pick<AppSettings, "megaDebridDisabledAccountIds" | "megaDebridAccountDailyLimitBytes" | "megaDebridAccountDailyUsageBytes">>;
|
||||
|
||||
type ProviderUsageSettings =
|
||||
ProviderDailySettings
|
||||
& Partial<Pick<AppSettings, "providerTotalUsageBytes" | "debridLinkApiKeyTotalUsageBytes">>
|
||||
& Partial<Pick<AppSettings, "megaDebridAccountTotalUsageBytes">>;
|
||||
|
||||
function normalizePositiveBytes(value: unknown): number {
|
||||
const numeric = Number(value);
|
||||
if (!Number.isFinite(numeric) || numeric <= 0) {
|
||||
return 0;
|
||||
}
|
||||
return Math.floor(numeric);
|
||||
}
|
||||
|
||||
export function getProviderUsageDayKey(epochMs = Date.now()): string {
|
||||
const current = new Date(epochMs);
|
||||
const year = current.getFullYear();
|
||||
const month = String(current.getMonth() + 1).padStart(2, "0");
|
||||
const day = String(current.getDate()).padStart(2, "0");
|
||||
return `${year}-${month}-${day}`;
|
||||
}
|
||||
|
||||
export function getProviderDailyLimitBytes(settings: ProviderDailySettings, provider: DebridProvider): number {
|
||||
return normalizePositiveBytes(settings.providerDailyLimitBytes?.[provider]);
|
||||
}
|
||||
|
||||
export function getProviderDailyUsageBytes(
|
||||
settings: ProviderDailySettings,
|
||||
provider: DebridProvider,
|
||||
epochMs = Date.now()
|
||||
): number {
|
||||
if (settings.providerDailyUsageDay !== getProviderUsageDayKey(epochMs)) {
|
||||
return 0;
|
||||
}
|
||||
return normalizePositiveBytes(settings.providerDailyUsageBytes?.[provider]);
|
||||
}
|
||||
|
||||
export function getProviderDailyRemainingBytes(
|
||||
settings: ProviderDailySettings,
|
||||
provider: DebridProvider,
|
||||
epochMs = Date.now()
|
||||
): number | null {
|
||||
const limit = getProviderDailyLimitBytes(settings, provider);
|
||||
if (limit <= 0) {
|
||||
return null;
|
||||
}
|
||||
return Math.max(0, limit - getProviderDailyUsageBytes(settings, provider, epochMs));
|
||||
}
|
||||
|
||||
export function isProviderDailyLimitReached(
|
||||
settings: ProviderDailySettings,
|
||||
provider: DebridProvider,
|
||||
epochMs = Date.now()
|
||||
): boolean {
|
||||
const limit = getProviderDailyLimitBytes(settings, provider);
|
||||
return limit > 0 && getProviderDailyUsageBytes(settings, provider, epochMs) >= limit;
|
||||
}
|
||||
|
||||
export function getProviderTotalUsageBytes(settings: ProviderUsageSettings, provider: DebridProvider): number {
|
||||
return normalizePositiveBytes(settings.providerTotalUsageBytes?.[provider]);
|
||||
}
|
||||
|
||||
export function resetProviderDailyUsage(
|
||||
settings: ProviderDailySettings,
|
||||
provider?: DebridProvider,
|
||||
epochMs = Date.now()
|
||||
): Pick<AppSettings, "providerDailyUsageDay" | "providerDailyUsageBytes"> {
|
||||
const dayKey = getProviderUsageDayKey(epochMs);
|
||||
if (!provider) {
|
||||
return {
|
||||
providerDailyUsageDay: dayKey,
|
||||
providerDailyUsageBytes: {}
|
||||
};
|
||||
}
|
||||
|
||||
const nextUsageBytes = settings.providerDailyUsageDay === dayKey
|
||||
? { ...(settings.providerDailyUsageBytes || {}) }
|
||||
: {};
|
||||
delete nextUsageBytes[provider];
|
||||
|
||||
return {
|
||||
providerDailyUsageDay: dayKey,
|
||||
providerDailyUsageBytes: nextUsageBytes
|
||||
};
|
||||
}
|
||||
|
||||
export function addProviderDailyUsageBytes(
|
||||
settings: ProviderDailySettings,
|
||||
provider: DebridProvider,
|
||||
byteDelta: number,
|
||||
epochMs = Date.now()
|
||||
): Pick<AppSettings, "providerDailyUsageDay" | "providerDailyUsageBytes"> {
|
||||
const increment = normalizePositiveBytes(byteDelta);
|
||||
const dayKey = getProviderUsageDayKey(epochMs);
|
||||
const currentUsageBytes = settings.providerDailyUsageDay === dayKey
|
||||
? { ...(settings.providerDailyUsageBytes || {}) }
|
||||
: {};
|
||||
if (increment <= 0) {
|
||||
return {
|
||||
providerDailyUsageDay: dayKey,
|
||||
providerDailyUsageBytes: currentUsageBytes
|
||||
};
|
||||
}
|
||||
|
||||
const nextUsageBytes = currentUsageBytes;
|
||||
nextUsageBytes[provider] = normalizePositiveBytes(nextUsageBytes[provider]) + increment;
|
||||
|
||||
return {
|
||||
providerDailyUsageDay: dayKey,
|
||||
providerDailyUsageBytes: nextUsageBytes
|
||||
};
|
||||
}
|
||||
|
||||
export function addProviderTotalUsageBytes(
|
||||
settings: ProviderUsageSettings,
|
||||
provider: DebridProvider,
|
||||
byteDelta: number
|
||||
): Pick<AppSettings, "providerTotalUsageBytes"> {
|
||||
const increment = normalizePositiveBytes(byteDelta);
|
||||
const currentUsageBytes = { ...(settings.providerTotalUsageBytes || {}) };
|
||||
if (increment <= 0) {
|
||||
return {
|
||||
providerTotalUsageBytes: currentUsageBytes
|
||||
};
|
||||
}
|
||||
|
||||
currentUsageBytes[provider] = normalizePositiveBytes(currentUsageBytes[provider]) + increment;
|
||||
|
||||
return {
|
||||
providerTotalUsageBytes: currentUsageBytes
|
||||
};
|
||||
}
|
||||
|
||||
export function getDebridLinkApiKeyDailyLimitBytes(settings: ProviderDailySettings, keyId: string): number {
|
||||
return normalizePositiveBytes(settings.debridLinkApiKeyDailyLimitBytes?.[keyId]);
|
||||
}
|
||||
|
||||
export function getDebridLinkApiKeyDailyUsageBytes(
|
||||
settings: ProviderDailySettings,
|
||||
keyId: string,
|
||||
epochMs = Date.now()
|
||||
): number {
|
||||
if (settings.providerDailyUsageDay !== getProviderUsageDayKey(epochMs)) {
|
||||
return 0;
|
||||
}
|
||||
return normalizePositiveBytes(settings.debridLinkApiKeyDailyUsageBytes?.[keyId]);
|
||||
}
|
||||
|
||||
export function getDebridLinkApiKeyDailyRemainingBytes(
|
||||
settings: ProviderDailySettings,
|
||||
keyId: string,
|
||||
epochMs = Date.now()
|
||||
): number | null {
|
||||
const limit = getDebridLinkApiKeyDailyLimitBytes(settings, keyId);
|
||||
if (limit <= 0) {
|
||||
return null;
|
||||
}
|
||||
return Math.max(0, limit - getDebridLinkApiKeyDailyUsageBytes(settings, keyId, epochMs));
|
||||
}
|
||||
|
||||
export function isDebridLinkApiKeyDailyLimitReached(
|
||||
settings: ProviderDailySettings,
|
||||
keyId: string,
|
||||
epochMs = Date.now()
|
||||
): boolean {
|
||||
const limit = getDebridLinkApiKeyDailyLimitBytes(settings, keyId);
|
||||
return limit > 0 && getDebridLinkApiKeyDailyUsageBytes(settings, keyId, epochMs) >= limit;
|
||||
}
|
||||
|
||||
export function getDebridLinkApiKeyTotalUsageBytes(settings: ProviderUsageSettings, keyId: string): number {
|
||||
return normalizePositiveBytes(settings.debridLinkApiKeyTotalUsageBytes?.[keyId]);
|
||||
}
|
||||
|
||||
export function resetDebridLinkApiKeyDailyUsage(
|
||||
settings: ProviderDailySettings,
|
||||
keyId?: string,
|
||||
epochMs = Date.now()
|
||||
): Pick<AppSettings, "providerDailyUsageDay" | "debridLinkApiKeyDailyUsageBytes"> {
|
||||
const dayKey = getProviderUsageDayKey(epochMs);
|
||||
if (!keyId) {
|
||||
return {
|
||||
providerDailyUsageDay: dayKey,
|
||||
debridLinkApiKeyDailyUsageBytes: {}
|
||||
};
|
||||
}
|
||||
|
||||
const nextUsageBytes = settings.providerDailyUsageDay === dayKey
|
||||
? { ...(settings.debridLinkApiKeyDailyUsageBytes || {}) }
|
||||
: {};
|
||||
delete nextUsageBytes[keyId];
|
||||
|
||||
return {
|
||||
providerDailyUsageDay: dayKey,
|
||||
debridLinkApiKeyDailyUsageBytes: nextUsageBytes
|
||||
};
|
||||
}
|
||||
|
||||
export function addDebridLinkApiKeyDailyUsageBytes(
|
||||
settings: ProviderDailySettings,
|
||||
keyId: string,
|
||||
byteDelta: number,
|
||||
epochMs = Date.now()
|
||||
): Pick<AppSettings, "providerDailyUsageDay" | "debridLinkApiKeyDailyUsageBytes"> {
|
||||
const increment = normalizePositiveBytes(byteDelta);
|
||||
const dayKey = getProviderUsageDayKey(epochMs);
|
||||
const currentUsageBytes = settings.providerDailyUsageDay === dayKey
|
||||
? { ...(settings.debridLinkApiKeyDailyUsageBytes || {}) }
|
||||
: {};
|
||||
if (increment <= 0) {
|
||||
return {
|
||||
providerDailyUsageDay: dayKey,
|
||||
debridLinkApiKeyDailyUsageBytes: currentUsageBytes
|
||||
};
|
||||
}
|
||||
|
||||
currentUsageBytes[keyId] = normalizePositiveBytes(currentUsageBytes[keyId]) + increment;
|
||||
|
||||
return {
|
||||
providerDailyUsageDay: dayKey,
|
||||
debridLinkApiKeyDailyUsageBytes: currentUsageBytes
|
||||
};
|
||||
}
|
||||
|
||||
export function addDebridLinkApiKeyTotalUsageBytes(
|
||||
settings: ProviderUsageSettings,
|
||||
keyId: string,
|
||||
byteDelta: number
|
||||
): Pick<AppSettings, "debridLinkApiKeyTotalUsageBytes"> {
|
||||
const increment = normalizePositiveBytes(byteDelta);
|
||||
const currentUsageBytes = { ...(settings.debridLinkApiKeyTotalUsageBytes || {}) };
|
||||
if (increment <= 0) {
|
||||
return {
|
||||
debridLinkApiKeyTotalUsageBytes: currentUsageBytes
|
||||
};
|
||||
}
|
||||
|
||||
currentUsageBytes[keyId] = normalizePositiveBytes(currentUsageBytes[keyId]) + increment;
|
||||
|
||||
return {
|
||||
debridLinkApiKeyTotalUsageBytes: currentUsageBytes
|
||||
};
|
||||
}
|
||||
|
||||
export function isMegaDebridAccountDisabled(settings: ProviderDailySettings, accountId: string): boolean {
|
||||
return Array.isArray(settings.megaDebridDisabledAccountIds) && settings.megaDebridDisabledAccountIds.includes(accountId);
|
||||
}
|
||||
|
||||
export function getMegaDebridAccountDailyLimitBytes(settings: ProviderDailySettings, accountId: string): number {
|
||||
return normalizePositiveBytes(settings.megaDebridAccountDailyLimitBytes?.[accountId]);
|
||||
}
|
||||
|
||||
export function getMegaDebridAccountDailyUsageBytes(
|
||||
settings: ProviderDailySettings,
|
||||
accountId: string,
|
||||
epochMs = Date.now()
|
||||
): number {
|
||||
if (settings.providerDailyUsageDay !== getProviderUsageDayKey(epochMs)) {
|
||||
return 0;
|
||||
}
|
||||
return normalizePositiveBytes(settings.megaDebridAccountDailyUsageBytes?.[accountId]);
|
||||
}
|
||||
|
||||
export function isMegaDebridAccountDailyLimitReached(
|
||||
settings: ProviderDailySettings,
|
||||
accountId: string,
|
||||
epochMs = Date.now()
|
||||
): boolean {
|
||||
const limit = getMegaDebridAccountDailyLimitBytes(settings, accountId);
|
||||
return limit > 0 && getMegaDebridAccountDailyUsageBytes(settings, accountId, epochMs) >= limit;
|
||||
}
|
||||
|
||||
export function getMegaDebridAccountTotalUsageBytes(settings: ProviderUsageSettings, accountId: string): number {
|
||||
return normalizePositiveBytes(settings.megaDebridAccountTotalUsageBytes?.[accountId]);
|
||||
}
|
||||
|
||||
export function addMegaDebridAccountDailyUsageBytes(
|
||||
settings: ProviderDailySettings,
|
||||
accountId: string,
|
||||
byteDelta: number,
|
||||
epochMs = Date.now()
|
||||
): Pick<AppSettings, "providerDailyUsageDay" | "megaDebridAccountDailyUsageBytes"> {
|
||||
const increment = normalizePositiveBytes(byteDelta);
|
||||
const dayKey = getProviderUsageDayKey(epochMs);
|
||||
const currentUsageBytes = settings.providerDailyUsageDay === dayKey
|
||||
? { ...(settings.megaDebridAccountDailyUsageBytes || {}) }
|
||||
: {};
|
||||
if (increment <= 0) {
|
||||
return {
|
||||
providerDailyUsageDay: dayKey,
|
||||
megaDebridAccountDailyUsageBytes: currentUsageBytes
|
||||
};
|
||||
}
|
||||
|
||||
currentUsageBytes[accountId] = normalizePositiveBytes(currentUsageBytes[accountId]) + increment;
|
||||
|
||||
return {
|
||||
providerDailyUsageDay: dayKey,
|
||||
megaDebridAccountDailyUsageBytes: currentUsageBytes
|
||||
};
|
||||
}
|
||||
|
||||
export function addMegaDebridAccountTotalUsageBytes(
|
||||
settings: ProviderUsageSettings,
|
||||
accountId: string,
|
||||
byteDelta: number
|
||||
): Pick<AppSettings, "megaDebridAccountTotalUsageBytes"> {
|
||||
const increment = normalizePositiveBytes(byteDelta);
|
||||
const currentUsageBytes = { ...(settings.megaDebridAccountTotalUsageBytes || {}) };
|
||||
if (increment <= 0) {
|
||||
return {
|
||||
megaDebridAccountTotalUsageBytes: currentUsageBytes
|
||||
};
|
||||
}
|
||||
|
||||
currentUsageBytes[accountId] = normalizePositiveBytes(currentUsageBytes[accountId]) + increment;
|
||||
|
||||
return {
|
||||
megaDebridAccountTotalUsageBytes: currentUsageBytes
|
||||
};
|
||||
}
|
||||
|
||||
1134
src/shared/types.ts
1134
src/shared/types.ts
File diff suppressed because it is too large
Load Diff
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.
|
||||
@ -1,161 +1,161 @@
|
||||
import { describe, it, expect, vi, afterEach } from "vitest";
|
||||
import { checkMegaDebridAccount, checkDebridLinkKey, checkAllDebridAccounts } from "../src/main/account-check";
|
||||
import type { MegaDebridAccountEntry } from "../src/shared/mega-debrid-accounts";
|
||||
import type { DebridLinkApiKeyEntry } from "../src/shared/debrid-link-keys";
|
||||
import type { AppSettings } from "../src/shared/types";
|
||||
|
||||
function megaAccount(login = "user@example.com"): MegaDebridAccountEntry {
|
||||
return { id: "mda_test", login, password: "pw", index: 0, label: "Account 1", maskedLogin: "us**le" };
|
||||
}
|
||||
|
||||
function debridLinkKey(token = "tok_abcdef"): DebridLinkApiKeyEntry {
|
||||
return { id: "dlk_test", token, index: 0, label: "Key 1", masked: "tok***def" };
|
||||
}
|
||||
|
||||
function mockFetchOnce(status: number, body: unknown): void {
|
||||
const text = typeof body === "string" ? body : JSON.stringify(body);
|
||||
vi.stubGlobal("fetch", vi.fn(async () => ({
|
||||
ok: status >= 200 && status < 300,
|
||||
status,
|
||||
text: async () => text
|
||||
})) as unknown as typeof fetch);
|
||||
}
|
||||
|
||||
const NOW = 1_700_000_000_000;
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
describe("checkMegaDebridAccount", () => {
|
||||
it("reports valid + premium from vip_end (future Unix ts)", async () => {
|
||||
const futureSec = Math.floor(NOW / 1000) + 30 * 24 * 60 * 60;
|
||||
mockFetchOnce(200, { response_code: "ok", response_text: "User logged", token: "t", vip_end: String(futureSec), email: "a@b.de" });
|
||||
const st = await checkMegaDebridAccount(megaAccount(), undefined, NOW);
|
||||
expect(st.valid).toBe(true);
|
||||
expect(st.isPremium).toBe(true);
|
||||
expect(st.premiumUntilMs).toBe(futureSec * 1000);
|
||||
expect(st.email).toBe("a@b.de");
|
||||
expect(st.message).toMatch(/Premium noch/);
|
||||
});
|
||||
|
||||
it("reports valid but NOT premium when vip_end is in the past", async () => {
|
||||
const pastSec = Math.floor(NOW / 1000) - 1000;
|
||||
mockFetchOnce(200, { response_code: "ok", token: "t", vip_end: String(pastSec) });
|
||||
const st = await checkMegaDebridAccount(megaAccount(), undefined, NOW);
|
||||
expect(st.valid).toBe(true);
|
||||
expect(st.isPremium).toBe(false);
|
||||
});
|
||||
|
||||
it("reports valid but no premium when vip_end is 0/missing", async () => {
|
||||
mockFetchOnce(200, { response_code: "ok", token: "t", vip_end: "0" });
|
||||
const st = await checkMegaDebridAccount(megaAccount(), undefined, NOW);
|
||||
expect(st.valid).toBe(true);
|
||||
expect(st.isPremium).toBe(false);
|
||||
expect(st.premiumUntilMs).toBe(0);
|
||||
expect(st.message).toMatch(/Kein Premium/);
|
||||
});
|
||||
|
||||
it("reports invalid login when response_code != ok", async () => {
|
||||
mockFetchOnce(200, { response_code: "error", response_text: "bad login" });
|
||||
const st = await checkMegaDebridAccount(megaAccount(), undefined, NOW);
|
||||
expect(st.valid).toBe(false);
|
||||
expect(st.isPremium).toBe(false);
|
||||
expect(st.message).toMatch(/Ungueltiger Login/);
|
||||
});
|
||||
|
||||
it("reports invalid on HTTP error", async () => {
|
||||
mockFetchOnce(500, "server error");
|
||||
const st = await checkMegaDebridAccount(megaAccount(), undefined, NOW);
|
||||
expect(st.valid).toBe(false);
|
||||
});
|
||||
|
||||
it("never throws on network error — returns a failed status", async () => {
|
||||
vi.stubGlobal("fetch", vi.fn(async () => { throw new Error("ECONNRESET"); }) as unknown as typeof fetch);
|
||||
const st = await checkMegaDebridAccount(megaAccount(), undefined, NOW);
|
||||
expect(st.valid).toBe(false);
|
||||
expect(st.message).toMatch(/Pruefung fehlgeschlagen/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("checkDebridLinkKey", () => {
|
||||
it("reports valid + premium from premiumLeft seconds", async () => {
|
||||
const premiumLeft = 60 * 24 * 60 * 60;
|
||||
mockFetchOnce(200, { success: true, value: { username: "u", accountType: 1, premiumLeft } });
|
||||
const st = await checkDebridLinkKey(debridLinkKey(), undefined, NOW);
|
||||
expect(st.valid).toBe(true);
|
||||
expect(st.isPremium).toBe(true);
|
||||
expect(st.premiumUntilMs).toBe(NOW + premiumLeft * 1000);
|
||||
});
|
||||
|
||||
it("reports valid but free (premiumLeft 0, accountType 0)", async () => {
|
||||
mockFetchOnce(200, { success: true, value: { username: "u", accountType: 0, premiumLeft: 0 } });
|
||||
const st = await checkDebridLinkKey(debridLinkKey(), undefined, NOW);
|
||||
expect(st.valid).toBe(true);
|
||||
expect(st.isPremium).toBe(false);
|
||||
expect(st.message).toMatch(/Free/);
|
||||
});
|
||||
|
||||
it("reports invalid key on HTTP 401", async () => {
|
||||
mockFetchOnce(401, { success: false, error: "badToken" });
|
||||
const st = await checkDebridLinkKey(debridLinkKey(), undefined, NOW);
|
||||
expect(st.valid).toBe(false);
|
||||
expect(st.message).toMatch(/Ungueltiger API-Key/);
|
||||
});
|
||||
|
||||
it("reports invalid key when success=false", async () => {
|
||||
mockFetchOnce(200, { success: false, error: "badToken" });
|
||||
const st = await checkDebridLinkKey(debridLinkKey(), undefined, NOW);
|
||||
expect(st.valid).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("checkAllDebridAccounts", () => {
|
||||
it("returns empty array when nothing configured", async () => {
|
||||
const settings = { megaCredentials: "", megaPassword: "", debridLinkApiKeys: "" } as unknown as AppSettings;
|
||||
const result = await checkAllDebridAccounts(settings);
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it("checks every configured mega account + debrid-link key", async () => {
|
||||
const futureSec = Math.floor(Date.now() / 1000) + 1000;
|
||||
vi.stubGlobal("fetch", vi.fn(async (url: string) => {
|
||||
if (String(url).includes("mega-debrid")) {
|
||||
return { ok: true, status: 200, text: async () => JSON.stringify({ response_code: "ok", token: "t", vip_end: String(futureSec) }) };
|
||||
}
|
||||
return { ok: true, status: 200, text: async () => JSON.stringify({ success: true, value: { accountType: 1, premiumLeft: 1000 } }) };
|
||||
}) as unknown as typeof fetch);
|
||||
|
||||
const settings = {
|
||||
megaCredentials: "a@b.de:pw1\nc@d.de:pw2",
|
||||
megaPassword: "",
|
||||
debridLinkApiKeys: "key1\nkey2\nkey3"
|
||||
} as unknown as AppSettings;
|
||||
|
||||
const result = await checkAllDebridAccounts(settings);
|
||||
expect(result).toHaveLength(5);
|
||||
expect(result.filter((r) => r.provider === "megadebrid")).toHaveLength(2);
|
||||
expect(result.filter((r) => r.provider === "debridlink")).toHaveLength(3);
|
||||
expect(result.every((r) => r.valid)).toBe(true);
|
||||
});
|
||||
|
||||
it("caps concurrency (never more than 4 in flight) and preserves result order", async () => {
|
||||
let inFlight = 0;
|
||||
let maxInFlight = 0;
|
||||
vi.stubGlobal("fetch", vi.fn(async () => {
|
||||
inFlight += 1;
|
||||
maxInFlight = Math.max(maxInFlight, inFlight);
|
||||
await new Promise((resolve) => setTimeout(resolve, 5));
|
||||
inFlight -= 1;
|
||||
return { ok: true, status: 200, text: async () => JSON.stringify({ success: true, value: { accountType: 1, premiumLeft: 1000 } }) };
|
||||
}) as unknown as typeof fetch);
|
||||
|
||||
const keys = Array.from({ length: 9 }, (_, i) => `key_${i}`).join("\n");
|
||||
const settings = { megaCredentials: "", megaPassword: "", debridLinkApiKeys: keys } as unknown as AppSettings;
|
||||
|
||||
const result = await checkAllDebridAccounts(settings);
|
||||
expect(result).toHaveLength(9);
|
||||
expect(maxInFlight).toBeLessThanOrEqual(4);
|
||||
result.forEach((r, i) => expect(r.label).toBe(`Key ${i + 1}`));
|
||||
});
|
||||
});
|
||||
import { describe, it, expect, vi, afterEach } from "vitest";
|
||||
import { checkMegaDebridAccount, checkDebridLinkKey, checkAllDebridAccounts } from "../src/main/account-check";
|
||||
import type { MegaDebridAccountEntry } from "../src/shared/mega-debrid-accounts";
|
||||
import type { DebridLinkApiKeyEntry } from "../src/shared/debrid-link-keys";
|
||||
import type { AppSettings } from "../src/shared/types";
|
||||
|
||||
function megaAccount(login = "user@example.com"): MegaDebridAccountEntry {
|
||||
return { id: "mda_test", login, password: "pw", index: 0, label: "Account 1", maskedLogin: "us**le" };
|
||||
}
|
||||
|
||||
function debridLinkKey(token = "tok_abcdef"): DebridLinkApiKeyEntry {
|
||||
return { id: "dlk_test", token, index: 0, label: "Key 1", masked: "tok***def" };
|
||||
}
|
||||
|
||||
function mockFetchOnce(status: number, body: unknown): void {
|
||||
const text = typeof body === "string" ? body : JSON.stringify(body);
|
||||
vi.stubGlobal("fetch", vi.fn(async () => ({
|
||||
ok: status >= 200 && status < 300,
|
||||
status,
|
||||
text: async () => text
|
||||
})) as unknown as typeof fetch);
|
||||
}
|
||||
|
||||
const NOW = 1_700_000_000_000;
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
describe("checkMegaDebridAccount", () => {
|
||||
it("reports valid + premium from vip_end (future Unix ts)", async () => {
|
||||
const futureSec = Math.floor(NOW / 1000) + 30 * 24 * 60 * 60;
|
||||
mockFetchOnce(200, { response_code: "ok", response_text: "User logged", token: "t", vip_end: String(futureSec), email: "a@b.de" });
|
||||
const st = await checkMegaDebridAccount(megaAccount(), undefined, NOW);
|
||||
expect(st.valid).toBe(true);
|
||||
expect(st.isPremium).toBe(true);
|
||||
expect(st.premiumUntilMs).toBe(futureSec * 1000);
|
||||
expect(st.email).toBe("a@b.de");
|
||||
expect(st.message).toMatch(/Premium noch/);
|
||||
});
|
||||
|
||||
it("reports valid but NOT premium when vip_end is in the past", async () => {
|
||||
const pastSec = Math.floor(NOW / 1000) - 1000;
|
||||
mockFetchOnce(200, { response_code: "ok", token: "t", vip_end: String(pastSec) });
|
||||
const st = await checkMegaDebridAccount(megaAccount(), undefined, NOW);
|
||||
expect(st.valid).toBe(true);
|
||||
expect(st.isPremium).toBe(false);
|
||||
});
|
||||
|
||||
it("reports valid but no premium when vip_end is 0/missing", async () => {
|
||||
mockFetchOnce(200, { response_code: "ok", token: "t", vip_end: "0" });
|
||||
const st = await checkMegaDebridAccount(megaAccount(), undefined, NOW);
|
||||
expect(st.valid).toBe(true);
|
||||
expect(st.isPremium).toBe(false);
|
||||
expect(st.premiumUntilMs).toBe(0);
|
||||
expect(st.message).toMatch(/Kein Premium/);
|
||||
});
|
||||
|
||||
it("reports invalid login when response_code != ok", async () => {
|
||||
mockFetchOnce(200, { response_code: "error", response_text: "bad login" });
|
||||
const st = await checkMegaDebridAccount(megaAccount(), undefined, NOW);
|
||||
expect(st.valid).toBe(false);
|
||||
expect(st.isPremium).toBe(false);
|
||||
expect(st.message).toMatch(/Ungueltiger Login/);
|
||||
});
|
||||
|
||||
it("reports invalid on HTTP error", async () => {
|
||||
mockFetchOnce(500, "server error");
|
||||
const st = await checkMegaDebridAccount(megaAccount(), undefined, NOW);
|
||||
expect(st.valid).toBe(false);
|
||||
});
|
||||
|
||||
it("never throws on network error — returns a failed status", async () => {
|
||||
vi.stubGlobal("fetch", vi.fn(async () => { throw new Error("ECONNRESET"); }) as unknown as typeof fetch);
|
||||
const st = await checkMegaDebridAccount(megaAccount(), undefined, NOW);
|
||||
expect(st.valid).toBe(false);
|
||||
expect(st.message).toMatch(/Pruefung fehlgeschlagen/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("checkDebridLinkKey", () => {
|
||||
it("reports valid + premium from premiumLeft seconds", async () => {
|
||||
const premiumLeft = 60 * 24 * 60 * 60;
|
||||
mockFetchOnce(200, { success: true, value: { username: "u", accountType: 1, premiumLeft } });
|
||||
const st = await checkDebridLinkKey(debridLinkKey(), undefined, NOW);
|
||||
expect(st.valid).toBe(true);
|
||||
expect(st.isPremium).toBe(true);
|
||||
expect(st.premiumUntilMs).toBe(NOW + premiumLeft * 1000);
|
||||
});
|
||||
|
||||
it("reports valid but free (premiumLeft 0, accountType 0)", async () => {
|
||||
mockFetchOnce(200, { success: true, value: { username: "u", accountType: 0, premiumLeft: 0 } });
|
||||
const st = await checkDebridLinkKey(debridLinkKey(), undefined, NOW);
|
||||
expect(st.valid).toBe(true);
|
||||
expect(st.isPremium).toBe(false);
|
||||
expect(st.message).toMatch(/Free/);
|
||||
});
|
||||
|
||||
it("reports invalid key on HTTP 401", async () => {
|
||||
mockFetchOnce(401, { success: false, error: "badToken" });
|
||||
const st = await checkDebridLinkKey(debridLinkKey(), undefined, NOW);
|
||||
expect(st.valid).toBe(false);
|
||||
expect(st.message).toMatch(/Ungueltiger API-Key/);
|
||||
});
|
||||
|
||||
it("reports invalid key when success=false", async () => {
|
||||
mockFetchOnce(200, { success: false, error: "badToken" });
|
||||
const st = await checkDebridLinkKey(debridLinkKey(), undefined, NOW);
|
||||
expect(st.valid).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("checkAllDebridAccounts", () => {
|
||||
it("returns empty array when nothing configured", async () => {
|
||||
const settings = { megaCredentials: "", megaPassword: "", debridLinkApiKeys: "" } as unknown as AppSettings;
|
||||
const result = await checkAllDebridAccounts(settings);
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it("checks every configured mega account + debrid-link key", async () => {
|
||||
const futureSec = Math.floor(Date.now() / 1000) + 1000;
|
||||
vi.stubGlobal("fetch", vi.fn(async (url: string) => {
|
||||
if (String(url).includes("mega-debrid")) {
|
||||
return { ok: true, status: 200, text: async () => JSON.stringify({ response_code: "ok", token: "t", vip_end: String(futureSec) }) };
|
||||
}
|
||||
return { ok: true, status: 200, text: async () => JSON.stringify({ success: true, value: { accountType: 1, premiumLeft: 1000 } }) };
|
||||
}) as unknown as typeof fetch);
|
||||
|
||||
const settings = {
|
||||
megaCredentials: "a@b.de:pw1\nc@d.de:pw2",
|
||||
megaPassword: "",
|
||||
debridLinkApiKeys: "key1\nkey2\nkey3"
|
||||
} as unknown as AppSettings;
|
||||
|
||||
const result = await checkAllDebridAccounts(settings);
|
||||
expect(result).toHaveLength(5);
|
||||
expect(result.filter((r) => r.provider === "megadebrid")).toHaveLength(2);
|
||||
expect(result.filter((r) => r.provider === "debridlink")).toHaveLength(3);
|
||||
expect(result.every((r) => r.valid)).toBe(true);
|
||||
});
|
||||
|
||||
it("caps concurrency (never more than 4 in flight) and preserves result order", async () => {
|
||||
let inFlight = 0;
|
||||
let maxInFlight = 0;
|
||||
vi.stubGlobal("fetch", vi.fn(async () => {
|
||||
inFlight += 1;
|
||||
maxInFlight = Math.max(maxInFlight, inFlight);
|
||||
await new Promise((resolve) => setTimeout(resolve, 5));
|
||||
inFlight -= 1;
|
||||
return { ok: true, status: 200, text: async () => JSON.stringify({ success: true, value: { accountType: 1, premiumLeft: 1000 } }) };
|
||||
}) as unknown as typeof fetch);
|
||||
|
||||
const keys = Array.from({ length: 9 }, (_, i) => `key_${i}`).join("\n");
|
||||
const settings = { megaCredentials: "", megaPassword: "", debridLinkApiKeys: keys } as unknown as AppSettings;
|
||||
|
||||
const result = await checkAllDebridAccounts(settings);
|
||||
expect(result).toHaveLength(9);
|
||||
expect(maxInFlight).toBeLessThanOrEqual(4);
|
||||
result.forEach((r, i) => expect(r.label).toBe(`Key ${i + 1}`));
|
||||
});
|
||||
});
|
||||
|
||||
@ -1,35 +1,35 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { applyAccountDialogToSettings, AccountDialogState } from "../src/renderer/App";
|
||||
import { defaultSettings } from "../src/main/constants";
|
||||
|
||||
function megaDialog(kind: "megadebrid-api" | "megadebrid-web"): AccountDialogState {
|
||||
return {
|
||||
mode: "edit",
|
||||
kind,
|
||||
token: "",
|
||||
login: "",
|
||||
password: "",
|
||||
dailyLimitGb: "",
|
||||
keyDailyLimitGbById: {},
|
||||
megaAccounts: [{ login: "user@x", password: "pw" }],
|
||||
megaNewLogin: "",
|
||||
megaNewPassword: "",
|
||||
megaDisabledIds: []
|
||||
};
|
||||
}
|
||||
|
||||
describe("applyAccountDialogToSettings — keeps the user's Mega preferApi choice", () => {
|
||||
it("does not flip megaDebridPreferApi to true when editing the API account", () => {
|
||||
const settings = { ...defaultSettings(), megaDebridApiEnabled: true, megaDebridWebEnabled: true, megaDebridPreferApi: false };
|
||||
const next = applyAccountDialogToSettings(settings, megaDialog("megadebrid-api"));
|
||||
expect(next.megaDebridApiEnabled).toBe(true);
|
||||
expect(next.megaDebridPreferApi).toBe(false);
|
||||
});
|
||||
|
||||
it("does not flip megaDebridPreferApi to false when editing the Web account", () => {
|
||||
const settings = { ...defaultSettings(), megaDebridApiEnabled: true, megaDebridWebEnabled: true, megaDebridPreferApi: true };
|
||||
const next = applyAccountDialogToSettings(settings, megaDialog("megadebrid-web"));
|
||||
expect(next.megaDebridWebEnabled).toBe(true);
|
||||
expect(next.megaDebridPreferApi).toBe(true);
|
||||
});
|
||||
});
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { applyAccountDialogToSettings, AccountDialogState } from "../src/renderer/App";
|
||||
import { defaultSettings } from "../src/main/constants";
|
||||
|
||||
function megaDialog(kind: "megadebrid-api" | "megadebrid-web"): AccountDialogState {
|
||||
return {
|
||||
mode: "edit",
|
||||
kind,
|
||||
token: "",
|
||||
login: "",
|
||||
password: "",
|
||||
dailyLimitGb: "",
|
||||
keyDailyLimitGbById: {},
|
||||
megaAccounts: [{ login: "user@x", password: "pw" }],
|
||||
megaNewLogin: "",
|
||||
megaNewPassword: "",
|
||||
megaDisabledIds: []
|
||||
};
|
||||
}
|
||||
|
||||
describe("applyAccountDialogToSettings — keeps the user's Mega preferApi choice", () => {
|
||||
it("does not flip megaDebridPreferApi to true when editing the API account", () => {
|
||||
const settings = { ...defaultSettings(), megaDebridApiEnabled: true, megaDebridWebEnabled: true, megaDebridPreferApi: false };
|
||||
const next = applyAccountDialogToSettings(settings, megaDialog("megadebrid-api"));
|
||||
expect(next.megaDebridApiEnabled).toBe(true);
|
||||
expect(next.megaDebridPreferApi).toBe(false);
|
||||
});
|
||||
|
||||
it("does not flip megaDebridPreferApi to false when editing the Web account", () => {
|
||||
const settings = { ...defaultSettings(), megaDebridApiEnabled: true, megaDebridWebEnabled: true, megaDebridPreferApi: true };
|
||||
const next = applyAccountDialogToSettings(settings, megaDialog("megadebrid-web"));
|
||||
expect(next.megaDebridWebEnabled).toBe(true);
|
||||
expect(next.megaDebridPreferApi).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@ -1,57 +1,57 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { logAccountRotation, runWithRotationItemSink, getRecentRotationEvents } from "../src/main/account-rotation-log";
|
||||
import type { RotationEvent } from "../src/shared/types";
|
||||
|
||||
describe("rotation item-sink (AsyncLocalStorage)", () => {
|
||||
it("routes the FULL rotation trail (incl. TEST) to the active item sink", async () => {
|
||||
const captured: RotationEvent[] = [];
|
||||
await runWithRotationItemSink((ev) => captured.push(ev), async () => {
|
||||
logAccountRotation("INFO", "Mega-Debrid Web", "Account 1/3 (ab**xy)", "TEST", { link: "x" });
|
||||
logAccountRotation("WARN", "Mega-Debrid Web", "Account 1/3 (ab**xy)", "FAILED", { reason: "Timeout", cooldownSec: 30, next: "Account 2/3 (cd**zw)" });
|
||||
logAccountRotation("INFO", "Mega-Debrid Web", "Account 2/3 (cd**zw)", "TEST", { link: "x" });
|
||||
logAccountRotation("INFO", "Mega-Debrid Web", "Account 2/3 (cd**zw)", "OK", { fileName: "f.mkv" });
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
const events = captured.map((e) => e.event);
|
||||
expect(events).toEqual(["TEST", "FAILED", "TEST", "OK"]);
|
||||
const failed = captured.find((e) => e.event === "FAILED");
|
||||
expect(failed?.reason).toBe("Timeout");
|
||||
expect(failed?.next).toBe("Account 2/3 (cd**zw)");
|
||||
});
|
||||
|
||||
it("does not leak events to the sink outside the run() scope", () => {
|
||||
const captured: RotationEvent[] = [];
|
||||
logAccountRotation("INFO", "Debrid-Link", "Key 1/2 (k1)", "OK");
|
||||
expect(captured).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("isolates two parallel item sinks (no cross-attribution)", async () => {
|
||||
const a: RotationEvent[] = [];
|
||||
const b: RotationEvent[] = [];
|
||||
await Promise.all([
|
||||
runWithRotationItemSink((ev) => a.push(ev), async () => {
|
||||
logAccountRotation("INFO", "Mega-Debrid Web", "Account 1 (a)", "TEST");
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
logAccountRotation("INFO", "Mega-Debrid Web", "Account 1 (a)", "OK");
|
||||
}),
|
||||
runWithRotationItemSink((ev) => b.push(ev), async () => {
|
||||
logAccountRotation("INFO", "Debrid-Link", "Key 1 (b)", "TEST");
|
||||
await new Promise((r) => setTimeout(r, 5));
|
||||
logAccountRotation("WARN", "Debrid-Link", "Key 1 (b)", "FAILED", { reason: "badToken" });
|
||||
})
|
||||
]);
|
||||
expect(a.every((e) => e.provider === "Mega-Debrid Web")).toBe(true);
|
||||
expect(b.every((e) => e.provider === "Debrid-Link")).toBe(true);
|
||||
expect(a.map((e) => e.event)).toEqual(["TEST", "OK"]);
|
||||
expect(b.map((e) => e.event)).toEqual(["TEST", "FAILED"]);
|
||||
});
|
||||
|
||||
it("still feeds the global UI ring (outcomes only, TEST filtered)", () => {
|
||||
logAccountRotation("INFO", "Mega-Debrid API", "Account 9 (zz)", "TEST");
|
||||
logAccountRotation("INFO", "Mega-Debrid API", "Account 9 (zz)", "OK", { fileName: "ring.mkv" });
|
||||
const ring = getRecentRotationEvents(10);
|
||||
expect(ring.some((e) => e.event === "OK" && e.accountLabel === "Account 9 (zz)")).toBe(true);
|
||||
expect(ring.some((e) => e.event === "TEST" && e.accountLabel === "Account 9 (zz)")).toBe(false);
|
||||
});
|
||||
});
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { logAccountRotation, runWithRotationItemSink, getRecentRotationEvents } from "../src/main/account-rotation-log";
|
||||
import type { RotationEvent } from "../src/shared/types";
|
||||
|
||||
describe("rotation item-sink (AsyncLocalStorage)", () => {
|
||||
it("routes the FULL rotation trail (incl. TEST) to the active item sink", async () => {
|
||||
const captured: RotationEvent[] = [];
|
||||
await runWithRotationItemSink((ev) => captured.push(ev), async () => {
|
||||
logAccountRotation("INFO", "Mega-Debrid Web", "Account 1/3 (ab**xy)", "TEST", { link: "x" });
|
||||
logAccountRotation("WARN", "Mega-Debrid Web", "Account 1/3 (ab**xy)", "FAILED", { reason: "Timeout", cooldownSec: 30, next: "Account 2/3 (cd**zw)" });
|
||||
logAccountRotation("INFO", "Mega-Debrid Web", "Account 2/3 (cd**zw)", "TEST", { link: "x" });
|
||||
logAccountRotation("INFO", "Mega-Debrid Web", "Account 2/3 (cd**zw)", "OK", { fileName: "f.mkv" });
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
const events = captured.map((e) => e.event);
|
||||
expect(events).toEqual(["TEST", "FAILED", "TEST", "OK"]);
|
||||
const failed = captured.find((e) => e.event === "FAILED");
|
||||
expect(failed?.reason).toBe("Timeout");
|
||||
expect(failed?.next).toBe("Account 2/3 (cd**zw)");
|
||||
});
|
||||
|
||||
it("does not leak events to the sink outside the run() scope", () => {
|
||||
const captured: RotationEvent[] = [];
|
||||
logAccountRotation("INFO", "Debrid-Link", "Key 1/2 (k1)", "OK");
|
||||
expect(captured).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("isolates two parallel item sinks (no cross-attribution)", async () => {
|
||||
const a: RotationEvent[] = [];
|
||||
const b: RotationEvent[] = [];
|
||||
await Promise.all([
|
||||
runWithRotationItemSink((ev) => a.push(ev), async () => {
|
||||
logAccountRotation("INFO", "Mega-Debrid Web", "Account 1 (a)", "TEST");
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
logAccountRotation("INFO", "Mega-Debrid Web", "Account 1 (a)", "OK");
|
||||
}),
|
||||
runWithRotationItemSink((ev) => b.push(ev), async () => {
|
||||
logAccountRotation("INFO", "Debrid-Link", "Key 1 (b)", "TEST");
|
||||
await new Promise((r) => setTimeout(r, 5));
|
||||
logAccountRotation("WARN", "Debrid-Link", "Key 1 (b)", "FAILED", { reason: "badToken" });
|
||||
})
|
||||
]);
|
||||
expect(a.every((e) => e.provider === "Mega-Debrid Web")).toBe(true);
|
||||
expect(b.every((e) => e.provider === "Debrid-Link")).toBe(true);
|
||||
expect(a.map((e) => e.event)).toEqual(["TEST", "OK"]);
|
||||
expect(b.map((e) => e.event)).toEqual(["TEST", "FAILED"]);
|
||||
});
|
||||
|
||||
it("still feeds the global UI ring (outcomes only, TEST filtered)", () => {
|
||||
logAccountRotation("INFO", "Mega-Debrid API", "Account 9 (zz)", "TEST");
|
||||
logAccountRotation("INFO", "Mega-Debrid API", "Account 9 (zz)", "OK", { fileName: "ring.mkv" });
|
||||
const ring = getRecentRotationEvents(10);
|
||||
expect(ring.some((e) => e.event === "OK" && e.accountLabel === "Account 9 (zz)")).toBe(true);
|
||||
expect(ring.some((e) => e.event === "TEST" && e.accountLabel === "Account 9 (zz)")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@ -1,49 +1,49 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { reorderPackageOrderByDrop, sortPackageOrderByName } from "../src/renderer/package-order";
|
||||
|
||||
describe("reorderPackageOrderByDrop", () => {
|
||||
it("moves adjacent package down by one on drop", () => {
|
||||
const next = reorderPackageOrderByDrop(["a", "b", "c"], "b", "c");
|
||||
expect(next).toEqual(["a", "c", "b"]);
|
||||
});
|
||||
|
||||
it("moves package after lower drop target", () => {
|
||||
const next = reorderPackageOrderByDrop(["a", "b", "c", "d"], "a", "c");
|
||||
expect(next).toEqual(["b", "c", "a", "d"]);
|
||||
});
|
||||
|
||||
it("returns original order when ids are invalid", () => {
|
||||
const order = ["a", "b", "c"];
|
||||
expect(reorderPackageOrderByDrop(order, "x", "b")).toEqual(order);
|
||||
expect(reorderPackageOrderByDrop(order, "a", "x")).toEqual(order);
|
||||
expect(reorderPackageOrderByDrop(order, "a", "a")).toEqual(order);
|
||||
});
|
||||
});
|
||||
|
||||
describe("sortPackageOrderByName", () => {
|
||||
it("sorts package IDs alphabetically ascending", () => {
|
||||
const sorted = sortPackageOrderByName(
|
||||
["pkg3", "pkg1", "pkg2"],
|
||||
{
|
||||
pkg1: { id: "pkg1", name: "Alpha", outputDir: "", extractDir: "", status: "queued", itemIds: [], cancelled: false, enabled: true, priority: "normal", createdAt: 0, updatedAt: 0 },
|
||||
pkg2: { id: "pkg2", name: "beta", outputDir: "", extractDir: "", status: "queued", itemIds: [], cancelled: false, enabled: true, priority: "normal", createdAt: 0, updatedAt: 0 },
|
||||
pkg3: { id: "pkg3", name: "Gamma", outputDir: "", extractDir: "", status: "queued", itemIds: [], cancelled: false, enabled: true, priority: "normal", createdAt: 0, updatedAt: 0 }
|
||||
},
|
||||
false
|
||||
);
|
||||
expect(sorted).toEqual(["pkg1", "pkg2", "pkg3"]);
|
||||
});
|
||||
|
||||
it("sorts package IDs alphabetically descending", () => {
|
||||
const sorted = sortPackageOrderByName(
|
||||
["pkg1", "pkg2", "pkg3"],
|
||||
{
|
||||
pkg1: { id: "pkg1", name: "Alpha", outputDir: "", extractDir: "", status: "queued", itemIds: [], cancelled: false, enabled: true, priority: "normal", createdAt: 0, updatedAt: 0 },
|
||||
pkg2: { id: "pkg2", name: "beta", outputDir: "", extractDir: "", status: "queued", itemIds: [], cancelled: false, enabled: true, priority: "normal", createdAt: 0, updatedAt: 0 },
|
||||
pkg3: { id: "pkg3", name: "Gamma", outputDir: "", extractDir: "", status: "queued", itemIds: [], cancelled: false, enabled: true, priority: "normal", createdAt: 0, updatedAt: 0 }
|
||||
},
|
||||
true
|
||||
);
|
||||
expect(sorted).toEqual(["pkg3", "pkg2", "pkg1"]);
|
||||
});
|
||||
});
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { reorderPackageOrderByDrop, sortPackageOrderByName } from "../src/renderer/package-order";
|
||||
|
||||
describe("reorderPackageOrderByDrop", () => {
|
||||
it("moves adjacent package down by one on drop", () => {
|
||||
const next = reorderPackageOrderByDrop(["a", "b", "c"], "b", "c");
|
||||
expect(next).toEqual(["a", "c", "b"]);
|
||||
});
|
||||
|
||||
it("moves package after lower drop target", () => {
|
||||
const next = reorderPackageOrderByDrop(["a", "b", "c", "d"], "a", "c");
|
||||
expect(next).toEqual(["b", "c", "a", "d"]);
|
||||
});
|
||||
|
||||
it("returns original order when ids are invalid", () => {
|
||||
const order = ["a", "b", "c"];
|
||||
expect(reorderPackageOrderByDrop(order, "x", "b")).toEqual(order);
|
||||
expect(reorderPackageOrderByDrop(order, "a", "x")).toEqual(order);
|
||||
expect(reorderPackageOrderByDrop(order, "a", "a")).toEqual(order);
|
||||
});
|
||||
});
|
||||
|
||||
describe("sortPackageOrderByName", () => {
|
||||
it("sorts package IDs alphabetically ascending", () => {
|
||||
const sorted = sortPackageOrderByName(
|
||||
["pkg3", "pkg1", "pkg2"],
|
||||
{
|
||||
pkg1: { id: "pkg1", name: "Alpha", outputDir: "", extractDir: "", status: "queued", itemIds: [], cancelled: false, enabled: true, priority: "normal", createdAt: 0, updatedAt: 0 },
|
||||
pkg2: { id: "pkg2", name: "beta", outputDir: "", extractDir: "", status: "queued", itemIds: [], cancelled: false, enabled: true, priority: "normal", createdAt: 0, updatedAt: 0 },
|
||||
pkg3: { id: "pkg3", name: "Gamma", outputDir: "", extractDir: "", status: "queued", itemIds: [], cancelled: false, enabled: true, priority: "normal", createdAt: 0, updatedAt: 0 }
|
||||
},
|
||||
false
|
||||
);
|
||||
expect(sorted).toEqual(["pkg1", "pkg2", "pkg3"]);
|
||||
});
|
||||
|
||||
it("sorts package IDs alphabetically descending", () => {
|
||||
const sorted = sortPackageOrderByName(
|
||||
["pkg1", "pkg2", "pkg3"],
|
||||
{
|
||||
pkg1: { id: "pkg1", name: "Alpha", outputDir: "", extractDir: "", status: "queued", itemIds: [], cancelled: false, enabled: true, priority: "normal", createdAt: 0, updatedAt: 0 },
|
||||
pkg2: { id: "pkg2", name: "beta", outputDir: "", extractDir: "", status: "queued", itemIds: [], cancelled: false, enabled: true, priority: "normal", createdAt: 0, updatedAt: 0 },
|
||||
pkg3: { id: "pkg3", name: "Gamma", outputDir: "", extractDir: "", status: "queued", itemIds: [], cancelled: false, enabled: true, priority: "normal", createdAt: 0, updatedAt: 0 }
|
||||
},
|
||||
true
|
||||
);
|
||||
expect(sorted).toEqual(["pkg3", "pkg2", "pkg1"]);
|
||||
});
|
||||
});
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user