Compare commits
No commits in common. "v2.0.0" and "main" have entirely different histories.
53
.gitignore
vendored
53
.gitignore
vendored
@ -1,18 +1,49 @@
|
|||||||
node_modules/
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
*.pyo
|
||||||
|
*.pyd
|
||||||
|
|
||||||
|
.venv/
|
||||||
|
venv/
|
||||||
|
|
||||||
build/
|
build/
|
||||||
dist/
|
dist/
|
||||||
release/
|
release/
|
||||||
.vite/
|
*.spec
|
||||||
coverage/
|
|
||||||
*.log
|
|
||||||
*.log.old
|
|
||||||
*.bak
|
|
||||||
rd_downloader_config.json
|
rd_downloader_config.json
|
||||||
|
rd_downloader.log
|
||||||
rd_download_manifest.json
|
rd_download_manifest.json
|
||||||
_update_staging/
|
_update_staging/
|
||||||
apply_update.cmd
|
apply_update.cmd
|
||||||
.env
|
|
||||||
.env.*
|
.claude/
|
||||||
!.env.example
|
.github/
|
||||||
.DS_Store
|
CHANGELOG.md
|
||||||
Thumbs.db
|
|
||||||
|
node_modules/
|
||||||
|
.vite/
|
||||||
|
coverage/
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
|
||||||
|
deploy/forgejo/.env
|
||||||
|
deploy/forgejo/forgejo/
|
||||||
|
deploy/forgejo/postgres/
|
||||||
|
deploy/forgejo/caddy/data/
|
||||||
|
deploy/forgejo/caddy/config/
|
||||||
|
deploy/forgejo/caddy/logs/
|
||||||
|
deploy/forgejo/backups/
|
||||||
|
.secrets
|
||||||
|
|
||||||
|
*.log.old
|
||||||
|
*.bak
|
||||||
|
|
||||||
|
rust-postprocess/
|
||||||
|
electron-postprocess/
|
||||||
|
python-postprocess/
|
||||||
|
scripts/*.py
|
||||||
|
scripts/*.ps1
|
||||||
|
scripts/*.md
|
||||||
|
scripts/fix-library-renames.mjs
|
||||||
|
|||||||
28
CLAUDE.md
Normal file
28
CLAUDE.md
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
## Release + Update Source (Wichtig)
|
||||||
|
|
||||||
|
- Primäre Plattform ist `https://git.24-music.de`
|
||||||
|
- Standard-Repo: `Administrator/real-debrid-downloader`
|
||||||
|
- Nicht mehr primär über Codeberg/GitHub releasen
|
||||||
|
|
||||||
|
## Releasen
|
||||||
|
|
||||||
|
Der Token liegt in `.secrets` (gitignored) und wird automatisch geladen.
|
||||||
|
|
||||||
|
Als KI-Agent: Token aus `.secrets` lesen und als Umgebungsvariable setzen, dann Release-Script ausführen:
|
||||||
|
```bash
|
||||||
|
export $(cat .secrets | xargs) && npm run release:gitea -- <version> [notes]
|
||||||
|
```
|
||||||
|
|
||||||
|
Manuell in PowerShell (falls nötig):
|
||||||
|
- `npm run release:gitea -- <version> [notes]` (Token ist bereits als Benutzervariable gesetzt)
|
||||||
|
|
||||||
|
Das Script:
|
||||||
|
- bumped `package.json`
|
||||||
|
- baut Windows-Artefakte
|
||||||
|
- pusht `main` + Tag
|
||||||
|
- erstellt Release auf `git.24-music.de`
|
||||||
|
- lädt Assets hoch
|
||||||
|
|
||||||
|
## Auto-Update
|
||||||
|
|
||||||
|
- Updater nutzt aktuell `git.24-music.de` als Standardquelle
|
||||||
21
LICENSE
21
LICENSE
@ -1,21 +0,0 @@
|
|||||||
MIT License
|
|
||||||
|
|
||||||
Copyright (c) 2026 Sucukdeluxe
|
|
||||||
|
|
||||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
||||||
of this software and associated documentation files (the "Software"), to deal
|
|
||||||
in the Software without restriction, including without limitation the rights
|
|
||||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
||||||
copies of the Software, and to permit persons to whom the Software is
|
|
||||||
furnished to do so, subject to the following conditions:
|
|
||||||
|
|
||||||
The above copyright notice and this permission notice shall be included in all
|
|
||||||
copies or substantial portions of the Software.
|
|
||||||
|
|
||||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
||||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
||||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
||||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
||||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
||||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
||||||
SOFTWARE.
|
|
||||||
607
README.md
607
README.md
@ -1,308 +1,307 @@
|
|||||||
# Multi Debrid Downloader
|
# 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.
|
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?
|
## Why this tool?
|
||||||
|
|
||||||
- JDownloader-style workflow with packages, progress, extraction, history, and clean post-processing.
|
- JDownloader-style workflow with packages, progress, extraction, history, and clean post-processing.
|
||||||
- Multiple debrid accounts in one app, including provider order, automatic fallback, and per-hoster routing.
|
- Multiple debrid accounts in one app, including provider order, automatic fallback, and per-hoster routing.
|
||||||
- Built for large queues with session persistence, retries, reconnect handling, resume support, and integrity checks.
|
- Built for large queues with session persistence, retries, reconnect handling, resume support, and integrity checks.
|
||||||
- Includes an in-app updater for releases published on GitHub.
|
- Includes an in-app updater for releases published on `git.24-music.de`.
|
||||||
|
|
||||||
## Supported providers
|
## Supported providers
|
||||||
|
|
||||||
- AllDebrid API
|
- AllDebrid API
|
||||||
- AllDebrid Web via browser login
|
- AllDebrid Web via browser login
|
||||||
- BestDebrid API
|
- BestDebrid API
|
||||||
- BestDebrid Web via cookie import
|
- BestDebrid Web via cookie import
|
||||||
- Debrid-Link with multi-key support
|
- Debrid-Link with multi-key support
|
||||||
- DDownload login
|
- DDownload login
|
||||||
- 1fichier API
|
- 1fichier API
|
||||||
- LinkSnappy login
|
- LinkSnappy login
|
||||||
- Mega-Debrid API
|
- Mega-Debrid API
|
||||||
- Mega-Debrid Web
|
- Mega-Debrid Web
|
||||||
- Real-Debrid
|
- Real-Debrid
|
||||||
|
|
||||||
## Core features
|
## Core features
|
||||||
|
|
||||||
### Queue and package handling
|
### Queue and package handling
|
||||||
|
|
||||||
- Package-based queue with item status, retries, ETA, speed, provider, and account label.
|
- 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.
|
- Start, pause, stop, cancel, reset, rename, and delete for packages and items.
|
||||||
- Ctrl+Click multi-select and bulk actions.
|
- Ctrl+Click multi-select and bulk actions.
|
||||||
- Queue backup import/export as JSON.
|
- Queue backup import/export as JSON.
|
||||||
- Context-menu export for selected packages or selected items as structured TXT re-import files.
|
- Context-menu export for selected packages or selected items as structured TXT re-import files.
|
||||||
- Duplicate handling when adding links: keep, skip, or overwrite.
|
- Duplicate handling when adding links: keep, skip, or overwrite.
|
||||||
- Optional start scheduling for a specific time.
|
- Optional start scheduling for a specific time.
|
||||||
- Session recovery after restart with optional auto-resume.
|
- Session recovery after restart with optional auto-resume.
|
||||||
- Optional auto-sorting by progress.
|
- Optional auto-sorting by progress.
|
||||||
|
|
||||||
### Link collection
|
### Link collection
|
||||||
|
|
||||||
- Paste links directly into the collector.
|
- Paste links directly into the collector.
|
||||||
- Import `.txt` export files that preserve package names and optional per-file names.
|
- Import `.txt` export files that preserve package names and optional per-file names.
|
||||||
- Clipboard watcher with automatic link detection.
|
- Clipboard watcher with automatic link detection.
|
||||||
- `.dlc` import via file picker and drag-and-drop.
|
- `.dlc` import via file picker and drag-and-drop.
|
||||||
- Drag-and-drop of plain links, `.txt` export files, and supported container files.
|
- Drag-and-drop of plain links, `.txt` export files, and supported container files.
|
||||||
|
|
||||||
### Provider routing and fallback
|
### Provider routing and fallback
|
||||||
|
|
||||||
- Configurable provider order with primary, secondary, and tertiary fallback.
|
- Configurable provider order with primary, secondary, and tertiary fallback.
|
||||||
- Optional automatic provider fallback on unrestrict/download failures.
|
- Optional automatic provider fallback on unrestrict/download failures.
|
||||||
- Per-hoster routing override, so specific hosters can always use a specific provider.
|
- Per-hoster routing override, so specific hosters can always use a specific provider.
|
||||||
- Providers can be disabled without deleting stored account data.
|
- Providers can be disabled without deleting stored account data.
|
||||||
- Daily traffic limits per provider.
|
- Daily traffic limits per provider.
|
||||||
- Debrid-Link per-key daily limits and per-key daily usage tracking.
|
- Debrid-Link per-key daily limits and per-key daily usage tracking.
|
||||||
|
|
||||||
### Accounts and provider tools
|
### Accounts and provider tools
|
||||||
|
|
||||||
- Central Accounts view with account type, status, info, access data, and actions.
|
- Central Accounts view with account type, status, info, access data, and actions.
|
||||||
- BestDebrid cookie import directly from a Netscape cookies file.
|
- BestDebrid cookie import directly from a Netscape cookies file.
|
||||||
- AllDebrid browser-login flow and in-app Rapidgator host status display.
|
- 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 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.
|
- 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.
|
- Reset button for stored account column widths in the Accounts table.
|
||||||
|
|
||||||
### Download engine
|
### Download engine
|
||||||
|
|
||||||
- Parallel downloads with resumable transfers when supported.
|
- Parallel downloads with resumable transfers when supported.
|
||||||
- Reconnect handling with configurable wait time.
|
- Reconnect handling with configurable wait time.
|
||||||
- Circuit-breaker style cooldown and retry handling for provider issues.
|
- Circuit-breaker style cooldown and retry handling for provider issues.
|
||||||
- Global speed limit or per-download speed limit mode.
|
- Global speed limit or per-download speed limit mode.
|
||||||
- Bandwidth schedules with time windows and speed caps.
|
- Bandwidth schedules with time windows and speed caps.
|
||||||
- Live bandwidth chart and session statistics.
|
- Live bandwidth chart and session statistics.
|
||||||
- Persistent all-time download counter.
|
- Persistent all-time download counter.
|
||||||
|
|
||||||
### Extraction and post-processing
|
### Extraction and post-processing
|
||||||
|
|
||||||
- Automatic extraction after download.
|
- Automatic extraction after download.
|
||||||
- Extraction can continue even when the session is stopped or after app restart.
|
- Extraction can continue even when the session is stopped or after app restart.
|
||||||
- Hybrid download + extract workflow.
|
- Hybrid download + extract workflow.
|
||||||
- Extraction backend using native tools by default, with JVM sidecar support available.
|
- Extraction backend using native tools by default, with JVM sidecar support available.
|
||||||
- Supports common archive formats including RAR, ZIP, and 7z.
|
- Supports common archive formats including RAR, ZIP, and 7z.
|
||||||
- Nested extraction for archives found inside extracted output.
|
- Nested extraction for archives found inside extracted output.
|
||||||
- Conflict handling: overwrite, skip, rename, or ask.
|
- Conflict handling: overwrite, skip, rename, or ask.
|
||||||
- Disk-space validation before extraction.
|
- Disk-space validation before extraction.
|
||||||
- Package-scoped password reuse for multi-archive sets.
|
- Package-scoped password reuse for multi-archive sets.
|
||||||
- Optional cleanup of downloaded archives after extraction.
|
- Optional cleanup of downloaded archives after extraction.
|
||||||
- Optional cleanup of link artifacts and sample files after extraction.
|
- Optional cleanup of link artifacts and sample files after extraction.
|
||||||
- Optional flat MKV collection folder after package completion.
|
- Optional flat MKV collection folder after package completion.
|
||||||
|
|
||||||
### Auto-rename and media cleanup
|
### Auto-rename and media cleanup
|
||||||
|
|
||||||
- Auto-rename for extracted scene-style files based on folder/source naming.
|
- Auto-rename for extracted scene-style files based on folder/source naming.
|
||||||
- Multi-episode token parsing.
|
- Multi-episode token parsing.
|
||||||
- Handles compact episode tokens like `s02e01` directly attached to the title.
|
- Handles compact episode tokens like `s02e01` directly attached to the title.
|
||||||
- Optional skip of already extracted packages on start.
|
- Optional skip of already extracted packages on start.
|
||||||
|
|
||||||
### Integrity, history, and backup
|
### Integrity, history, and backup
|
||||||
|
|
||||||
- Optional integrity verification with `CRC32`, `MD5`, and `SHA1`.
|
- Optional integrity verification with `CRC32`, `MD5`, and `SHA1`.
|
||||||
- Download history with package details, duration, size, provider, and target folder.
|
- Download history with package details, duration, size, provider, and target folder.
|
||||||
- Backup export/import for restoring app state.
|
- Backup export/import for restoring app state.
|
||||||
- Persistent config, session, and history files in the Electron `userData` directory.
|
- Persistent config, session, and history files in the Electron `userData` directory.
|
||||||
|
|
||||||
### UI and desktop integration
|
### UI and desktop integration
|
||||||
|
|
||||||
- Downloads, history, statistics, and settings tabs.
|
- Downloads, history, statistics, and settings tabs.
|
||||||
- Progress bars for packages and single items.
|
- Progress bars for packages and single items.
|
||||||
- Hoster/provider display showing both source and effective debrid account.
|
- Hoster/provider display showing both source and effective debrid account.
|
||||||
- Minimize-to-tray support.
|
- Minimize-to-tray support.
|
||||||
- Dark/light theme setting.
|
- Dark/light theme setting.
|
||||||
- Long path support on Windows.
|
- Long path support on Windows.
|
||||||
- Default startup window size of `1920x1080`.
|
- Default startup window size of `1920x1080`.
|
||||||
|
|
||||||
## Installation
|
## Installation
|
||||||
|
|
||||||
### Prebuilt releases
|
### Prebuilt releases
|
||||||
|
|
||||||
1. Download the latest installer or portable build from the releases page.
|
1. Download the latest installer or portable build from the releases page.
|
||||||
2. Start the app.
|
2. Start the app.
|
||||||
3. Add your provider credentials in `Settings > Accounts`.
|
3. Add your provider credentials in `Settings > Accounts`.
|
||||||
|
|
||||||
Releases: [GitHub Releases](https://github.com/Sucukdeluxe/multi-debrid-downloader/releases)
|
Releases: [git.24-music.de Releases](https://git.24-music.de/Administrator/real-debrid-downloader/releases)
|
||||||
|
|
||||||
### Build from source
|
### Build from source
|
||||||
|
|
||||||
Requirements:
|
Requirements:
|
||||||
|
|
||||||
- Node.js `20+`
|
- Node.js `20+`
|
||||||
- npm
|
- npm
|
||||||
- Windows `10/11`
|
- Windows `10/11`
|
||||||
- Java Runtime `8+` for the optional JVM extraction backend
|
- Java Runtime `8+` for the optional JVM extraction backend
|
||||||
- Optional native extraction tools: 7-Zip / WinRAR / UnRAR
|
- Optional native extraction tools: 7-Zip / WinRAR / UnRAR
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm install
|
npm install
|
||||||
npm run dev
|
npm run dev
|
||||||
```
|
```
|
||||||
|
|
||||||
## NPM scripts
|
## NPM scripts
|
||||||
|
|
||||||
| Command | Description |
|
| Command | Description |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| `npm run dev` | Starts Vite, tsup watchers, and Electron in development mode |
|
| `npm run dev` | Starts Vite, tsup watchers, and Electron in development mode |
|
||||||
| `npm run build` | Builds main and renderer bundles |
|
| `npm run build` | Builds main and renderer bundles |
|
||||||
| `npm run start` | Starts the built app in production mode |
|
| `npm run start` | Starts the built app in production mode |
|
||||||
| `npm test` | Runs Vitest unit tests |
|
| `npm test` | Runs Vitest unit tests |
|
||||||
| `npm run self-check` | Runs integrated self-checks |
|
| `npm run self-check` | Runs integrated self-checks |
|
||||||
| `npm run verify:release -- --verify-archives --seven-zip <path-to-7z.exe>` | Verifies metadata, unpacked redistribution files, and the nested Setup/Portable payloads |
|
|
||||||
| `npm run release:win` | Builds Windows installer and portable EXE |
|
| `npm run release:win` | Builds Windows installer and portable EXE |
|
||||||
|
| `npm run release:gitea -- <version> [notes]` | Builds, tags, and uploads a release to `git.24-music.de` |
|
||||||
## Typical workflow
|
| `npm run release:forgejo -- <version> [notes]` | Alias for the same release 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`.
|
|
||||||
|
|
||||||
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
28
package.json
28
package.json
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "real-debrid-downloader",
|
"name": "real-debrid-downloader",
|
||||||
"version": "2.0.0",
|
"version": "1.7.232",
|
||||||
"description": "Desktop downloader",
|
"description": "Desktop downloader",
|
||||||
"main": "build/main/main/main.js",
|
"main": "build/main/main/main.js",
|
||||||
"author": "Sucukdeluxe",
|
"author": "Sucukdeluxe",
|
||||||
@ -17,13 +17,14 @@
|
|||||||
"test": "vitest run",
|
"test": "vitest run",
|
||||||
"self-check": "tsx tests/self-check.ts",
|
"self-check": "tsx tests/self-check.ts",
|
||||||
"release:win": "npm run build && electron-builder --publish never --win nsis portable",
|
"release:win": "npm run build && electron-builder --publish never --win nsis portable",
|
||||||
"verify:release": "node scripts/verify_public_release.mjs"
|
"release:gitea": "node scripts/release_gitea.mjs",
|
||||||
|
"release:forgejo": "node scripts/release_gitea.mjs"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"adm-zip": "0.6.0",
|
"adm-zip": "^0.5.16",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-dom": "^18.3.1",
|
"react-dom": "^18.3.1",
|
||||||
"uuid": "11.1.1"
|
"uuid": "^11.1.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/adm-zip": "^0.5.7",
|
"@types/adm-zip": "^0.5.7",
|
||||||
@ -42,8 +43,7 @@
|
|||||||
"typescript": "^5.7.3",
|
"typescript": "^5.7.3",
|
||||||
"vite": "^6.0.5",
|
"vite": "^6.0.5",
|
||||||
"vitest": "^2.1.8",
|
"vitest": "^2.1.8",
|
||||||
"wait-on": "^8.0.1",
|
"wait-on": "^8.0.1"
|
||||||
"yaml": "^2.9.0"
|
|
||||||
},
|
},
|
||||||
"build": {
|
"build": {
|
||||||
"appId": "com.sucukdeluxe.realdebrid",
|
"appId": "com.sucukdeluxe.realdebrid",
|
||||||
@ -52,24 +52,12 @@
|
|||||||
"buildResources": "assets",
|
"buildResources": "assets",
|
||||||
"output": "release"
|
"output": "release"
|
||||||
},
|
},
|
||||||
"publish": {
|
|
||||||
"provider": "github",
|
|
||||||
"owner": "Sucukdeluxe",
|
|
||||||
"repo": "multi-debrid-downloader"
|
|
||||||
},
|
|
||||||
"files": [
|
"files": [
|
||||||
"build/main/**/*",
|
"build/main/**/*",
|
||||||
"build/renderer/**/*",
|
"build/renderer/**/*",
|
||||||
"resources/extractor-jvm/**/*",
|
"resources/extractor-jvm/**/*",
|
||||||
"LICENSE",
|
|
||||||
"package.json"
|
"package.json"
|
||||||
],
|
],
|
||||||
"extraResources": [
|
|
||||||
{
|
|
||||||
"from": "LICENSE",
|
|
||||||
"to": "LICENSE"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"asarUnpack": [
|
"asarUnpack": [
|
||||||
"resources/extractor-jvm/**/*"
|
"resources/extractor-jvm/**/*"
|
||||||
],
|
],
|
||||||
@ -82,15 +70,11 @@
|
|||||||
"signAndEditExecutable": false
|
"signAndEditExecutable": false
|
||||||
},
|
},
|
||||||
"nsis": {
|
"nsis": {
|
||||||
"artifactName": "${productName}-Setup-${version}.${ext}",
|
|
||||||
"oneClick": false,
|
"oneClick": false,
|
||||||
"perMachine": false,
|
"perMachine": false,
|
||||||
"allowToChangeInstallationDirectory": true,
|
"allowToChangeInstallationDirectory": true,
|
||||||
"createDesktopShortcut": true
|
"createDesktopShortcut": true
|
||||||
},
|
},
|
||||||
"portable": {
|
|
||||||
"artifactName": "${productName}-${version}-portable.${ext}"
|
|
||||||
},
|
|
||||||
"afterPack": "scripts/afterPack.cjs"
|
"afterPack": "scripts/afterPack.cjs"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,22 +1,22 @@
|
|||||||
# JVM extractor runtime
|
# JVM extractor runtime
|
||||||
|
|
||||||
This directory contains the Java sidecar runtime used by `src/main/extractor.ts`.
|
This directory contains the Java sidecar runtime used by `src/main/extractor.ts`.
|
||||||
|
|
||||||
## Included backends
|
## Included backends
|
||||||
|
|
||||||
- `sevenzipjbinding` for the primary extraction path (RAR/7z/ZIP and others)
|
- `sevenzipjbinding` for the primary extraction path (RAR/7z/ZIP and others)
|
||||||
- `zip4j` for ZIP multipart handling (JD-style split ZIP behavior)
|
- `zip4j` for ZIP multipart handling (JD-style split ZIP behavior)
|
||||||
|
|
||||||
## Layout
|
## Layout
|
||||||
|
|
||||||
- `classes/` compiled `JBindExtractorMain` classes
|
- `classes/` compiled `JBindExtractorMain` classes
|
||||||
- `lib/` runtime jars required by the sidecar
|
- `lib/` runtime jars required by the sidecar
|
||||||
- `src/` Java source for the sidecar
|
- `src/` Java source for the sidecar
|
||||||
|
|
||||||
## Rebuild notes
|
## Rebuild notes
|
||||||
|
|
||||||
The checked-in classes are Java 8 compatible and built from:
|
The checked-in classes are Java 8 compatible and built from:
|
||||||
|
|
||||||
`resources/extractor-jvm/src/com/sucukdeluxe/extractor/JBindExtractorMain.java`
|
`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.
|
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 |
|
1) sevenzipjbinding (16.02-2.01)
|
||||||
| --- | --- | --- | --- | --- |
|
- Maven artifact: net.sf.sevenzipjbinding:sevenzipjbinding
|
||||||
| net.sf.sevenzipjbinding:sevenzipjbinding:16.02-2.01 | 16.02-2.01 | GNU Lesser General Public License 2.1 or later; bundled 7-Zip code includes the unRAR restriction | licenses/LGPL-2.1.txt; licenses/7-Zip-license.txt | https://sevenzipjbind.sourceforge.net/ |
|
- Maven artifact: net.sf.sevenzipjbinding:sevenzipjbinding-all-platforms
|
||||||
| net.sf.sevenzipjbinding:sevenzipjbinding-all-platforms:16.02-2.01 | 16.02-2.01 | GNU Lesser General Public License 2.1 or later; bundled 7-Zip code includes the unRAR restriction | licenses/LGPL-2.1.txt; licenses/7-Zip-license.txt | https://sevenzipjbind.sourceforge.net/ |
|
- Upstream: https://sevenzipjbind.sourceforge.net/
|
||||||
| net.lingala.zip4j:zip4j:2.11.5 | 2.11.5 | Apache License 2.0 | licenses/Apache-2.0.txt | https://github.com/srikanth-lingala/zip4j |
|
|
||||||
|
2) zip4j (2.11.5)
|
||||||
|
- Maven artifact: net.lingala.zip4j:zip4j
|
||||||
|
- Upstream: https://github.com/srikanth-lingala/zip4j
|
||||||
|
|
||||||
|
Please review upstream licenses and notices before redistribution.
|
||||||
|
|||||||
@ -1,146 +0,0 @@
|
|||||||
7-Zip
|
|
||||||
~~~~~
|
|
||||||
License for use and distribution
|
|
||||||
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
|
||||||
|
|
||||||
7-Zip Copyright (C) 1999-2025 Igor Pavlov.
|
|
||||||
|
|
||||||
The licenses for files are:
|
|
||||||
|
|
||||||
- 7z.dll:
|
|
||||||
- The "GNU LGPL" as main license for most of the code
|
|
||||||
- The "GNU LGPL" with "unRAR license restriction" for some code
|
|
||||||
- The "BSD 3-clause License" for some code
|
|
||||||
- The "BSD 2-clause License" for some code
|
|
||||||
- All other files: the "GNU LGPL".
|
|
||||||
|
|
||||||
Redistributions in binary form must reproduce related license information from this file.
|
|
||||||
|
|
||||||
Note:
|
|
||||||
You can use 7-Zip on any computer, including a computer in a commercial
|
|
||||||
organization. You don't need to register or pay for 7-Zip.
|
|
||||||
|
|
||||||
|
|
||||||
GNU LGPL information
|
|
||||||
--------------------
|
|
||||||
|
|
||||||
This library is free software; you can redistribute it and/or
|
|
||||||
modify it under the terms of the GNU Lesser General Public
|
|
||||||
License as published by the Free Software Foundation; either
|
|
||||||
version 2.1 of the License, or (at your option) any later version.
|
|
||||||
|
|
||||||
This library is distributed in the hope that it will be useful,
|
|
||||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
|
|
||||||
Lesser General Public License for more details.
|
|
||||||
|
|
||||||
You can receive a copy of the GNU Lesser General Public License from
|
|
||||||
http://www.gnu.org/
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
BSD 3-clause License in 7-Zip code
|
|
||||||
----------------------------------
|
|
||||||
|
|
||||||
The "BSD 3-clause License" is used for the following code in 7z.dll
|
|
||||||
1) LZFSE data decompression.
|
|
||||||
That code was derived from the code in the "LZFSE compression library" developed by Apple Inc,
|
|
||||||
that also uses the "BSD 3-clause License".
|
|
||||||
2) ZSTD data decompression.
|
|
||||||
that code was developed using original zstd decoder code as reference code.
|
|
||||||
The original zstd decoder code was developed by Facebook Inc,
|
|
||||||
that also uses the "BSD 3-clause License".
|
|
||||||
|
|
||||||
Copyright (c) 2015-2016, Apple Inc. All rights reserved.
|
|
||||||
Copyright (c) Facebook, Inc. All rights reserved.
|
|
||||||
Copyright (c) 2023-2025 Igor Pavlov.
|
|
||||||
|
|
||||||
Text of the "BSD 3-clause License"
|
|
||||||
----------------------------------
|
|
||||||
|
|
||||||
Redistribution and use in source and binary forms, with or without modification,
|
|
||||||
are permitted provided that the following conditions are met:
|
|
||||||
|
|
||||||
1. Redistributions of source code must retain the above copyright notice, this
|
|
||||||
list of conditions and the following disclaimer.
|
|
||||||
|
|
||||||
2. Redistributions in binary form must reproduce the above copyright notice,
|
|
||||||
this list of conditions and the following disclaimer in the documentation
|
|
||||||
and/or other materials provided with the distribution.
|
|
||||||
|
|
||||||
3. Neither the name of the copyright holder nor the names of its contributors may
|
|
||||||
be used to endorse or promote products derived from this software without
|
|
||||||
specific prior written permission.
|
|
||||||
|
|
||||||
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
|
|
||||||
ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
|
|
||||||
WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
|
|
||||||
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR
|
|
||||||
ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
|
|
||||||
(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
|
|
||||||
LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
|
|
||||||
ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
|
|
||||||
(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
|
|
||||||
SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
BSD 2-clause License in 7-Zip code
|
|
||||||
----------------------------------
|
|
||||||
|
|
||||||
The "BSD 2-clause License" is used for the XXH64 code in 7-Zip.
|
|
||||||
|
|
||||||
XXH64 code in 7-Zip was derived from the original XXH64 code developed by Yann Collet.
|
|
||||||
|
|
||||||
Copyright (c) 2012-2021 Yann Collet.
|
|
||||||
Copyright (c) 2023-2025 Igor Pavlov.
|
|
||||||
|
|
||||||
Text of the "BSD 2-clause License"
|
|
||||||
----------------------------------
|
|
||||||
|
|
||||||
Redistribution and use in source and binary forms, with or without modification,
|
|
||||||
are permitted provided that the following conditions are met:
|
|
||||||
|
|
||||||
1. Redistributions of source code must retain the above copyright notice, this
|
|
||||||
list of conditions and the following disclaimer.
|
|
||||||
|
|
||||||
2. Redistributions in binary form must reproduce the above copyright notice,
|
|
||||||
this list of conditions and the following disclaimer in the documentation
|
|
||||||
and/or other materials provided with the distribution.
|
|
||||||
|
|
||||||
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
|
|
||||||
ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
|
|
||||||
WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
|
|
||||||
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR
|
|
||||||
ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
|
|
||||||
(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
|
|
||||||
LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
|
|
||||||
ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
|
|
||||||
(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
|
|
||||||
SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
unRAR license restriction
|
|
||||||
-------------------------
|
|
||||||
|
|
||||||
The decompression engine for RAR archives was developed using source
|
|
||||||
code of unRAR program.
|
|
||||||
All copyrights to original unRAR code are owned by Alexander Roshal.
|
|
||||||
|
|
||||||
The license for original unRAR code has the following restriction:
|
|
||||||
|
|
||||||
The unRAR sources cannot be used to re-create the RAR compression algorithm,
|
|
||||||
which is proprietary. Distribution of modified unRAR sources in separate form
|
|
||||||
or as a part of other software is permitted, provided that it is clearly
|
|
||||||
stated in the documentation and source comments that the code may
|
|
||||||
not be used to develop a RAR (WinRAR) compatible archiver.
|
|
||||||
|
|
||||||
--
|
|
||||||
@ -1,202 +0,0 @@
|
|||||||
|
|
||||||
Apache License
|
|
||||||
Version 2.0, January 2004
|
|
||||||
http://www.apache.org/licenses/
|
|
||||||
|
|
||||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
|
||||||
|
|
||||||
1. Definitions.
|
|
||||||
|
|
||||||
"License" shall mean the terms and conditions for use, reproduction,
|
|
||||||
and distribution as defined by Sections 1 through 9 of this document.
|
|
||||||
|
|
||||||
"Licensor" shall mean the copyright owner or entity authorized by
|
|
||||||
the copyright owner that is granting the License.
|
|
||||||
|
|
||||||
"Legal Entity" shall mean the union of the acting entity and all
|
|
||||||
other entities that control, are controlled by, or are under common
|
|
||||||
control with that entity. For the purposes of this definition,
|
|
||||||
"control" means (i) the power, direct or indirect, to cause the
|
|
||||||
direction or management of such entity, whether by contract or
|
|
||||||
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
|
||||||
outstanding shares, or (iii) beneficial ownership of such entity.
|
|
||||||
|
|
||||||
"You" (or "Your") shall mean an individual or Legal Entity
|
|
||||||
exercising permissions granted by this License.
|
|
||||||
|
|
||||||
"Source" form shall mean the preferred form for making modifications,
|
|
||||||
including but not limited to software source code, documentation
|
|
||||||
source, and configuration files.
|
|
||||||
|
|
||||||
"Object" form shall mean any form resulting from mechanical
|
|
||||||
transformation or translation of a Source form, including but
|
|
||||||
not limited to compiled object code, generated documentation,
|
|
||||||
and conversions to other media types.
|
|
||||||
|
|
||||||
"Work" shall mean the work of authorship, whether in Source or
|
|
||||||
Object form, made available under the License, as indicated by a
|
|
||||||
copyright notice that is included in or attached to the work
|
|
||||||
(an example is provided in the Appendix below).
|
|
||||||
|
|
||||||
"Derivative Works" shall mean any work, whether in Source or Object
|
|
||||||
form, that is based on (or derived from) the Work and for which the
|
|
||||||
editorial revisions, annotations, elaborations, or other modifications
|
|
||||||
represent, as a whole, an original work of authorship. For the purposes
|
|
||||||
of this License, Derivative Works shall not include works that remain
|
|
||||||
separable from, or merely link (or bind by name) to the interfaces of,
|
|
||||||
the Work and Derivative Works thereof.
|
|
||||||
|
|
||||||
"Contribution" shall mean any work of authorship, including
|
|
||||||
the original version of the Work and any modifications or additions
|
|
||||||
to that Work or Derivative Works thereof, that is intentionally
|
|
||||||
submitted to Licensor for inclusion in the Work by the copyright owner
|
|
||||||
or by an individual or Legal Entity authorized to submit on behalf of
|
|
||||||
the copyright owner. For the purposes of this definition, "submitted"
|
|
||||||
means any form of electronic, verbal, or written communication sent
|
|
||||||
to the Licensor or its representatives, including but not limited to
|
|
||||||
communication on electronic mailing lists, source code control systems,
|
|
||||||
and issue tracking systems that are managed by, or on behalf of, the
|
|
||||||
Licensor for the purpose of discussing and improving the Work, but
|
|
||||||
excluding communication that is conspicuously marked or otherwise
|
|
||||||
designated in writing by the copyright owner as "Not a Contribution."
|
|
||||||
|
|
||||||
"Contributor" shall mean Licensor and any individual or Legal Entity
|
|
||||||
on behalf of whom a Contribution has been received by Licensor and
|
|
||||||
subsequently incorporated within the Work.
|
|
||||||
|
|
||||||
2. Grant of Copyright License. Subject to the terms and conditions of
|
|
||||||
this License, each Contributor hereby grants to You a perpetual,
|
|
||||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
|
||||||
copyright license to reproduce, prepare Derivative Works of,
|
|
||||||
publicly display, publicly perform, sublicense, and distribute the
|
|
||||||
Work and such Derivative Works in Source or Object form.
|
|
||||||
|
|
||||||
3. Grant of Patent License. Subject to the terms and conditions of
|
|
||||||
this License, each Contributor hereby grants to You a perpetual,
|
|
||||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
|
||||||
(except as stated in this section) patent license to make, have made,
|
|
||||||
use, offer to sell, sell, import, and otherwise transfer the Work,
|
|
||||||
where such license applies only to those patent claims licensable
|
|
||||||
by such Contributor that are necessarily infringed by their
|
|
||||||
Contribution(s) alone or by combination of their Contribution(s)
|
|
||||||
with the Work to which such Contribution(s) was submitted. If You
|
|
||||||
institute patent litigation against any entity (including a
|
|
||||||
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
|
||||||
or a Contribution incorporated within the Work constitutes direct
|
|
||||||
or contributory patent infringement, then any patent licenses
|
|
||||||
granted to You under this License for that Work shall terminate
|
|
||||||
as of the date such litigation is filed.
|
|
||||||
|
|
||||||
4. Redistribution. You may reproduce and distribute copies of the
|
|
||||||
Work or Derivative Works thereof in any medium, with or without
|
|
||||||
modifications, and in Source or Object form, provided that You
|
|
||||||
meet the following conditions:
|
|
||||||
|
|
||||||
(a) You must give any other recipients of the Work or
|
|
||||||
Derivative Works a copy of this License; and
|
|
||||||
|
|
||||||
(b) You must cause any modified files to carry prominent notices
|
|
||||||
stating that You changed the files; and
|
|
||||||
|
|
||||||
(c) You must retain, in the Source form of any Derivative Works
|
|
||||||
that You distribute, all copyright, patent, trademark, and
|
|
||||||
attribution notices from the Source form of the Work,
|
|
||||||
excluding those notices that do not pertain to any part of
|
|
||||||
the Derivative Works; and
|
|
||||||
|
|
||||||
(d) If the Work includes a "NOTICE" text file as part of its
|
|
||||||
distribution, then any Derivative Works that You distribute must
|
|
||||||
include a readable copy of the attribution notices contained
|
|
||||||
within such NOTICE file, excluding those notices that do not
|
|
||||||
pertain to any part of the Derivative Works, in at least one
|
|
||||||
of the following places: within a NOTICE text file distributed
|
|
||||||
as part of the Derivative Works; within the Source form or
|
|
||||||
documentation, if provided along with the Derivative Works; or,
|
|
||||||
within a display generated by the Derivative Works, if and
|
|
||||||
wherever such third-party notices normally appear. The contents
|
|
||||||
of the NOTICE file are for informational purposes only and
|
|
||||||
do not modify the License. You may add Your own attribution
|
|
||||||
notices within Derivative Works that You distribute, alongside
|
|
||||||
or as an addendum to the NOTICE text from the Work, provided
|
|
||||||
that such additional attribution notices cannot be construed
|
|
||||||
as modifying the License.
|
|
||||||
|
|
||||||
You may add Your own copyright statement to Your modifications and
|
|
||||||
may provide additional or different license terms and conditions
|
|
||||||
for use, reproduction, or distribution of Your modifications, or
|
|
||||||
for any such Derivative Works as a whole, provided Your use,
|
|
||||||
reproduction, and distribution of the Work otherwise complies with
|
|
||||||
the conditions stated in this License.
|
|
||||||
|
|
||||||
5. Submission of Contributions. Unless You explicitly state otherwise,
|
|
||||||
any Contribution intentionally submitted for inclusion in the Work
|
|
||||||
by You to the Licensor shall be under the terms and conditions of
|
|
||||||
this License, without any additional terms or conditions.
|
|
||||||
Notwithstanding the above, nothing herein shall supersede or modify
|
|
||||||
the terms of any separate license agreement you may have executed
|
|
||||||
with Licensor regarding such Contributions.
|
|
||||||
|
|
||||||
6. Trademarks. This License does not grant permission to use the trade
|
|
||||||
names, trademarks, service marks, or product names of the Licensor,
|
|
||||||
except as required for reasonable and customary use in describing the
|
|
||||||
origin of the Work and reproducing the content of the NOTICE file.
|
|
||||||
|
|
||||||
7. Disclaimer of Warranty. Unless required by applicable law or
|
|
||||||
agreed to in writing, Licensor provides the Work (and each
|
|
||||||
Contributor provides its Contributions) on an "AS IS" BASIS,
|
|
||||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
|
||||||
implied, including, without limitation, any warranties or conditions
|
|
||||||
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
|
||||||
PARTICULAR PURPOSE. You are solely responsible for determining the
|
|
||||||
appropriateness of using or redistributing the Work and assume any
|
|
||||||
risks associated with Your exercise of permissions under this License.
|
|
||||||
|
|
||||||
8. Limitation of Liability. In no event and under no legal theory,
|
|
||||||
whether in tort (including negligence), contract, or otherwise,
|
|
||||||
unless required by applicable law (such as deliberate and grossly
|
|
||||||
negligent acts) or agreed to in writing, shall any Contributor be
|
|
||||||
liable to You for damages, including any direct, indirect, special,
|
|
||||||
incidental, or consequential damages of any character arising as a
|
|
||||||
result of this License or out of the use or inability to use the
|
|
||||||
Work (including but not limited to damages for loss of goodwill,
|
|
||||||
work stoppage, computer failure or malfunction, or any and all
|
|
||||||
other commercial damages or losses), even if such Contributor
|
|
||||||
has been advised of the possibility of such damages.
|
|
||||||
|
|
||||||
9. Accepting Warranty or Additional Liability. While redistributing
|
|
||||||
the Work or Derivative Works thereof, You may choose to offer,
|
|
||||||
and charge a fee for, acceptance of support, warranty, indemnity,
|
|
||||||
or other liability obligations and/or rights consistent with this
|
|
||||||
License. However, in accepting such obligations, You may act only
|
|
||||||
on Your own behalf and on Your sole responsibility, not on behalf
|
|
||||||
of any other Contributor, and only if You agree to indemnify,
|
|
||||||
defend, and hold each Contributor harmless for any liability
|
|
||||||
incurred by, or claims asserted against, such Contributor by reason
|
|
||||||
of your accepting any such warranty or additional liability.
|
|
||||||
|
|
||||||
END OF TERMS AND CONDITIONS
|
|
||||||
|
|
||||||
APPENDIX: How to apply the Apache License to your work.
|
|
||||||
|
|
||||||
To apply the Apache License to your work, attach the following
|
|
||||||
boilerplate notice, with the fields enclosed by brackets "[]"
|
|
||||||
replaced with your own identifying information. (Don't include
|
|
||||||
the brackets!) The text should be enclosed in the appropriate
|
|
||||||
comment syntax for the file format. We also recommend that a
|
|
||||||
file or class name and description of purpose be included on the
|
|
||||||
same "printed page" as the copyright notice for easier
|
|
||||||
identification within third-party archives.
|
|
||||||
|
|
||||||
Copyright [yyyy] [name of copyright owner]
|
|
||||||
|
|
||||||
Licensed under the Apache License, Version 2.0 (the "License");
|
|
||||||
you may not use this file except in compliance with the License.
|
|
||||||
You may obtain a copy of the License at
|
|
||||||
|
|
||||||
http://www.apache.org/licenses/LICENSE-2.0
|
|
||||||
|
|
||||||
Unless required by applicable law or agreed to in writing, software
|
|
||||||
distributed under the License is distributed on an "AS IS" BASIS,
|
|
||||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
||||||
See the License for the specific language governing permissions and
|
|
||||||
limitations under the License.
|
|
||||||
@ -1,501 +0,0 @@
|
|||||||
GNU LESSER GENERAL PUBLIC LICENSE
|
|
||||||
Version 2.1, February 1999
|
|
||||||
|
|
||||||
Copyright (C) 1991, 1999 Free Software Foundation, Inc.
|
|
||||||
<https://fsf.org/>
|
|
||||||
Everyone is permitted to copy and distribute verbatim copies
|
|
||||||
of this license document, but changing it is not allowed.
|
|
||||||
|
|
||||||
[This is the first released version of the Lesser GPL. It also counts
|
|
||||||
as the successor of the GNU Library Public License, version 2, hence
|
|
||||||
the version number 2.1.]
|
|
||||||
|
|
||||||
Preamble
|
|
||||||
|
|
||||||
The licenses for most software are designed to take away your
|
|
||||||
freedom to share and change it. By contrast, the GNU General Public
|
|
||||||
Licenses are intended to guarantee your freedom to share and change
|
|
||||||
free software--to make sure the software is free for all its users.
|
|
||||||
|
|
||||||
This license, the Lesser General Public License, applies to some
|
|
||||||
specially designated software packages--typically libraries--of the
|
|
||||||
Free Software Foundation and other authors who decide to use it. You
|
|
||||||
can use it too, but we suggest you first think carefully about whether
|
|
||||||
this license or the ordinary General Public License is the better
|
|
||||||
strategy to use in any particular case, based on the explanations below.
|
|
||||||
|
|
||||||
When we speak of free software, we are referring to freedom of use,
|
|
||||||
not price. Our General Public Licenses are designed to make sure that
|
|
||||||
you have the freedom to distribute copies of free software (and charge
|
|
||||||
for this service if you wish); that you receive source code or can get
|
|
||||||
it if you want it; that you can change the software and use pieces of
|
|
||||||
it in new free programs; and that you are informed that you can do
|
|
||||||
these things.
|
|
||||||
|
|
||||||
To protect your rights, we need to make restrictions that forbid
|
|
||||||
distributors to deny you these rights or to ask you to surrender these
|
|
||||||
rights. These restrictions translate to certain responsibilities for
|
|
||||||
you if you distribute copies of the library or if you modify it.
|
|
||||||
|
|
||||||
For example, if you distribute copies of the library, whether gratis
|
|
||||||
or for a fee, you must give the recipients all the rights that we gave
|
|
||||||
you. You must make sure that they, too, receive or can get the source
|
|
||||||
code. If you link other code with the library, you must provide
|
|
||||||
complete object files to the recipients, so that they can relink them
|
|
||||||
with the library after making changes to the library and recompiling
|
|
||||||
it. And you must show them these terms so they know their rights.
|
|
||||||
|
|
||||||
We protect your rights with a two-step method: (1) we copyright the
|
|
||||||
library, and (2) we offer you this license, which gives you legal
|
|
||||||
permission to copy, distribute and/or modify the library.
|
|
||||||
|
|
||||||
To protect each distributor, we want to make it very clear that
|
|
||||||
there is no warranty for the free library. Also, if the library is
|
|
||||||
modified by someone else and passed on, the recipients should know
|
|
||||||
that what they have is not the original version, so that the original
|
|
||||||
author's reputation will not be affected by problems that might be
|
|
||||||
introduced by others.
|
|
||||||
|
|
||||||
Finally, software patents pose a constant threat to the existence of
|
|
||||||
any free program. We wish to make sure that a company cannot
|
|
||||||
effectively restrict the users of a free program by obtaining a
|
|
||||||
restrictive license from a patent holder. Therefore, we insist that
|
|
||||||
any patent license obtained for a version of the library must be
|
|
||||||
consistent with the full freedom of use specified in this license.
|
|
||||||
|
|
||||||
Most GNU software, including some libraries, is covered by the
|
|
||||||
ordinary GNU General Public License. This license, the GNU Lesser
|
|
||||||
General Public License, applies to certain designated libraries, and
|
|
||||||
is quite different from the ordinary General Public License. We use
|
|
||||||
this license for certain libraries in order to permit linking those
|
|
||||||
libraries into non-free programs.
|
|
||||||
|
|
||||||
When a program is linked with a library, whether statically or using
|
|
||||||
a shared library, the combination of the two is legally speaking a
|
|
||||||
combined work, a derivative of the original library. The ordinary
|
|
||||||
General Public License therefore permits such linking only if the
|
|
||||||
entire combination fits its criteria of freedom. The Lesser General
|
|
||||||
Public License permits more lax criteria for linking other code with
|
|
||||||
the library.
|
|
||||||
|
|
||||||
We call this license the "Lesser" General Public License because it
|
|
||||||
does Less to protect the user's freedom than the ordinary General
|
|
||||||
Public License. It also provides other free software developers Less
|
|
||||||
of an advantage over competing non-free programs. These disadvantages
|
|
||||||
are the reason we use the ordinary General Public License for many
|
|
||||||
libraries. However, the Lesser license provides advantages in certain
|
|
||||||
special circumstances.
|
|
||||||
|
|
||||||
For example, on rare occasions, there may be a special need to
|
|
||||||
encourage the widest possible use of a certain library, so that it becomes
|
|
||||||
a de-facto standard. To achieve this, non-free programs must be
|
|
||||||
allowed to use the library. A more frequent case is that a free
|
|
||||||
library does the same job as widely used non-free libraries. In this
|
|
||||||
case, there is little to gain by limiting the free library to free
|
|
||||||
software only, so we use the Lesser General Public License.
|
|
||||||
|
|
||||||
In other cases, permission to use a particular library in non-free
|
|
||||||
programs enables a greater number of people to use a large body of
|
|
||||||
free software. For example, permission to use the GNU C Library in
|
|
||||||
non-free programs enables many more people to use the whole GNU
|
|
||||||
operating system, as well as its variant, the GNU/Linux operating
|
|
||||||
system.
|
|
||||||
|
|
||||||
Although the Lesser General Public License is Less protective of the
|
|
||||||
users' freedom, it does ensure that the user of a program that is
|
|
||||||
linked with the Library has the freedom and the wherewithal to run
|
|
||||||
that program using a modified version of the Library.
|
|
||||||
|
|
||||||
The precise terms and conditions for copying, distribution and
|
|
||||||
modification follow. Pay close attention to the difference between a
|
|
||||||
"work based on the library" and a "work that uses the library". The
|
|
||||||
former contains code derived from the library, whereas the latter must
|
|
||||||
be combined with the library in order to run.
|
|
||||||
|
|
||||||
GNU LESSER GENERAL PUBLIC LICENSE
|
|
||||||
TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION
|
|
||||||
|
|
||||||
0. This License Agreement applies to any software library or other
|
|
||||||
program which contains a notice placed by the copyright holder or
|
|
||||||
other authorized party saying it may be distributed under the terms of
|
|
||||||
this Lesser General Public License (also called "this License").
|
|
||||||
Each licensee is addressed as "you".
|
|
||||||
|
|
||||||
A "library" means a collection of software functions and/or data
|
|
||||||
prepared so as to be conveniently linked with application programs
|
|
||||||
(which use some of those functions and data) to form executables.
|
|
||||||
|
|
||||||
The "Library", below, refers to any such software library or work
|
|
||||||
which has been distributed under these terms. A "work based on the
|
|
||||||
Library" means either the Library or any derivative work under
|
|
||||||
copyright law: that is to say, a work containing the Library or a
|
|
||||||
portion of it, either verbatim or with modifications and/or translated
|
|
||||||
straightforwardly into another language. (Hereinafter, translation is
|
|
||||||
included without limitation in the term "modification".)
|
|
||||||
|
|
||||||
"Source code" for a work means the preferred form of the work for
|
|
||||||
making modifications to it. For a library, complete source code means
|
|
||||||
all the source code for all modules it contains, plus any associated
|
|
||||||
interface definition files, plus the scripts used to control compilation
|
|
||||||
and installation of the library.
|
|
||||||
|
|
||||||
Activities other than copying, distribution and modification are not
|
|
||||||
covered by this License; they are outside its scope. The act of
|
|
||||||
running a program using the Library is not restricted, and output from
|
|
||||||
such a program is covered only if its contents constitute a work based
|
|
||||||
on the Library (independent of the use of the Library in a tool for
|
|
||||||
writing it). Whether that is true depends on what the Library does
|
|
||||||
and what the program that uses the Library does.
|
|
||||||
|
|
||||||
1. You may copy and distribute verbatim copies of the Library's
|
|
||||||
complete source code as you receive it, in any medium, provided that
|
|
||||||
you conspicuously and appropriately publish on each copy an
|
|
||||||
appropriate copyright notice and disclaimer of warranty; keep intact
|
|
||||||
all the notices that refer to this License and to the absence of any
|
|
||||||
warranty; and distribute a copy of this License along with the
|
|
||||||
Library.
|
|
||||||
|
|
||||||
You may charge a fee for the physical act of transferring a copy,
|
|
||||||
and you may at your option offer warranty protection in exchange for a
|
|
||||||
fee.
|
|
||||||
|
|
||||||
2. You may modify your copy or copies of the Library or any portion
|
|
||||||
of it, thus forming a work based on the Library, and copy and
|
|
||||||
distribute such modifications or work under the terms of Section 1
|
|
||||||
above, provided that you also meet all of these conditions:
|
|
||||||
|
|
||||||
a) The modified work must itself be a software library.
|
|
||||||
|
|
||||||
b) You must cause the files modified to carry prominent notices
|
|
||||||
stating that you changed the files and the date of any change.
|
|
||||||
|
|
||||||
c) You must cause the whole of the work to be licensed at no
|
|
||||||
charge to all third parties under the terms of this License.
|
|
||||||
|
|
||||||
d) If a facility in the modified Library refers to a function or a
|
|
||||||
table of data to be supplied by an application program that uses
|
|
||||||
the facility, other than as an argument passed when the facility
|
|
||||||
is invoked, then you must make a good faith effort to ensure that,
|
|
||||||
in the event an application does not supply such function or
|
|
||||||
table, the facility still operates, and performs whatever part of
|
|
||||||
its purpose remains meaningful.
|
|
||||||
|
|
||||||
(For example, a function in a library to compute square roots has
|
|
||||||
a purpose that is entirely well-defined independent of the
|
|
||||||
application. Therefore, Subsection 2d requires that any
|
|
||||||
application-supplied function or table used by this function must
|
|
||||||
be optional: if the application does not supply it, the square
|
|
||||||
root function must still compute square roots.)
|
|
||||||
|
|
||||||
These requirements apply to the modified work as a whole. If
|
|
||||||
identifiable sections of that work are not derived from the Library,
|
|
||||||
and can be reasonably considered independent and separate works in
|
|
||||||
themselves, then this License, and its terms, do not apply to those
|
|
||||||
sections when you distribute them as separate works. But when you
|
|
||||||
distribute the same sections as part of a whole which is a work based
|
|
||||||
on the Library, the distribution of the whole must be on the terms of
|
|
||||||
this License, whose permissions for other licensees extend to the
|
|
||||||
entire whole, and thus to each and every part regardless of who wrote
|
|
||||||
it.
|
|
||||||
|
|
||||||
Thus, it is not the intent of this section to claim rights or contest
|
|
||||||
your rights to work written entirely by you; rather, the intent is to
|
|
||||||
exercise the right to control the distribution of derivative or
|
|
||||||
collective works based on the Library.
|
|
||||||
|
|
||||||
In addition, mere aggregation of another work not based on the Library
|
|
||||||
with the Library (or with a work based on the Library) on a volume of
|
|
||||||
a storage or distribution medium does not bring the other work under
|
|
||||||
the scope of this License.
|
|
||||||
|
|
||||||
3. You may opt to apply the terms of the ordinary GNU General Public
|
|
||||||
License instead of this License to a given copy of the Library. To do
|
|
||||||
this, you must alter all the notices that refer to this License, so
|
|
||||||
that they refer to the ordinary GNU General Public License, version 2,
|
|
||||||
instead of to this License. (If a newer version than version 2 of the
|
|
||||||
ordinary GNU General Public License has appeared, then you can specify
|
|
||||||
that version instead if you wish.) Do not make any other change in
|
|
||||||
these notices.
|
|
||||||
|
|
||||||
Once this change is made in a given copy, it is irreversible for
|
|
||||||
that copy, so the ordinary GNU General Public License applies to all
|
|
||||||
subsequent copies and derivative works made from that copy.
|
|
||||||
|
|
||||||
This option is useful when you wish to copy part of the code of
|
|
||||||
the Library into a program that is not a library.
|
|
||||||
|
|
||||||
4. You may copy and distribute the Library (or a portion or
|
|
||||||
derivative of it, under Section 2) in object code or executable form
|
|
||||||
under the terms of Sections 1 and 2 above provided that you accompany
|
|
||||||
it with the complete corresponding machine-readable source code, which
|
|
||||||
must be distributed under the terms of Sections 1 and 2 above on a
|
|
||||||
medium customarily used for software interchange.
|
|
||||||
|
|
||||||
If distribution of object code is made by offering access to copy
|
|
||||||
from a designated place, then offering equivalent access to copy the
|
|
||||||
source code from the same place satisfies the requirement to
|
|
||||||
distribute the source code, even though third parties are not
|
|
||||||
compelled to copy the source along with the object code.
|
|
||||||
|
|
||||||
5. A program that contains no derivative of any portion of the
|
|
||||||
Library, but is designed to work with the Library by being compiled or
|
|
||||||
linked with it, is called a "work that uses the Library". Such a
|
|
||||||
work, in isolation, is not a derivative work of the Library, and
|
|
||||||
therefore falls outside the scope of this License.
|
|
||||||
|
|
||||||
However, linking a "work that uses the Library" with the Library
|
|
||||||
creates an executable that is a derivative of the Library (because it
|
|
||||||
contains portions of the Library), rather than a "work that uses the
|
|
||||||
library". The executable is therefore covered by this License.
|
|
||||||
Section 6 states terms for distribution of such executables.
|
|
||||||
|
|
||||||
When a "work that uses the Library" uses material from a header file
|
|
||||||
that is part of the Library, the object code for the work may be a
|
|
||||||
derivative work of the Library even though the source code is not.
|
|
||||||
Whether this is true is especially significant if the work can be
|
|
||||||
linked without the Library, or if the work is itself a library. The
|
|
||||||
threshold for this to be true is not precisely defined by law.
|
|
||||||
|
|
||||||
If such an object file uses only numerical parameters, data
|
|
||||||
structure layouts and accessors, and small macros and small inline
|
|
||||||
functions (ten lines or less in length), then the use of the object
|
|
||||||
file is unrestricted, regardless of whether it is legally a derivative
|
|
||||||
work. (Executables containing this object code plus portions of the
|
|
||||||
Library will still fall under Section 6.)
|
|
||||||
|
|
||||||
Otherwise, if the work is a derivative of the Library, you may
|
|
||||||
distribute the object code for the work under the terms of Section 6.
|
|
||||||
Any executables containing that work also fall under Section 6,
|
|
||||||
whether or not they are linked directly with the Library itself.
|
|
||||||
|
|
||||||
6. As an exception to the Sections above, you may also combine or
|
|
||||||
link a "work that uses the Library" with the Library to produce a
|
|
||||||
work containing portions of the Library, and distribute that work
|
|
||||||
under terms of your choice, provided that the terms permit
|
|
||||||
modification of the work for the customer's own use and reverse
|
|
||||||
engineering for debugging such modifications.
|
|
||||||
|
|
||||||
You must give prominent notice with each copy of the work that the
|
|
||||||
Library is used in it and that the Library and its use are covered by
|
|
||||||
this License. You must supply a copy of this License. If the work
|
|
||||||
during execution displays copyright notices, you must include the
|
|
||||||
copyright notice for the Library among them, as well as a reference
|
|
||||||
directing the user to the copy of this License. Also, you must do one
|
|
||||||
of these things:
|
|
||||||
|
|
||||||
a) Accompany the work with the complete corresponding
|
|
||||||
machine-readable source code for the Library including whatever
|
|
||||||
changes were used in the work (which must be distributed under
|
|
||||||
Sections 1 and 2 above); and, if the work is an executable linked
|
|
||||||
with the Library, with the complete machine-readable "work that
|
|
||||||
uses the Library", as object code and/or source code, so that the
|
|
||||||
user can modify the Library and then relink to produce a modified
|
|
||||||
executable containing the modified Library. (It is understood
|
|
||||||
that the user who changes the contents of definitions files in the
|
|
||||||
Library will not necessarily be able to recompile the application
|
|
||||||
to use the modified definitions.)
|
|
||||||
|
|
||||||
b) Use a suitable shared library mechanism for linking with the
|
|
||||||
Library. A suitable mechanism is one that (1) uses at run time a
|
|
||||||
copy of the library already present on the user's computer system,
|
|
||||||
rather than copying library functions into the executable, and (2)
|
|
||||||
will operate properly with a modified version of the library, if
|
|
||||||
the user installs one, as long as the modified version is
|
|
||||||
interface-compatible with the version that the work was made with.
|
|
||||||
|
|
||||||
c) Accompany the work with a written offer, valid for at
|
|
||||||
least three years, to give the same user the materials
|
|
||||||
specified in Subsection 6a, above, for a charge no more
|
|
||||||
than the cost of performing this distribution.
|
|
||||||
|
|
||||||
d) If distribution of the work is made by offering access to copy
|
|
||||||
from a designated place, offer equivalent access to copy the above
|
|
||||||
specified materials from the same place.
|
|
||||||
|
|
||||||
e) Verify that the user has already received a copy of these
|
|
||||||
materials or that you have already sent this user a copy.
|
|
||||||
|
|
||||||
For an executable, the required form of the "work that uses the
|
|
||||||
Library" must include any data and utility programs needed for
|
|
||||||
reproducing the executable from it. However, as a special exception,
|
|
||||||
the materials to be distributed need not include anything that is
|
|
||||||
normally distributed (in either source or binary form) with the major
|
|
||||||
components (compiler, kernel, and so on) of the operating system on
|
|
||||||
which the executable runs, unless that component itself accompanies
|
|
||||||
the executable.
|
|
||||||
|
|
||||||
It may happen that this requirement contradicts the license
|
|
||||||
restrictions of other proprietary libraries that do not normally
|
|
||||||
accompany the operating system. Such a contradiction means you cannot
|
|
||||||
use both them and the Library together in an executable that you
|
|
||||||
distribute.
|
|
||||||
|
|
||||||
7. You may place library facilities that are a work based on the
|
|
||||||
Library side-by-side in a single library together with other library
|
|
||||||
facilities not covered by this License, and distribute such a combined
|
|
||||||
library, provided that the separate distribution of the work based on
|
|
||||||
the Library and of the other library facilities is otherwise
|
|
||||||
permitted, and provided that you do these two things:
|
|
||||||
|
|
||||||
a) Accompany the combined library with a copy of the same work
|
|
||||||
based on the Library, uncombined with any other library
|
|
||||||
facilities. This must be distributed under the terms of the
|
|
||||||
Sections above.
|
|
||||||
|
|
||||||
b) Give prominent notice with the combined library of the fact
|
|
||||||
that part of it is a work based on the Library, and explaining
|
|
||||||
where to find the accompanying uncombined form of the same work.
|
|
||||||
|
|
||||||
8. You may not copy, modify, sublicense, link with, or distribute
|
|
||||||
the Library except as expressly provided under this License. Any
|
|
||||||
attempt otherwise to copy, modify, sublicense, link with, or
|
|
||||||
distribute the Library is void, and will automatically terminate your
|
|
||||||
rights under this License. However, parties who have received copies,
|
|
||||||
or rights, from you under this License will not have their licenses
|
|
||||||
terminated so long as such parties remain in full compliance.
|
|
||||||
|
|
||||||
9. You are not required to accept this License, since you have not
|
|
||||||
signed it. However, nothing else grants you permission to modify or
|
|
||||||
distribute the Library or its derivative works. These actions are
|
|
||||||
prohibited by law if you do not accept this License. Therefore, by
|
|
||||||
modifying or distributing the Library (or any work based on the
|
|
||||||
Library), you indicate your acceptance of this License to do so, and
|
|
||||||
all its terms and conditions for copying, distributing or modifying
|
|
||||||
the Library or works based on it.
|
|
||||||
|
|
||||||
10. Each time you redistribute the Library (or any work based on the
|
|
||||||
Library), the recipient automatically receives a license from the
|
|
||||||
original licensor to copy, distribute, link with or modify the Library
|
|
||||||
subject to these terms and conditions. You may not impose any further
|
|
||||||
restrictions on the recipients' exercise of the rights granted herein.
|
|
||||||
You are not responsible for enforcing compliance by third parties with
|
|
||||||
this License.
|
|
||||||
|
|
||||||
11. If, as a consequence of a court judgment or allegation of patent
|
|
||||||
infringement or for any other reason (not limited to patent issues),
|
|
||||||
conditions are imposed on you (whether by court order, agreement or
|
|
||||||
otherwise) that contradict the conditions of this License, they do not
|
|
||||||
excuse you from the conditions of this License. If you cannot
|
|
||||||
distribute so as to satisfy simultaneously your obligations under this
|
|
||||||
License and any other pertinent obligations, then as a consequence you
|
|
||||||
may not distribute the Library at all. For example, if a patent
|
|
||||||
license would not permit royalty-free redistribution of the Library by
|
|
||||||
all those who receive copies directly or indirectly through you, then
|
|
||||||
the only way you could satisfy both it and this License would be to
|
|
||||||
refrain entirely from distribution of the Library.
|
|
||||||
|
|
||||||
If any portion of this section is held invalid or unenforceable under any
|
|
||||||
particular circumstance, the balance of the section is intended to apply,
|
|
||||||
and the section as a whole is intended to apply in other circumstances.
|
|
||||||
|
|
||||||
It is not the purpose of this section to induce you to infringe any
|
|
||||||
patents or other property right claims or to contest validity of any
|
|
||||||
such claims; this section has the sole purpose of protecting the
|
|
||||||
integrity of the free software distribution system which is
|
|
||||||
implemented by public license practices. Many people have made
|
|
||||||
generous contributions to the wide range of software distributed
|
|
||||||
through that system in reliance on consistent application of that
|
|
||||||
system; it is up to the author/donor to decide if he or she is willing
|
|
||||||
to distribute software through any other system and a licensee cannot
|
|
||||||
impose that choice.
|
|
||||||
|
|
||||||
This section is intended to make thoroughly clear what is believed to
|
|
||||||
be a consequence of the rest of this License.
|
|
||||||
|
|
||||||
12. If the distribution and/or use of the Library is restricted in
|
|
||||||
certain countries either by patents or by copyrighted interfaces, the
|
|
||||||
original copyright holder who places the Library under this License may add
|
|
||||||
an explicit geographical distribution limitation excluding those countries,
|
|
||||||
so that distribution is permitted only in or among countries not thus
|
|
||||||
excluded. In such case, this License incorporates the limitation as if
|
|
||||||
written in the body of this License.
|
|
||||||
|
|
||||||
13. The Free Software Foundation may publish revised and/or new
|
|
||||||
versions of the Lesser General Public License from time to time.
|
|
||||||
Such new versions will be similar in spirit to the present version,
|
|
||||||
but may differ in detail to address new problems or concerns.
|
|
||||||
|
|
||||||
Each version is given a distinguishing version number. If the Library
|
|
||||||
specifies a version number of this License which applies to it and
|
|
||||||
"any later version", you have the option of following the terms and
|
|
||||||
conditions either of that version or of any later version published by
|
|
||||||
the Free Software Foundation. If the Library does not specify a
|
|
||||||
license version number, you may choose any version ever published by
|
|
||||||
the Free Software Foundation.
|
|
||||||
|
|
||||||
14. If you wish to incorporate parts of the Library into other free
|
|
||||||
programs whose distribution conditions are incompatible with these,
|
|
||||||
write to the author to ask for permission. For software which is
|
|
||||||
copyrighted by the Free Software Foundation, write to the Free
|
|
||||||
Software Foundation; we sometimes make exceptions for this. Our
|
|
||||||
decision will be guided by the two goals of preserving the free status
|
|
||||||
of all derivatives of our free software and of promoting the sharing
|
|
||||||
and reuse of software generally.
|
|
||||||
|
|
||||||
NO WARRANTY
|
|
||||||
|
|
||||||
15. BECAUSE THE LIBRARY IS LICENSED FREE OF CHARGE, THERE IS NO
|
|
||||||
WARRANTY FOR THE LIBRARY, TO THE EXTENT PERMITTED BY APPLICABLE LAW.
|
|
||||||
EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR
|
|
||||||
OTHER PARTIES PROVIDE THE LIBRARY "AS IS" WITHOUT WARRANTY OF ANY
|
|
||||||
KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE
|
|
||||||
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
|
|
||||||
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE
|
|
||||||
LIBRARY IS WITH YOU. SHOULD THE LIBRARY PROVE DEFECTIVE, YOU ASSUME
|
|
||||||
THE COST OF ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
|
|
||||||
|
|
||||||
16. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN
|
|
||||||
WRITING WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY
|
|
||||||
AND/OR REDISTRIBUTE THE LIBRARY AS PERMITTED ABOVE, BE LIABLE TO YOU
|
|
||||||
FOR DAMAGES, INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR
|
|
||||||
CONSEQUENTIAL DAMAGES ARISING OUT OF THE USE OR INABILITY TO USE THE
|
|
||||||
LIBRARY (INCLUDING BUT NOT LIMITED TO LOSS OF DATA OR DATA BEING
|
|
||||||
RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR A
|
|
||||||
FAILURE OF THE LIBRARY TO OPERATE WITH ANY OTHER SOFTWARE), EVEN IF
|
|
||||||
SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH
|
|
||||||
DAMAGES.
|
|
||||||
|
|
||||||
END OF TERMS AND CONDITIONS
|
|
||||||
|
|
||||||
How to Apply These Terms to Your New Libraries
|
|
||||||
|
|
||||||
If you develop a new library, and you want it to be of the greatest
|
|
||||||
possible use to the public, we recommend making it free software that
|
|
||||||
everyone can redistribute and change. You can do so by permitting
|
|
||||||
redistribution under these terms (or, alternatively, under the terms of the
|
|
||||||
ordinary General Public License).
|
|
||||||
|
|
||||||
To apply these terms, attach the following notices to the library. It is
|
|
||||||
safest to attach them to the start of each source file to most effectively
|
|
||||||
convey the exclusion of warranty; and each file should have at least the
|
|
||||||
"copyright" line and a pointer to where the full notice is found.
|
|
||||||
|
|
||||||
<one line to give the library's name and a brief idea of what it does.>
|
|
||||||
Copyright (C) <year> <name of author>
|
|
||||||
|
|
||||||
This library is free software; you can redistribute it and/or
|
|
||||||
modify it under the terms of the GNU Lesser General Public
|
|
||||||
License as published by the Free Software Foundation; either
|
|
||||||
version 2.1 of the License, or (at your option) any later version.
|
|
||||||
|
|
||||||
This library is distributed in the hope that it will be useful,
|
|
||||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
|
|
||||||
Lesser General Public License for more details.
|
|
||||||
|
|
||||||
You should have received a copy of the GNU Lesser General Public
|
|
||||||
License along with this library; if not, see <https://www.gnu.org/licenses/>.
|
|
||||||
|
|
||||||
Also add information on how to contact you by electronic and paper mail.
|
|
||||||
|
|
||||||
You should also get your employer (if you work as a programmer) or your
|
|
||||||
school, if any, to sign a "copyright disclaimer" for the library, if
|
|
||||||
necessary. Here is a sample; alter the names:
|
|
||||||
|
|
||||||
Yoyodyne, Inc., hereby disclaims all copyright interest in the
|
|
||||||
library `Frob' (a library for tweaking knobs) written by James Random Hacker.
|
|
||||||
|
|
||||||
<signature of Moe Ghoul>, 1 April 1990
|
|
||||||
Moe Ghoul, President of Vice
|
|
||||||
|
|
||||||
That's all there is to it!
|
|
||||||
File diff suppressed because it is too large
Load Diff
@ -1,18 +1,18 @@
|
|||||||
const path = require("path");
|
const path = require("path");
|
||||||
const { rcedit } = require("rcedit");
|
const { rcedit } = require("rcedit");
|
||||||
|
|
||||||
module.exports = async function afterPack(context) {
|
module.exports = async function afterPack(context) {
|
||||||
const productFilename = context.packager?.appInfo?.productFilename;
|
const productFilename = context.packager?.appInfo?.productFilename;
|
||||||
if (!productFilename) {
|
if (!productFilename) {
|
||||||
console.warn(" • rcedit: skipped — productFilename not available");
|
console.warn(" • rcedit: skipped — productFilename not available");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const exePath = path.join(context.appOutDir, `${productFilename}.exe`);
|
const exePath = path.join(context.appOutDir, `${productFilename}.exe`);
|
||||||
const iconPath = path.resolve(__dirname, "..", "assets", "app_icon.ico");
|
const iconPath = path.resolve(__dirname, "..", "assets", "app_icon.ico");
|
||||||
console.log(` • rcedit: patching icon → ${exePath}`);
|
console.log(` • rcedit: patching icon → ${exePath}`);
|
||||||
try {
|
try {
|
||||||
await rcedit(exePath, { icon: iconPath });
|
await rcedit(exePath, { icon: iconPath });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn(` • rcedit: failed — ${String(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,197 +1,197 @@
|
|||||||
import type { AppSettings, DebridAccountStatus } from "../shared/types";
|
import type { AppSettings, DebridAccountStatus } from "../shared/types";
|
||||||
import { parseMegaDebridAccounts, type MegaDebridAccountEntry } from "../shared/mega-debrid-accounts";
|
import { parseMegaDebridAccounts, type MegaDebridAccountEntry } from "../shared/mega-debrid-accounts";
|
||||||
import { parseDebridLinkApiKeys, type DebridLinkApiKeyEntry } from "../shared/debrid-link-keys";
|
import { parseDebridLinkApiKeys, type DebridLinkApiKeyEntry } from "../shared/debrid-link-keys";
|
||||||
import { logger } from "./logger";
|
import { logger } from "./logger";
|
||||||
import { compactErrorText } from "./utils";
|
import { compactErrorText } from "./utils";
|
||||||
|
|
||||||
const MEGA_DEBRID_API = "https://www.mega-debrid.eu/api.php";
|
const MEGA_DEBRID_API = "https://www.mega-debrid.eu/api.php";
|
||||||
const DEBRID_LINK_API = "https://debrid-link.com/api/v2";
|
const DEBRID_LINK_API = "https://debrid-link.com/api/v2";
|
||||||
const CHECK_USER_AGENT =
|
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";
|
"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;
|
const CHECK_TIMEOUT_MS = 20000;
|
||||||
|
|
||||||
function timeoutSignal(signal: AbortSignal | undefined, ms: number): AbortSignal {
|
function timeoutSignal(signal: AbortSignal | undefined, ms: number): AbortSignal {
|
||||||
const timeout = AbortSignal.timeout(ms);
|
const timeout = AbortSignal.timeout(ms);
|
||||||
return signal ? AbortSignal.any([signal, timeout]) : timeout;
|
return signal ? AbortSignal.any([signal, timeout]) : timeout;
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseJsonSafe(text: string): Record<string, unknown> | null {
|
function parseJsonSafe(text: string): Record<string, unknown> | null {
|
||||||
try {
|
try {
|
||||||
const parsed = JSON.parse(text) as unknown;
|
const parsed = JSON.parse(text) as unknown;
|
||||||
return parsed && typeof parsed === "object" ? (parsed as Record<string, unknown>) : null;
|
return parsed && typeof parsed === "object" ? (parsed as Record<string, unknown>) : null;
|
||||||
} catch {
|
} catch {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatRemaining(premiumUntilMs: number | null, now: number): string {
|
function formatRemaining(premiumUntilMs: number | null, now: number): string {
|
||||||
if (premiumUntilMs == null) {
|
if (premiumUntilMs == null) {
|
||||||
return "Premium-Status unbekannt";
|
return "Premium-Status unbekannt";
|
||||||
}
|
}
|
||||||
if (premiumUntilMs <= 0) {
|
if (premiumUntilMs <= 0) {
|
||||||
return "Kein Premium";
|
return "Kein Premium";
|
||||||
}
|
}
|
||||||
const remainingMs = premiumUntilMs - now;
|
const remainingMs = premiumUntilMs - now;
|
||||||
if (remainingMs <= 0) {
|
if (remainingMs <= 0) {
|
||||||
return "Premium abgelaufen";
|
return "Premium abgelaufen";
|
||||||
}
|
}
|
||||||
const days = Math.floor(remainingMs / (24 * 60 * 60 * 1000));
|
const days = Math.floor(remainingMs / (24 * 60 * 60 * 1000));
|
||||||
if (days >= 1) {
|
if (days >= 1) {
|
||||||
return `Premium noch ${days} Tag${days === 1 ? "" : "e"}`;
|
return `Premium noch ${days} Tag${days === 1 ? "" : "e"}`;
|
||||||
}
|
}
|
||||||
const hours = Math.max(1, Math.floor(remainingMs / (60 * 60 * 1000)));
|
const hours = Math.max(1, Math.floor(remainingMs / (60 * 60 * 1000)));
|
||||||
return `Premium noch ${hours} Std`;
|
return `Premium noch ${hours} Std`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function checkMegaDebridAccount(
|
export async function checkMegaDebridAccount(
|
||||||
account: MegaDebridAccountEntry,
|
account: MegaDebridAccountEntry,
|
||||||
signal?: AbortSignal,
|
signal?: AbortSignal,
|
||||||
now = Date.now()
|
now = Date.now()
|
||||||
): Promise<DebridAccountStatus> {
|
): Promise<DebridAccountStatus> {
|
||||||
const base: DebridAccountStatus = {
|
const base: DebridAccountStatus = {
|
||||||
accountId: account.id,
|
accountId: account.id,
|
||||||
provider: "megadebrid",
|
provider: "megadebrid",
|
||||||
label: account.label,
|
label: account.label,
|
||||||
maskedLogin: account.maskedLogin,
|
maskedLogin: account.maskedLogin,
|
||||||
valid: false,
|
valid: false,
|
||||||
isPremium: false,
|
isPremium: false,
|
||||||
premiumUntilMs: null,
|
premiumUntilMs: null,
|
||||||
message: "",
|
message: "",
|
||||||
checkedAt: now
|
checkedAt: now
|
||||||
};
|
};
|
||||||
try {
|
try {
|
||||||
const url = `${MEGA_DEBRID_API}?action=connectUser&login=${encodeURIComponent(account.login)}&password=${encodeURIComponent(account.password)}`;
|
const url = `${MEGA_DEBRID_API}?action=connectUser&login=${encodeURIComponent(account.login)}&password=${encodeURIComponent(account.password)}`;
|
||||||
const response = await fetch(url, {
|
const response = await fetch(url, {
|
||||||
headers: { "User-Agent": CHECK_USER_AGENT },
|
headers: { "User-Agent": CHECK_USER_AGENT },
|
||||||
signal: timeoutSignal(signal, CHECK_TIMEOUT_MS)
|
signal: timeoutSignal(signal, CHECK_TIMEOUT_MS)
|
||||||
});
|
});
|
||||||
const text = await response.text();
|
const text = await response.text();
|
||||||
const payload = parseJsonSafe(text);
|
const payload = parseJsonSafe(text);
|
||||||
if (!response.ok || !payload) {
|
if (!response.ok || !payload) {
|
||||||
return { ...base, message: `Login fehlgeschlagen (HTTP ${response.status})` };
|
return { ...base, message: `Login fehlgeschlagen (HTTP ${response.status})` };
|
||||||
}
|
}
|
||||||
if (payload.response_code !== "ok") {
|
if (payload.response_code !== "ok") {
|
||||||
const reason = String(payload.response_text || payload.response_code || "Login abgelehnt");
|
const reason = String(payload.response_text || payload.response_code || "Login abgelehnt");
|
||||||
return { ...base, message: `Ungueltiger Login: ${reason}` };
|
return { ...base, message: `Ungueltiger Login: ${reason}` };
|
||||||
}
|
}
|
||||||
const vipEndRaw = Number(payload.vip_end || 0);
|
const vipEndRaw = Number(payload.vip_end || 0);
|
||||||
const premiumUntilMs = Number.isFinite(vipEndRaw) && vipEndRaw > 0 ? vipEndRaw * 1000 : 0;
|
const premiumUntilMs = Number.isFinite(vipEndRaw) && vipEndRaw > 0 ? vipEndRaw * 1000 : 0;
|
||||||
const isPremium = premiumUntilMs > now;
|
const isPremium = premiumUntilMs > now;
|
||||||
const email = String(payload.email || "").trim() || undefined;
|
const email = String(payload.email || "").trim() || undefined;
|
||||||
return {
|
return {
|
||||||
...base,
|
...base,
|
||||||
valid: true,
|
valid: true,
|
||||||
isPremium,
|
isPremium,
|
||||||
premiumUntilMs,
|
premiumUntilMs,
|
||||||
email,
|
email,
|
||||||
message: formatRemaining(premiumUntilMs, now)
|
message: formatRemaining(premiumUntilMs, now)
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const errText = compactErrorText(error);
|
const errText = compactErrorText(error);
|
||||||
const aborted = signal?.aborted || /aborted/i.test(errText);
|
const aborted = signal?.aborted || /aborted/i.test(errText);
|
||||||
return {
|
return {
|
||||||
...base,
|
...base,
|
||||||
message: aborted ? "Pruefung abgebrochen" : `Pruefung fehlgeschlagen: ${errText}`
|
message: aborted ? "Pruefung abgebrochen" : `Pruefung fehlgeschlagen: ${errText}`
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function checkDebridLinkKey(
|
export async function checkDebridLinkKey(
|
||||||
key: DebridLinkApiKeyEntry,
|
key: DebridLinkApiKeyEntry,
|
||||||
signal?: AbortSignal,
|
signal?: AbortSignal,
|
||||||
now = Date.now()
|
now = Date.now()
|
||||||
): Promise<DebridAccountStatus> {
|
): Promise<DebridAccountStatus> {
|
||||||
const base: DebridAccountStatus = {
|
const base: DebridAccountStatus = {
|
||||||
accountId: key.id,
|
accountId: key.id,
|
||||||
provider: "debridlink",
|
provider: "debridlink",
|
||||||
label: key.label,
|
label: key.label,
|
||||||
maskedLogin: key.masked,
|
maskedLogin: key.masked,
|
||||||
valid: false,
|
valid: false,
|
||||||
isPremium: false,
|
isPremium: false,
|
||||||
premiumUntilMs: null,
|
premiumUntilMs: null,
|
||||||
message: "",
|
message: "",
|
||||||
checkedAt: now
|
checkedAt: now
|
||||||
};
|
};
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`${DEBRID_LINK_API}/account/infos`, {
|
const response = await fetch(`${DEBRID_LINK_API}/account/infos`, {
|
||||||
headers: {
|
headers: {
|
||||||
Authorization: `Bearer ${key.token}`,
|
Authorization: `Bearer ${key.token}`,
|
||||||
"User-Agent": CHECK_USER_AGENT
|
"User-Agent": CHECK_USER_AGENT
|
||||||
},
|
},
|
||||||
signal: timeoutSignal(signal, CHECK_TIMEOUT_MS)
|
signal: timeoutSignal(signal, CHECK_TIMEOUT_MS)
|
||||||
});
|
});
|
||||||
const text = await response.text();
|
const text = await response.text();
|
||||||
const payload = parseJsonSafe(text);
|
const payload = parseJsonSafe(text);
|
||||||
if (!response.ok || !payload) {
|
if (!response.ok || !payload) {
|
||||||
if (response.status === 401 || response.status === 403) {
|
if (response.status === 401 || response.status === 403) {
|
||||||
return { ...base, message: "Ungueltiger API-Key (nicht autorisiert)" };
|
return { ...base, message: "Ungueltiger API-Key (nicht autorisiert)" };
|
||||||
}
|
}
|
||||||
return { ...base, message: `Pruefung fehlgeschlagen (HTTP ${response.status})` };
|
return { ...base, message: `Pruefung fehlgeschlagen (HTTP ${response.status})` };
|
||||||
}
|
}
|
||||||
if (payload.success === false) {
|
if (payload.success === false) {
|
||||||
const reason = String(payload.error || "Key abgelehnt");
|
const reason = String(payload.error || "Key abgelehnt");
|
||||||
return { ...base, message: `Ungueltiger API-Key: ${reason}` };
|
return { ...base, message: `Ungueltiger API-Key: ${reason}` };
|
||||||
}
|
}
|
||||||
const value = (payload.value && typeof payload.value === "object" ? payload.value : payload) as Record<string, unknown>;
|
const value = (payload.value && typeof payload.value === "object" ? payload.value : payload) as Record<string, unknown>;
|
||||||
const premiumLeftSec = Number(value.premiumLeft || 0);
|
const premiumLeftSec = Number(value.premiumLeft || 0);
|
||||||
const accountType = Number(value.accountType || 0);
|
const accountType = Number(value.accountType || 0);
|
||||||
const premiumUntilMs = Number.isFinite(premiumLeftSec) && premiumLeftSec > 0 ? now + premiumLeftSec * 1000 : 0;
|
const premiumUntilMs = Number.isFinite(premiumLeftSec) && premiumLeftSec > 0 ? now + premiumLeftSec * 1000 : 0;
|
||||||
const isPremium = premiumUntilMs > now || accountType > 0;
|
const isPremium = premiumUntilMs > now || accountType > 0;
|
||||||
const username = String(value.username || "").trim() || undefined;
|
const username = String(value.username || "").trim() || undefined;
|
||||||
return {
|
return {
|
||||||
...base,
|
...base,
|
||||||
valid: true,
|
valid: true,
|
||||||
isPremium,
|
isPremium,
|
||||||
premiumUntilMs: premiumUntilMs > 0 ? premiumUntilMs : (accountType > 0 ? null : 0),
|
premiumUntilMs: premiumUntilMs > 0 ? premiumUntilMs : (accountType > 0 ? null : 0),
|
||||||
email: username,
|
email: username,
|
||||||
message: premiumUntilMs > 0
|
message: premiumUntilMs > 0
|
||||||
? formatRemaining(premiumUntilMs, now)
|
? formatRemaining(premiumUntilMs, now)
|
||||||
: (accountType > 0 ? "Premium aktiv" : "Kein Premium (Free)")
|
: (accountType > 0 ? "Premium aktiv" : "Kein Premium (Free)")
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const errText = compactErrorText(error);
|
const errText = compactErrorText(error);
|
||||||
const aborted = signal?.aborted || /aborted/i.test(errText);
|
const aborted = signal?.aborted || /aborted/i.test(errText);
|
||||||
return {
|
return {
|
||||||
...base,
|
...base,
|
||||||
message: aborted ? "Pruefung abgebrochen" : `Pruefung fehlgeschlagen: ${errText}`
|
message: aborted ? "Pruefung abgebrochen" : `Pruefung fehlgeschlagen: ${errText}`
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function checkAllDebridAccounts(
|
export async function checkAllDebridAccounts(
|
||||||
settings: AppSettings,
|
settings: AppSettings,
|
||||||
signal?: AbortSignal
|
signal?: AbortSignal
|
||||||
): Promise<DebridAccountStatus[]> {
|
): Promise<DebridAccountStatus[]> {
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
const megaAccounts = parseMegaDebridAccounts(settings.megaCredentials || "", settings.megaPassword || "");
|
const megaAccounts = parseMegaDebridAccounts(settings.megaCredentials || "", settings.megaPassword || "");
|
||||||
const debridLinkKeys = parseDebridLinkApiKeys(settings.debridLinkApiKeys || "");
|
const debridLinkKeys = parseDebridLinkApiKeys(settings.debridLinkApiKeys || "");
|
||||||
|
|
||||||
const taskFns: Array<() => Promise<DebridAccountStatus>> = [
|
const taskFns: Array<() => Promise<DebridAccountStatus>> = [
|
||||||
...megaAccounts.map((account) => () => checkMegaDebridAccount(account, signal, now)),
|
...megaAccounts.map((account) => () => checkMegaDebridAccount(account, signal, now)),
|
||||||
...debridLinkKeys.map((key) => () => checkDebridLinkKey(key, signal, now))
|
...debridLinkKeys.map((key) => () => checkDebridLinkKey(key, signal, now))
|
||||||
];
|
];
|
||||||
|
|
||||||
const results = await runWithConcurrency(taskFns, CHECK_CONCURRENCY);
|
const results = await runWithConcurrency(taskFns, CHECK_CONCURRENCY);
|
||||||
logger.info(
|
logger.info(
|
||||||
`Account-Check abgeschlossen: ${results.length} Accounts geprueft ` +
|
`Account-Check abgeschlossen: ${results.length} Accounts geprueft ` +
|
||||||
`(${results.filter((r) => r.valid).length} gueltig, ${results.filter((r) => r.isPremium).length} premium)`
|
`(${results.filter((r) => r.valid).length} gueltig, ${results.filter((r) => r.isPremium).length} premium)`
|
||||||
);
|
);
|
||||||
return results;
|
return results;
|
||||||
}
|
}
|
||||||
|
|
||||||
const CHECK_CONCURRENCY = 4;
|
const CHECK_CONCURRENCY = 4;
|
||||||
|
|
||||||
async function runWithConcurrency<T>(taskFns: Array<() => Promise<T>>, limit: number): Promise<T[]> {
|
async function runWithConcurrency<T>(taskFns: Array<() => Promise<T>>, limit: number): Promise<T[]> {
|
||||||
const results: T[] = new Array(taskFns.length);
|
const results: T[] = new Array(taskFns.length);
|
||||||
let nextIndex = 0;
|
let nextIndex = 0;
|
||||||
const worker = async (): Promise<void> => {
|
const worker = async (): Promise<void> => {
|
||||||
while (nextIndex < taskFns.length) {
|
while (nextIndex < taskFns.length) {
|
||||||
const current = nextIndex;
|
const current = nextIndex;
|
||||||
nextIndex += 1;
|
nextIndex += 1;
|
||||||
results[current] = await taskFns[current]();
|
results[current] = await taskFns[current]();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
const workers = Array.from({ length: Math.min(limit, taskFns.length) }, () => worker());
|
const workers = Array.from({ length: Math.min(limit, taskFns.length) }, () => worker());
|
||||||
await Promise.all(workers);
|
await Promise.all(workers);
|
||||||
return results;
|
return results;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,204 +1,204 @@
|
|||||||
import fs from "node:fs";
|
import fs from "node:fs";
|
||||||
import { logTimestamp } from "./log-timestamp";
|
import { logTimestamp } from "./log-timestamp";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { AsyncLocalStorage } from "node:async_hooks";
|
import { AsyncLocalStorage } from "node:async_hooks";
|
||||||
import type { RotationEvent } from "../shared/types";
|
import type { RotationEvent } from "../shared/types";
|
||||||
|
|
||||||
export type RotationItemSink = (event: RotationEvent) => void;
|
export type RotationItemSink = (event: RotationEvent) => void;
|
||||||
const rotationItemContext = new AsyncLocalStorage<RotationItemSink>();
|
const rotationItemContext = new AsyncLocalStorage<RotationItemSink>();
|
||||||
|
|
||||||
export function runWithRotationItemSink<T>(sink: RotationItemSink, fn: () => Promise<T>): Promise<T> {
|
export function runWithRotationItemSink<T>(sink: RotationItemSink, fn: () => Promise<T>): Promise<T> {
|
||||||
return rotationItemContext.run(sink, fn);
|
return rotationItemContext.run(sink, fn);
|
||||||
}
|
}
|
||||||
|
|
||||||
type RotationLevel = "INFO" | "WARN" | "ERROR";
|
type RotationLevel = "INFO" | "WARN" | "ERROR";
|
||||||
|
|
||||||
const ROTATION_EVENT_RING_MAX = 60;
|
const ROTATION_EVENT_RING_MAX = 60;
|
||||||
const rotationEventRing: RotationEvent[] = [];
|
const rotationEventRing: RotationEvent[] = [];
|
||||||
let rotationEventSeq = 0;
|
let rotationEventSeq = 0;
|
||||||
let rotationEventListener: ((event: RotationEvent) => void) | null = null;
|
let rotationEventListener: ((event: RotationEvent) => void) | null = null;
|
||||||
|
|
||||||
export function setRotationEventListener(listener: ((event: RotationEvent) => void) | null): void {
|
export function setRotationEventListener(listener: ((event: RotationEvent) => void) | null): void {
|
||||||
rotationEventListener = listener;
|
rotationEventListener = listener;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getRecentRotationEvents(limit = ROTATION_EVENT_RING_MAX): RotationEvent[] {
|
export function getRecentRotationEvents(limit = ROTATION_EVENT_RING_MAX): RotationEvent[] {
|
||||||
const slice = rotationEventRing.slice(-limit);
|
const slice = rotationEventRing.slice(-limit);
|
||||||
slice.reverse();
|
slice.reverse();
|
||||||
return slice;
|
return slice;
|
||||||
}
|
}
|
||||||
|
|
||||||
function isUiRelevantRotationEvent(event: string): boolean {
|
function isUiRelevantRotationEvent(event: string): boolean {
|
||||||
return event !== "TEST";
|
return event !== "TEST";
|
||||||
}
|
}
|
||||||
|
|
||||||
function pushRotationEvent(
|
function pushRotationEvent(
|
||||||
level: RotationLevel,
|
level: RotationLevel,
|
||||||
provider: string,
|
provider: string,
|
||||||
accountLabel: string,
|
accountLabel: string,
|
||||||
event: string,
|
event: string,
|
||||||
fields?: Record<string, unknown>,
|
fields?: Record<string, unknown>,
|
||||||
at = Date.now()
|
at = Date.now()
|
||||||
): void {
|
): void {
|
||||||
rotationEventSeq += 1;
|
rotationEventSeq += 1;
|
||||||
const entry: RotationEvent = {
|
const entry: RotationEvent = {
|
||||||
id: `rot_${at}_${rotationEventSeq}`,
|
id: `rot_${at}_${rotationEventSeq}`,
|
||||||
at,
|
at,
|
||||||
level,
|
level,
|
||||||
provider,
|
provider,
|
||||||
accountLabel,
|
accountLabel,
|
||||||
event,
|
event,
|
||||||
reason: fields && fields.reason != null ? String(fields.reason) : undefined,
|
reason: fields && fields.reason != null ? String(fields.reason) : undefined,
|
||||||
category: fields && fields.category != null ? String(fields.category) : undefined,
|
category: fields && fields.category != null ? String(fields.category) : undefined,
|
||||||
cooldownSec: fields && fields.cooldownSec != null ? Number(fields.cooldownSec) || 0 : undefined,
|
cooldownSec: fields && fields.cooldownSec != null ? Number(fields.cooldownSec) || 0 : undefined,
|
||||||
next: fields && fields.next != null ? String(fields.next) : undefined
|
next: fields && fields.next != null ? String(fields.next) : undefined
|
||||||
};
|
};
|
||||||
|
|
||||||
const itemSink = rotationItemContext.getStore();
|
const itemSink = rotationItemContext.getStore();
|
||||||
if (itemSink) {
|
if (itemSink) {
|
||||||
try {
|
try {
|
||||||
itemSink(entry);
|
itemSink(entry);
|
||||||
} catch {
|
} catch {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!isUiRelevantRotationEvent(event)) {
|
if (!isUiRelevantRotationEvent(event)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
rotationEventRing.push(entry);
|
rotationEventRing.push(entry);
|
||||||
if (rotationEventRing.length > ROTATION_EVENT_RING_MAX) {
|
if (rotationEventRing.length > ROTATION_EVENT_RING_MAX) {
|
||||||
rotationEventRing.splice(0, rotationEventRing.length - ROTATION_EVENT_RING_MAX);
|
rotationEventRing.splice(0, rotationEventRing.length - ROTATION_EVENT_RING_MAX);
|
||||||
}
|
}
|
||||||
if (rotationEventListener) {
|
if (rotationEventListener) {
|
||||||
try {
|
try {
|
||||||
rotationEventListener(entry);
|
rotationEventListener(entry);
|
||||||
} catch {
|
} catch {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const ROTATION_LOG_MAX_FILE_BYTES = Number(process.env.RD_ACCOUNT_ROTATION_LOG_MAX_BYTES || 5 * 1024 * 1024);
|
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);
|
const ROTATION_LOG_RETENTION_DAYS = Number(process.env.RD_ACCOUNT_ROTATION_LOG_RETENTION_DAYS || 14);
|
||||||
|
|
||||||
let rotationLogPath: string | null = null;
|
let rotationLogPath: string | null = null;
|
||||||
|
|
||||||
function sanitizeFieldValue(value: unknown): string {
|
function sanitizeFieldValue(value: unknown): string {
|
||||||
if (value === undefined || value === null) {
|
if (value === undefined || value === null) {
|
||||||
return "";
|
return "";
|
||||||
}
|
}
|
||||||
if (typeof value === "string") {
|
if (typeof value === "string") {
|
||||||
return value.replace(/\r?\n/g, "\\n");
|
return value.replace(/\r?\n/g, "\\n");
|
||||||
}
|
}
|
||||||
if (typeof value === "number" || typeof value === "boolean") {
|
if (typeof value === "number" || typeof value === "boolean") {
|
||||||
return String(value);
|
return String(value);
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
return JSON.stringify(value).replace(/\r?\n/g, "\\n");
|
return JSON.stringify(value).replace(/\r?\n/g, "\\n");
|
||||||
} catch {
|
} catch {
|
||||||
return String(value);
|
return String(value);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatFields(fields?: Record<string, unknown>): string {
|
function formatFields(fields?: Record<string, unknown>): string {
|
||||||
if (!fields) {
|
if (!fields) {
|
||||||
return "";
|
return "";
|
||||||
}
|
}
|
||||||
const parts = Object.entries(fields)
|
const parts = Object.entries(fields)
|
||||||
.filter(([, value]) => value !== undefined && value !== null && sanitizeFieldValue(value) !== "")
|
.filter(([, value]) => value !== undefined && value !== null && sanitizeFieldValue(value) !== "")
|
||||||
.map(([key, value]) => `${key}=${sanitizeFieldValue(value)}`);
|
.map(([key, value]) => `${key}=${sanitizeFieldValue(value)}`);
|
||||||
return parts.length > 0 ? ` | ${parts.join(" | ")}` : "";
|
return parts.length > 0 ? ` | ${parts.join(" | ")}` : "";
|
||||||
}
|
}
|
||||||
|
|
||||||
function rotateIfNeeded(filePath: string): void {
|
function rotateIfNeeded(filePath: string): void {
|
||||||
try {
|
try {
|
||||||
const stat = fs.statSync(filePath);
|
const stat = fs.statSync(filePath);
|
||||||
if (stat.size < ROTATION_LOG_MAX_FILE_BYTES) {
|
if (stat.size < ROTATION_LOG_MAX_FILE_BYTES) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const backup = `${filePath}.old`;
|
const backup = `${filePath}.old`;
|
||||||
try {
|
try {
|
||||||
fs.rmSync(backup, { force: true });
|
fs.rmSync(backup, { force: true });
|
||||||
} catch {
|
} catch {
|
||||||
}
|
}
|
||||||
fs.renameSync(filePath, backup);
|
fs.renameSync(filePath, backup);
|
||||||
} catch {
|
} catch {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function cleanupOldBackup(filePath: string): void {
|
function cleanupOldBackup(filePath: string): void {
|
||||||
const backup = `${filePath}.old`;
|
const backup = `${filePath}.old`;
|
||||||
try {
|
try {
|
||||||
const stat = fs.statSync(backup);
|
const stat = fs.statSync(backup);
|
||||||
const cutoff = Date.now() - ROTATION_LOG_RETENTION_DAYS * 24 * 60 * 60 * 1000;
|
const cutoff = Date.now() - ROTATION_LOG_RETENTION_DAYS * 24 * 60 * 60 * 1000;
|
||||||
if (stat.mtimeMs < cutoff) {
|
if (stat.mtimeMs < cutoff) {
|
||||||
fs.rmSync(backup, { force: true });
|
fs.rmSync(backup, { force: true });
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function initAccountRotationLog(baseDir: string): void {
|
export function initAccountRotationLog(baseDir: string): void {
|
||||||
rotationLogPath = path.join(baseDir, "account-rotation.log");
|
rotationLogPath = path.join(baseDir, "account-rotation.log");
|
||||||
try {
|
try {
|
||||||
fs.mkdirSync(path.dirname(rotationLogPath), { recursive: true });
|
fs.mkdirSync(path.dirname(rotationLogPath), { recursive: true });
|
||||||
cleanupOldBackup(rotationLogPath);
|
cleanupOldBackup(rotationLogPath);
|
||||||
if (!fs.existsSync(rotationLogPath)) {
|
if (!fs.existsSync(rotationLogPath)) {
|
||||||
fs.writeFileSync(rotationLogPath, "", "utf8");
|
fs.writeFileSync(rotationLogPath, "", "utf8");
|
||||||
}
|
}
|
||||||
rotateIfNeeded(rotationLogPath);
|
rotateIfNeeded(rotationLogPath);
|
||||||
if (!fs.existsSync(rotationLogPath)) {
|
if (!fs.existsSync(rotationLogPath)) {
|
||||||
fs.writeFileSync(rotationLogPath, "", "utf8");
|
fs.writeFileSync(rotationLogPath, "", "utf8");
|
||||||
}
|
}
|
||||||
fs.appendFileSync(
|
fs.appendFileSync(
|
||||||
rotationLogPath,
|
rotationLogPath,
|
||||||
`=== Account-Rotation Log Start: ${logTimestamp()} ===\n`,
|
`=== Account-Rotation Log Start: ${logTimestamp()} ===\n`,
|
||||||
"utf8"
|
"utf8"
|
||||||
);
|
);
|
||||||
} catch {
|
} catch {
|
||||||
rotationLogPath = null;
|
rotationLogPath = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function logAccountRotation(
|
export function logAccountRotation(
|
||||||
level: RotationLevel,
|
level: RotationLevel,
|
||||||
provider: string,
|
provider: string,
|
||||||
accountLabel: string,
|
accountLabel: string,
|
||||||
event: string,
|
event: string,
|
||||||
fields?: Record<string, unknown>
|
fields?: Record<string, unknown>
|
||||||
): void {
|
): void {
|
||||||
pushRotationEvent(level, provider, accountLabel, event, fields);
|
pushRotationEvent(level, provider, accountLabel, event, fields);
|
||||||
if (!rotationLogPath) {
|
if (!rotationLogPath) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
rotateIfNeeded(rotationLogPath);
|
rotateIfNeeded(rotationLogPath);
|
||||||
if (!fs.existsSync(rotationLogPath)) {
|
if (!fs.existsSync(rotationLogPath)) {
|
||||||
fs.writeFileSync(rotationLogPath, "", "utf8");
|
fs.writeFileSync(rotationLogPath, "", "utf8");
|
||||||
}
|
}
|
||||||
const head = `${logTimestamp()} [${level}] ${provider} | ${accountLabel} | ${event}`;
|
const head = `${logTimestamp()} [${level}] ${provider} | ${accountLabel} | ${event}`;
|
||||||
fs.appendFileSync(rotationLogPath, `${head}${formatFields(fields)}\n`, "utf8");
|
fs.appendFileSync(rotationLogPath, `${head}${formatFields(fields)}\n`, "utf8");
|
||||||
} catch {
|
} catch {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getAccountRotationLogPath(): string | null {
|
export function getAccountRotationLogPath(): string | null {
|
||||||
if (!rotationLogPath) {
|
if (!rotationLogPath) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
return fs.existsSync(rotationLogPath) ? rotationLogPath : null;
|
return fs.existsSync(rotationLogPath) ? rotationLogPath : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function shutdownAccountRotationLog(): void {
|
export function shutdownAccountRotationLog(): void {
|
||||||
if (!rotationLogPath) {
|
if (!rotationLogPath) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
fs.appendFileSync(
|
fs.appendFileSync(
|
||||||
rotationLogPath,
|
rotationLogPath,
|
||||||
`=== Account-Rotation Log Ende: ${logTimestamp()} ===\n`,
|
`=== Account-Rotation Log Ende: ${logTimestamp()} ===\n`,
|
||||||
"utf8"
|
"utf8"
|
||||||
);
|
);
|
||||||
} catch {
|
} catch {
|
||||||
}
|
}
|
||||||
rotationLogPath = null;
|
rotationLogPath = null;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,482 +1,482 @@
|
|||||||
import { BrowserWindow, session } from "electron";
|
import { BrowserWindow, session } from "electron";
|
||||||
import { AllDebridHostInfo } from "../shared/types";
|
import { AllDebridHostInfo } from "../shared/types";
|
||||||
import { UnrestrictedLink } from "./realdebrid";
|
import { UnrestrictedLink } from "./realdebrid";
|
||||||
import { filenameFromUrl, sleep } from "./utils";
|
import { filenameFromUrl, sleep } from "./utils";
|
||||||
|
|
||||||
const ALLDEBRID_BASE_URL = "https://alldebrid.com";
|
const ALLDEBRID_BASE_URL = "https://alldebrid.com";
|
||||||
const ALLDEBRID_LOGIN_URL = `${ALLDEBRID_BASE_URL}/register/?from=de`;
|
const ALLDEBRID_LOGIN_URL = `${ALLDEBRID_BASE_URL}/register/?from=de`;
|
||||||
const ALLDEBRID_SERVICE_URL = `${ALLDEBRID_BASE_URL}/service.php`;
|
const ALLDEBRID_SERVICE_URL = `${ALLDEBRID_BASE_URL}/service.php`;
|
||||||
const ALLDEBRID_SERVICE_REFERER = `${ALLDEBRID_BASE_URL}/service/?from=de`;
|
const ALLDEBRID_SERVICE_REFERER = `${ALLDEBRID_BASE_URL}/service/?from=de`;
|
||||||
const ALLDEBRID_DELAYED_URL = `${ALLDEBRID_BASE_URL}/internalapi/v4/link/delayed`;
|
const ALLDEBRID_DELAYED_URL = `${ALLDEBRID_BASE_URL}/internalapi/v4/link/delayed`;
|
||||||
const ALLDEBRID_STATUS_URL = `${ALLDEBRID_BASE_URL}/status/`;
|
const ALLDEBRID_STATUS_URL = `${ALLDEBRID_BASE_URL}/status/`;
|
||||||
const ALLDEBRID_PERSISTENT_PARTITION = "persist:alldebrid-web";
|
const ALLDEBRID_PERSISTENT_PARTITION = "persist:alldebrid-web";
|
||||||
const ALLDEBRID_TRANSIENT_PARTITION = "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";
|
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 = {
|
type DelayedStatusPayload = {
|
||||||
status: number;
|
status: number;
|
||||||
link: string;
|
link: string;
|
||||||
timeLeft: number;
|
timeLeft: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
type GenerateOutcome =
|
type GenerateOutcome =
|
||||||
| { kind: "success"; value: UnrestrictedLink }
|
| { kind: "success"; value: UnrestrictedLink }
|
||||||
| { kind: "login_required" };
|
| { kind: "login_required" };
|
||||||
|
|
||||||
function abortError(): Error {
|
function abortError(): Error {
|
||||||
return new Error("aborted:alldebrid-web");
|
return new Error("aborted:alldebrid-web");
|
||||||
}
|
}
|
||||||
|
|
||||||
function withTimeoutSignal(signal: AbortSignal | undefined, timeoutMs: number): AbortSignal {
|
function withTimeoutSignal(signal: AbortSignal | undefined, timeoutMs: number): AbortSignal {
|
||||||
const timeoutSignal = AbortSignal.timeout(timeoutMs);
|
const timeoutSignal = AbortSignal.timeout(timeoutMs);
|
||||||
if (!signal) {
|
if (!signal) {
|
||||||
return timeoutSignal;
|
return timeoutSignal;
|
||||||
}
|
}
|
||||||
return AbortSignal.any([signal, timeoutSignal]);
|
return AbortSignal.any([signal, timeoutSignal]);
|
||||||
}
|
}
|
||||||
|
|
||||||
function throwIfAborted(signal?: AbortSignal): void {
|
function throwIfAborted(signal?: AbortSignal): void {
|
||||||
if (signal?.aborted) {
|
if (signal?.aborted) {
|
||||||
throw abortError();
|
throw abortError();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function sleepWithSignal(ms: number, signal?: AbortSignal): Promise<void> {
|
async function sleepWithSignal(ms: number, signal?: AbortSignal): Promise<void> {
|
||||||
if (!signal) {
|
if (!signal) {
|
||||||
await sleep(ms);
|
await sleep(ms);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (signal.aborted) {
|
if (signal.aborted) {
|
||||||
throw abortError();
|
throw abortError();
|
||||||
}
|
}
|
||||||
|
|
||||||
await new Promise<void>((resolve, reject) => {
|
await new Promise<void>((resolve, reject) => {
|
||||||
let timer: NodeJS.Timeout | null = setTimeout(() => {
|
let timer: NodeJS.Timeout | null = setTimeout(() => {
|
||||||
timer = null;
|
timer = null;
|
||||||
signal.removeEventListener("abort", onAbort);
|
signal.removeEventListener("abort", onAbort);
|
||||||
resolve();
|
resolve();
|
||||||
}, Math.max(0, ms));
|
}, Math.max(0, ms));
|
||||||
|
|
||||||
const onAbort = (): void => {
|
const onAbort = (): void => {
|
||||||
if (timer) {
|
if (timer) {
|
||||||
clearTimeout(timer);
|
clearTimeout(timer);
|
||||||
timer = null;
|
timer = null;
|
||||||
}
|
}
|
||||||
signal.removeEventListener("abort", onAbort);
|
signal.removeEventListener("abort", onAbort);
|
||||||
reject(abortError());
|
reject(abortError());
|
||||||
};
|
};
|
||||||
|
|
||||||
signal.addEventListener("abort", onAbort, { once: true });
|
signal.addEventListener("abort", onAbort, { once: true });
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function asRecord(value: unknown): Record<string, unknown> | null {
|
function asRecord(value: unknown): Record<string, unknown> | null {
|
||||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
return value as Record<string, unknown>;
|
return value as Record<string, unknown>;
|
||||||
}
|
}
|
||||||
|
|
||||||
function pickString(payload: Record<string, unknown> | null, keys: string[]): string {
|
function pickString(payload: Record<string, unknown> | null, keys: string[]): string {
|
||||||
if (!payload) {
|
if (!payload) {
|
||||||
return "";
|
return "";
|
||||||
}
|
}
|
||||||
for (const key of keys) {
|
for (const key of keys) {
|
||||||
const value = payload[key];
|
const value = payload[key];
|
||||||
if (typeof value === "string" && value.trim()) {
|
if (typeof value === "string" && value.trim()) {
|
||||||
return value.trim();
|
return value.trim();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return "";
|
return "";
|
||||||
}
|
}
|
||||||
|
|
||||||
function pickNumber(payload: Record<string, unknown> | null, keys: string[]): number | null {
|
function pickNumber(payload: Record<string, unknown> | null, keys: string[]): number | null {
|
||||||
if (!payload) {
|
if (!payload) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
for (const key of keys) {
|
for (const key of keys) {
|
||||||
const value = Number(payload[key] ?? NaN);
|
const value = Number(payload[key] ?? NaN);
|
||||||
if (Number.isFinite(value) && value >= 0) {
|
if (Number.isFinite(value) && value >= 0) {
|
||||||
return Math.floor(value);
|
return Math.floor(value);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseJson(text: string): Record<string, unknown> | null {
|
function parseJson(text: string): Record<string, unknown> | null {
|
||||||
try {
|
try {
|
||||||
return asRecord(JSON.parse(text) as unknown);
|
return asRecord(JSON.parse(text) as unknown);
|
||||||
} catch {
|
} catch {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeHostName(value: string): string {
|
function normalizeHostName(value: string): string {
|
||||||
return String(value || "").replace(/[^a-z0-9]+/gi, "").toLowerCase();
|
return String(value || "").replace(/[^a-z0-9]+/gi, "").toLowerCase();
|
||||||
}
|
}
|
||||||
|
|
||||||
function toHostStateFromIcon(url: string): AllDebridHostInfo["state"] {
|
function toHostStateFromIcon(url: string): AllDebridHostInfo["state"] {
|
||||||
const normalized = String(url || "").toLowerCase();
|
const normalized = String(url || "").toLowerCase();
|
||||||
if (normalized.includes("up.gif")) {
|
if (normalized.includes("up.gif")) {
|
||||||
return "up";
|
return "up";
|
||||||
}
|
}
|
||||||
if (normalized.includes("down.gif")) {
|
if (normalized.includes("down.gif")) {
|
||||||
return "down";
|
return "down";
|
||||||
}
|
}
|
||||||
if (normalized.includes("not.tracked")) {
|
if (normalized.includes("not.tracked")) {
|
||||||
return "not_tracked";
|
return "not_tracked";
|
||||||
}
|
}
|
||||||
return "unknown";
|
return "unknown";
|
||||||
}
|
}
|
||||||
|
|
||||||
function toHostStatusLabel(state: AllDebridHostInfo["state"]): string {
|
function toHostStatusLabel(state: AllDebridHostInfo["state"]): string {
|
||||||
if (state === "up") {
|
if (state === "up") {
|
||||||
return "Verfügbar";
|
return "Verfügbar";
|
||||||
}
|
}
|
||||||
if (state === "down") {
|
if (state === "down") {
|
||||||
return "Unverfügbar";
|
return "Unverfügbar";
|
||||||
}
|
}
|
||||||
if (state === "not_tracked") {
|
if (state === "not_tracked") {
|
||||||
return "Nicht getrackt";
|
return "Nicht getrackt";
|
||||||
}
|
}
|
||||||
return "Unbekannt";
|
return "Unbekannt";
|
||||||
}
|
}
|
||||||
|
|
||||||
function extractHostInfoFromStatusPage(html: string, host: string): AllDebridHostInfo | null {
|
function extractHostInfoFromStatusPage(html: string, host: string): AllDebridHostInfo | null {
|
||||||
const wanted = normalizeHostName(host);
|
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;
|
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)) {
|
for (let match = rowRegex.exec(html); match; match = rowRegex.exec(html)) {
|
||||||
const hostAlt = normalizeHostName(match[1] || "");
|
const hostAlt = normalizeHostName(match[1] || "");
|
||||||
if (hostAlt !== wanted) {
|
if (hostAlt !== wanted) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
const state = toHostStateFromIcon(match[2] || "");
|
const state = toHostStateFromIcon(match[2] || "");
|
||||||
const lastCheckedSeconds = Number(match[3] ?? NaN);
|
const lastCheckedSeconds = Number(match[3] ?? NaN);
|
||||||
return {
|
return {
|
||||||
host,
|
host,
|
||||||
source: "web",
|
source: "web",
|
||||||
state,
|
state,
|
||||||
statusLabel: toHostStatusLabel(state),
|
statusLabel: toHostStatusLabel(state),
|
||||||
fetchedAt: Date.now(),
|
fetchedAt: Date.now(),
|
||||||
lastCheckedAt: Number.isFinite(lastCheckedSeconds) ? lastCheckedSeconds * 1000 : null,
|
lastCheckedAt: Number.isFinite(lastCheckedSeconds) ? lastCheckedSeconds * 1000 : null,
|
||||||
quota: null,
|
quota: null,
|
||||||
quotaMax: null,
|
quotaMax: null,
|
||||||
quotaType: "",
|
quotaType: "",
|
||||||
limitSimuDl: null,
|
limitSimuDl: null,
|
||||||
note: "Quota und Simultan-Slots sind per Web-Login nicht öffentlich verfügbar."
|
note: "Quota und Simultan-Slots sind per Web-Login nicht öffentlich verfügbar."
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class AllDebridWebFallback {
|
export class AllDebridWebFallback {
|
||||||
private queue: Promise<unknown> = Promise.resolve();
|
private queue: Promise<unknown> = Promise.resolve();
|
||||||
|
|
||||||
private loginWindow: BrowserWindow | null = null;
|
private loginWindow: BrowserWindow | null = null;
|
||||||
|
|
||||||
private loginWindowPartition = "";
|
private loginWindowPartition = "";
|
||||||
|
|
||||||
private getRememberSession: () => boolean;
|
private getRememberSession: () => boolean;
|
||||||
|
|
||||||
public constructor(getRememberSession: () => boolean) {
|
public constructor(getRememberSession: () => boolean) {
|
||||||
this.getRememberSession = getRememberSession;
|
this.getRememberSession = getRememberSession;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async unrestrict(link: string, signal?: AbortSignal): Promise<UnrestrictedLink | null> {
|
public async unrestrict(link: string, signal?: AbortSignal): Promise<UnrestrictedLink | null> {
|
||||||
const overallSignal = withTimeoutSignal(signal, 10 * 60 * 1000);
|
const overallSignal = withTimeoutSignal(signal, 10 * 60 * 1000);
|
||||||
return this.runExclusive(async () => {
|
return this.runExclusive(async () => {
|
||||||
throwIfAborted(overallSignal);
|
throwIfAborted(overallSignal);
|
||||||
if (!String(link || "").trim()) {
|
if (!String(link || "").trim()) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const initial = await this.generate(link, overallSignal);
|
const initial = await this.generate(link, overallSignal);
|
||||||
if (initial.kind === "success") {
|
if (initial.kind === "success") {
|
||||||
return initial.value;
|
return initial.value;
|
||||||
}
|
}
|
||||||
return this.waitForLoginAndGenerate(link, overallSignal);
|
return this.waitForLoginAndGenerate(link, overallSignal);
|
||||||
}, overallSignal);
|
}, overallSignal);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async openLoginWindow(): Promise<void> {
|
public async openLoginWindow(): Promise<void> {
|
||||||
const window = await this.ensureLoginWindow();
|
const window = await this.ensureLoginWindow();
|
||||||
if (window.isMinimized()) {
|
if (window.isMinimized()) {
|
||||||
window.restore();
|
window.restore();
|
||||||
}
|
}
|
||||||
window.show();
|
window.show();
|
||||||
window.focus();
|
window.focus();
|
||||||
}
|
}
|
||||||
|
|
||||||
public async getHostInfo(host: string): Promise<AllDebridHostInfo> {
|
public async getHostInfo(host: string): Promise<AllDebridHostInfo> {
|
||||||
const currentSession = session.fromPartition(this.getPartition());
|
const currentSession = session.fromPartition(this.getPartition());
|
||||||
const response = await currentSession.fetch(ALLDEBRID_STATUS_URL, {
|
const response = await currentSession.fetch(ALLDEBRID_STATUS_URL, {
|
||||||
headers: {
|
headers: {
|
||||||
Accept: "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
|
Accept: "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
|
||||||
Referer: ALLDEBRID_SERVICE_REFERER,
|
Referer: ALLDEBRID_SERVICE_REFERER,
|
||||||
"User-Agent": ALLDEBRID_USER_AGENT
|
"User-Agent": ALLDEBRID_USER_AGENT
|
||||||
},
|
},
|
||||||
signal: withTimeoutSignal(undefined, 30_000)
|
signal: withTimeoutSignal(undefined, 30_000)
|
||||||
});
|
});
|
||||||
const text = await response.text();
|
const text = await response.text();
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error(`AllDebrid Web Status HTTP ${response.status}`);
|
throw new Error(`AllDebrid Web Status HTTP ${response.status}`);
|
||||||
}
|
}
|
||||||
if (!/id=['"]statusContainer['"]/i.test(text)) {
|
if (!/id=['"]statusContainer['"]/i.test(text)) {
|
||||||
throw new Error("AllDebrid Web-Status nicht verfügbar. Bitte zuerst im AllDebrid-Fenster einloggen.");
|
throw new Error("AllDebrid Web-Status nicht verfügbar. Bitte zuerst im AllDebrid-Fenster einloggen.");
|
||||||
}
|
}
|
||||||
const info = extractHostInfoFromStatusPage(text, host);
|
const info = extractHostInfoFromStatusPage(text, host);
|
||||||
if (!info) {
|
if (!info) {
|
||||||
throw new Error(`AllDebrid Web-Status für ${host} nicht gefunden`);
|
throw new Error(`AllDebrid Web-Status für ${host} nicht gefunden`);
|
||||||
}
|
}
|
||||||
return info;
|
return info;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async clearSessions(): Promise<void> {
|
public async clearSessions(): Promise<void> {
|
||||||
this.disposeLoginWindow();
|
this.disposeLoginWindow();
|
||||||
for (const partition of [ALLDEBRID_PERSISTENT_PARTITION, ALLDEBRID_TRANSIENT_PARTITION]) {
|
for (const partition of [ALLDEBRID_PERSISTENT_PARTITION, ALLDEBRID_TRANSIENT_PARTITION]) {
|
||||||
const currentSession = session.fromPartition(partition);
|
const currentSession = session.fromPartition(partition);
|
||||||
try {
|
try {
|
||||||
await currentSession.clearStorageData({
|
await currentSession.clearStorageData({
|
||||||
storages: ["cookies", "indexdb", "localstorage", "serviceworkers", "cachestorage"]
|
storages: ["cookies", "indexdb", "localstorage", "serviceworkers", "cachestorage"]
|
||||||
});
|
});
|
||||||
} catch {
|
} catch {
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
await currentSession.clearCache();
|
await currentSession.clearCache();
|
||||||
} catch {
|
} catch {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public dispose(): void {
|
public dispose(): void {
|
||||||
this.disposeLoginWindow();
|
this.disposeLoginWindow();
|
||||||
}
|
}
|
||||||
|
|
||||||
private getPartition(): string {
|
private getPartition(): string {
|
||||||
return this.getRememberSession() ? ALLDEBRID_PERSISTENT_PARTITION : ALLDEBRID_TRANSIENT_PARTITION;
|
return this.getRememberSession() ? ALLDEBRID_PERSISTENT_PARTITION : ALLDEBRID_TRANSIENT_PARTITION;
|
||||||
}
|
}
|
||||||
|
|
||||||
private disposeLoginWindow(): void {
|
private disposeLoginWindow(): void {
|
||||||
const current = this.loginWindow;
|
const current = this.loginWindow;
|
||||||
this.loginWindow = null;
|
this.loginWindow = null;
|
||||||
this.loginWindowPartition = "";
|
this.loginWindowPartition = "";
|
||||||
if (current && !current.isDestroyed()) {
|
if (current && !current.isDestroyed()) {
|
||||||
current.close();
|
current.close();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async runExclusive<T>(job: () => Promise<T>, signal?: AbortSignal): Promise<T> {
|
private async runExclusive<T>(job: () => Promise<T>, signal?: AbortSignal): Promise<T> {
|
||||||
const queuedAt = Date.now();
|
const queuedAt = Date.now();
|
||||||
const queueWaitTimeoutMs = 90_000;
|
const queueWaitTimeoutMs = 90_000;
|
||||||
const guardedJob = async (): Promise<T> => {
|
const guardedJob = async (): Promise<T> => {
|
||||||
throwIfAborted(signal);
|
throwIfAborted(signal);
|
||||||
const waited = Date.now() - queuedAt;
|
const waited = Date.now() - queuedAt;
|
||||||
if (waited > queueWaitTimeoutMs) {
|
if (waited > queueWaitTimeoutMs) {
|
||||||
throw new Error(`AllDebrid-Web Queue-Timeout (${Math.floor(waited / 1000)}s gewartet)`);
|
throw new Error(`AllDebrid-Web Queue-Timeout (${Math.floor(waited / 1000)}s gewartet)`);
|
||||||
}
|
}
|
||||||
return job();
|
return job();
|
||||||
};
|
};
|
||||||
const run = this.queue.then(guardedJob, guardedJob);
|
const run = this.queue.then(guardedJob, guardedJob);
|
||||||
this.queue = run.then(() => undefined, () => undefined);
|
this.queue = run.then(() => undefined, () => undefined);
|
||||||
return run;
|
return run;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async ensureLoginWindow(): Promise<BrowserWindow> {
|
private async ensureLoginWindow(): Promise<BrowserWindow> {
|
||||||
const partition = this.getPartition();
|
const partition = this.getPartition();
|
||||||
const existing = this.loginWindow;
|
const existing = this.loginWindow;
|
||||||
if (existing && !existing.isDestroyed() && this.loginWindowPartition === partition) {
|
if (existing && !existing.isDestroyed() && this.loginWindowPartition === partition) {
|
||||||
return existing;
|
return existing;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (existing && !existing.isDestroyed()) {
|
if (existing && !existing.isDestroyed()) {
|
||||||
existing.close();
|
existing.close();
|
||||||
}
|
}
|
||||||
|
|
||||||
const window = new BrowserWindow({
|
const window = new BrowserWindow({
|
||||||
width: 1120,
|
width: 1120,
|
||||||
height: 900,
|
height: 900,
|
||||||
minWidth: 980,
|
minWidth: 980,
|
||||||
minHeight: 760,
|
minHeight: 760,
|
||||||
autoHideMenuBar: true,
|
autoHideMenuBar: true,
|
||||||
title: "AllDebrid Web-Login",
|
title: "AllDebrid Web-Login",
|
||||||
webPreferences: {
|
webPreferences: {
|
||||||
partition,
|
partition,
|
||||||
contextIsolation: true,
|
contextIsolation: true,
|
||||||
nodeIntegration: false
|
nodeIntegration: false
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
window.setMenuBarVisibility(false);
|
window.setMenuBarVisibility(false);
|
||||||
window.on("closed", () => {
|
window.on("closed", () => {
|
||||||
if (this.loginWindow === window) {
|
if (this.loginWindow === window) {
|
||||||
this.loginWindow = null;
|
this.loginWindow = null;
|
||||||
this.loginWindowPartition = "";
|
this.loginWindowPartition = "";
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
this.loginWindow = window;
|
this.loginWindow = window;
|
||||||
this.loginWindowPartition = partition;
|
this.loginWindowPartition = partition;
|
||||||
await window.loadURL(ALLDEBRID_LOGIN_URL);
|
await window.loadURL(ALLDEBRID_LOGIN_URL);
|
||||||
return window;
|
return window;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async postForm(
|
private async postForm(
|
||||||
url: string,
|
url: string,
|
||||||
body: URLSearchParams,
|
body: URLSearchParams,
|
||||||
referer: string,
|
referer: string,
|
||||||
signal?: AbortSignal
|
signal?: AbortSignal
|
||||||
): Promise<{ response: Response; text: string }> {
|
): Promise<{ response: Response; text: string }> {
|
||||||
const currentSession = session.fromPartition(this.getPartition());
|
const currentSession = session.fromPartition(this.getPartition());
|
||||||
const response = await currentSession.fetch(url, {
|
const response = await currentSession.fetch(url, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: {
|
headers: {
|
||||||
Accept: "application/json, text/javascript, */*; q=0.01",
|
Accept: "application/json, text/javascript, */*; q=0.01",
|
||||||
"Content-Type": "application/x-www-form-urlencoded; charset=UTF-8",
|
"Content-Type": "application/x-www-form-urlencoded; charset=UTF-8",
|
||||||
Origin: ALLDEBRID_BASE_URL,
|
Origin: ALLDEBRID_BASE_URL,
|
||||||
Referer: referer,
|
Referer: referer,
|
||||||
"User-Agent": ALLDEBRID_USER_AGENT,
|
"User-Agent": ALLDEBRID_USER_AGENT,
|
||||||
"X-Requested-With": "XMLHttpRequest"
|
"X-Requested-With": "XMLHttpRequest"
|
||||||
},
|
},
|
||||||
body: body.toString(),
|
body: body.toString(),
|
||||||
signal: withTimeoutSignal(signal, 30_000)
|
signal: withTimeoutSignal(signal, 30_000)
|
||||||
});
|
});
|
||||||
const text = await response.text();
|
const text = await response.text();
|
||||||
return { response, text };
|
return { response, text };
|
||||||
}
|
}
|
||||||
|
|
||||||
private async generate(link: string, signal?: AbortSignal): Promise<GenerateOutcome> {
|
private async generate(link: string, signal?: AbortSignal): Promise<GenerateOutcome> {
|
||||||
throwIfAborted(signal);
|
throwIfAborted(signal);
|
||||||
const body = new URLSearchParams({
|
const body = new URLSearchParams({
|
||||||
link,
|
link,
|
||||||
nb: "0",
|
nb: "0",
|
||||||
json: "true",
|
json: "true",
|
||||||
pw: ""
|
pw: ""
|
||||||
});
|
});
|
||||||
const { response, text } = await this.postForm(ALLDEBRID_SERVICE_URL, body, ALLDEBRID_SERVICE_REFERER, signal);
|
const { response, text } = await this.postForm(ALLDEBRID_SERVICE_URL, body, ALLDEBRID_SERVICE_REFERER, signal);
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error(`AllDebrid Web HTTP ${response.status}`);
|
throw new Error(`AllDebrid Web HTTP ${response.status}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const trimmed = text.trim();
|
const trimmed = text.trim();
|
||||||
if (trimmed === "login") {
|
if (trimmed === "login") {
|
||||||
return { kind: "login_required" };
|
return { kind: "login_required" };
|
||||||
}
|
}
|
||||||
|
|
||||||
const payload = parseJson(trimmed);
|
const payload = parseJson(trimmed);
|
||||||
if (!payload) {
|
if (!payload) {
|
||||||
throw new Error("AllDebrid Web lieferte keine JSON-Antwort");
|
throw new Error("AllDebrid Web lieferte keine JSON-Antwort");
|
||||||
}
|
}
|
||||||
|
|
||||||
const errorText = pickString(payload, ["error"]);
|
const errorText = pickString(payload, ["error"]);
|
||||||
if (errorText) {
|
if (errorText) {
|
||||||
if (errorText.toLowerCase() === "premium") {
|
if (errorText.toLowerCase() === "premium") {
|
||||||
throw new Error("AllDebrid Web: Premium erforderlich");
|
throw new Error("AllDebrid Web: Premium erforderlich");
|
||||||
}
|
}
|
||||||
throw new Error(`AllDebrid Web: ${errorText}`);
|
throw new Error(`AllDebrid Web: ${errorText}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const directUrl = pickString(payload, ["link"]);
|
const directUrl = pickString(payload, ["link"]);
|
||||||
const fileName = pickString(payload, ["filename"]);
|
const fileName = pickString(payload, ["filename"]);
|
||||||
const fileSize = pickNumber(payload, ["filesize"]);
|
const fileSize = pickNumber(payload, ["filesize"]);
|
||||||
if (directUrl) {
|
if (directUrl) {
|
||||||
return {
|
return {
|
||||||
kind: "success",
|
kind: "success",
|
||||||
value: {
|
value: {
|
||||||
directUrl,
|
directUrl,
|
||||||
fileName: fileName || filenameFromUrl(directUrl) || filenameFromUrl(link),
|
fileName: fileName || filenameFromUrl(directUrl) || filenameFromUrl(link),
|
||||||
fileSize,
|
fileSize,
|
||||||
retriesUsed: 0
|
retriesUsed: 0
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const delayedId = payload.delayed;
|
const delayedId = payload.delayed;
|
||||||
if (delayedId !== undefined && delayedId !== null && delayedId !== false && String(delayedId).trim()) {
|
if (delayedId !== undefined && delayedId !== null && delayedId !== false && String(delayedId).trim()) {
|
||||||
const delayed = await this.waitForDelayedLink(String(delayedId).trim(), signal);
|
const delayed = await this.waitForDelayedLink(String(delayedId).trim(), signal);
|
||||||
return {
|
return {
|
||||||
kind: "success",
|
kind: "success",
|
||||||
value: {
|
value: {
|
||||||
directUrl: delayed.link,
|
directUrl: delayed.link,
|
||||||
fileName: fileName || filenameFromUrl(delayed.link) || filenameFromUrl(link),
|
fileName: fileName || filenameFromUrl(delayed.link) || filenameFromUrl(link),
|
||||||
fileSize: fileSize,
|
fileSize: fileSize,
|
||||||
retriesUsed: 0
|
retriesUsed: 0
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
if (Array.isArray(payload.streams) && payload.streams.length > 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: Streaming-Auswahl wird derzeit nicht unterstützt");
|
||||||
}
|
}
|
||||||
|
|
||||||
throw new Error("AllDebrid Web: Antwort ohne Download-Link");
|
throw new Error("AllDebrid Web: Antwort ohne Download-Link");
|
||||||
}
|
}
|
||||||
|
|
||||||
private async waitForDelayedLink(delayedId: string, signal?: AbortSignal): Promise<DelayedStatusPayload> {
|
private async waitForDelayedLink(delayedId: string, signal?: AbortSignal): Promise<DelayedStatusPayload> {
|
||||||
for (let attempt = 1; attempt <= 120; attempt += 1) {
|
for (let attempt = 1; attempt <= 120; attempt += 1) {
|
||||||
throwIfAborted(signal);
|
throwIfAborted(signal);
|
||||||
const body = new URLSearchParams({ id: delayedId });
|
const body = new URLSearchParams({ id: delayedId });
|
||||||
const { response, text } = await this.postForm(ALLDEBRID_DELAYED_URL, body, ALLDEBRID_SERVICE_REFERER, signal);
|
const { response, text } = await this.postForm(ALLDEBRID_DELAYED_URL, body, ALLDEBRID_SERVICE_REFERER, signal);
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error(`AllDebrid Web delayed HTTP ${response.status}`);
|
throw new Error(`AllDebrid Web delayed HTTP ${response.status}`);
|
||||||
}
|
}
|
||||||
const payload = parseJson(text.trim());
|
const payload = parseJson(text.trim());
|
||||||
const data = asRecord(payload?.data);
|
const data = asRecord(payload?.data);
|
||||||
if (pickString(payload, ["status"]).toLowerCase() !== "success" || !data) {
|
if (pickString(payload, ["status"]).toLowerCase() !== "success" || !data) {
|
||||||
throw new Error("AllDebrid Web: Delayed-Status ungültig");
|
throw new Error("AllDebrid Web: Delayed-Status ungültig");
|
||||||
}
|
}
|
||||||
|
|
||||||
const status = Number(data.status ?? NaN);
|
const status = Number(data.status ?? NaN);
|
||||||
if (!Number.isFinite(status)) {
|
if (!Number.isFinite(status)) {
|
||||||
throw new Error("AllDebrid Web: Delayed-Status ohne Status");
|
throw new Error("AllDebrid Web: Delayed-Status ohne Status");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (status >= 2) {
|
if (status >= 2) {
|
||||||
const link = pickString(data, ["link"]);
|
const link = pickString(data, ["link"]);
|
||||||
if (!link) {
|
if (!link) {
|
||||||
throw new Error("AllDebrid Web: Delayed-Link fehlt");
|
throw new Error("AllDebrid Web: Delayed-Link fehlt");
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
status,
|
status,
|
||||||
link,
|
link,
|
||||||
timeLeft: Math.max(0, Number(data.time_left ?? 0) || 0)
|
timeLeft: Math.max(0, Number(data.time_left ?? 0) || 0)
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const 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;
|
const delayMs = timeLeft > 0 ? Math.min(5_000, Math.max(1_500, timeLeft * 250)) : 2_000;
|
||||||
await sleepWithSignal(delayMs, signal);
|
await sleepWithSignal(delayMs, signal);
|
||||||
}
|
}
|
||||||
|
|
||||||
throw new Error("AllDebrid Web: Delayed-Link Timeout");
|
throw new Error("AllDebrid Web: Delayed-Link Timeout");
|
||||||
}
|
}
|
||||||
|
|
||||||
private async waitForLoginAndGenerate(link: string, signal?: AbortSignal): Promise<UnrestrictedLink | null> {
|
private async waitForLoginAndGenerate(link: string, signal?: AbortSignal): Promise<UnrestrictedLink | null> {
|
||||||
const window = await this.ensureLoginWindow();
|
const window = await this.ensureLoginWindow();
|
||||||
if (window.isMinimized()) {
|
if (window.isMinimized()) {
|
||||||
window.restore();
|
window.restore();
|
||||||
}
|
}
|
||||||
window.show();
|
window.show();
|
||||||
window.focus();
|
window.focus();
|
||||||
|
|
||||||
const startedAt = Date.now();
|
const startedAt = Date.now();
|
||||||
while (Date.now() - startedAt < 10 * 60 * 1000) {
|
while (Date.now() - startedAt < 10 * 60 * 1000) {
|
||||||
throwIfAborted(signal);
|
throwIfAborted(signal);
|
||||||
if (window.isDestroyed()) {
|
if (window.isDestroyed()) {
|
||||||
throw new Error("AllDebrid Web-Login abgebrochen");
|
throw new Error("AllDebrid Web-Login abgebrochen");
|
||||||
}
|
}
|
||||||
|
|
||||||
const outcome = await this.generate(link, signal);
|
const outcome = await this.generate(link, signal);
|
||||||
if (outcome.kind === "success") {
|
if (outcome.kind === "success") {
|
||||||
if (!window.isDestroyed()) {
|
if (!window.isDestroyed()) {
|
||||||
window.close();
|
window.close();
|
||||||
}
|
}
|
||||||
return outcome.value;
|
return outcome.value;
|
||||||
}
|
}
|
||||||
|
|
||||||
await sleepWithSignal(1_500, signal);
|
await sleepWithSignal(1_500, signal);
|
||||||
}
|
}
|
||||||
|
|
||||||
throw new Error("AllDebrid Web-Login Timeout");
|
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 fs from "node:fs";
|
||||||
import { logTimestamp } from "./log-timestamp";
|
import { logTimestamp } from "./log-timestamp";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
|
|
||||||
type AuditLevel = "INFO" | "WARN" | "ERROR";
|
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_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);
|
const AUDIT_LOG_RETENTION_DAYS = Number(process.env.RD_AUDIT_LOG_RETENTION_DAYS || 30);
|
||||||
|
|
||||||
let auditLogPath: string | null = null;
|
let auditLogPath: string | null = null;
|
||||||
|
|
||||||
function sanitizeFieldValue(value: unknown): string {
|
function sanitizeFieldValue(value: unknown): string {
|
||||||
if (value === undefined || value === null) {
|
if (value === undefined || value === null) {
|
||||||
return "";
|
return "";
|
||||||
}
|
}
|
||||||
if (typeof value === "string") {
|
if (typeof value === "string") {
|
||||||
return value.replace(/\r?\n/g, "\\n");
|
return value.replace(/\r?\n/g, "\\n");
|
||||||
}
|
}
|
||||||
if (typeof value === "number" || typeof value === "boolean") {
|
if (typeof value === "number" || typeof value === "boolean") {
|
||||||
return String(value);
|
return String(value);
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
return JSON.stringify(value).replace(/\r?\n/g, "\\n");
|
return JSON.stringify(value).replace(/\r?\n/g, "\\n");
|
||||||
} catch {
|
} catch {
|
||||||
return String(value);
|
return String(value);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatFields(fields?: Record<string, unknown>): string {
|
function formatFields(fields?: Record<string, unknown>): string {
|
||||||
if (!fields) {
|
if (!fields) {
|
||||||
return "";
|
return "";
|
||||||
}
|
}
|
||||||
const parts = Object.entries(fields)
|
const parts = Object.entries(fields)
|
||||||
.filter(([, value]) => value !== undefined && value !== null && sanitizeFieldValue(value) !== "")
|
.filter(([, value]) => value !== undefined && value !== null && sanitizeFieldValue(value) !== "")
|
||||||
.map(([key, value]) => `${key}=${sanitizeFieldValue(value)}`);
|
.map(([key, value]) => `${key}=${sanitizeFieldValue(value)}`);
|
||||||
return parts.length > 0 ? ` | ${parts.join(" | ")}` : "";
|
return parts.length > 0 ? ` | ${parts.join(" | ")}` : "";
|
||||||
}
|
}
|
||||||
|
|
||||||
function rotateIfNeeded(filePath: string): void {
|
function rotateIfNeeded(filePath: string): void {
|
||||||
try {
|
try {
|
||||||
const stat = fs.statSync(filePath);
|
const stat = fs.statSync(filePath);
|
||||||
if (stat.size < AUDIT_LOG_MAX_FILE_BYTES) {
|
if (stat.size < AUDIT_LOG_MAX_FILE_BYTES) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const backup = `${filePath}.old`;
|
const backup = `${filePath}.old`;
|
||||||
try {
|
try {
|
||||||
fs.rmSync(backup, { force: true });
|
fs.rmSync(backup, { force: true });
|
||||||
} catch {
|
} catch {
|
||||||
}
|
}
|
||||||
fs.renameSync(filePath, backup);
|
fs.renameSync(filePath, backup);
|
||||||
} catch {
|
} catch {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function cleanupOldBackup(filePath: string): void {
|
function cleanupOldBackup(filePath: string): void {
|
||||||
const backup = `${filePath}.old`;
|
const backup = `${filePath}.old`;
|
||||||
try {
|
try {
|
||||||
const stat = fs.statSync(backup);
|
const stat = fs.statSync(backup);
|
||||||
const cutoff = Date.now() - AUDIT_LOG_RETENTION_DAYS * 24 * 60 * 60 * 1000;
|
const cutoff = Date.now() - AUDIT_LOG_RETENTION_DAYS * 24 * 60 * 60 * 1000;
|
||||||
if (stat.mtimeMs < cutoff) {
|
if (stat.mtimeMs < cutoff) {
|
||||||
fs.rmSync(backup, { force: true });
|
fs.rmSync(backup, { force: true });
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function initAuditLog(baseDir: string): void {
|
export function initAuditLog(baseDir: string): void {
|
||||||
auditLogPath = path.join(baseDir, "audit.log");
|
auditLogPath = path.join(baseDir, "audit.log");
|
||||||
try {
|
try {
|
||||||
fs.mkdirSync(path.dirname(auditLogPath), { recursive: true });
|
fs.mkdirSync(path.dirname(auditLogPath), { recursive: true });
|
||||||
cleanupOldBackup(auditLogPath);
|
cleanupOldBackup(auditLogPath);
|
||||||
if (!fs.existsSync(auditLogPath)) {
|
if (!fs.existsSync(auditLogPath)) {
|
||||||
fs.writeFileSync(auditLogPath, "", "utf8");
|
fs.writeFileSync(auditLogPath, "", "utf8");
|
||||||
}
|
}
|
||||||
rotateIfNeeded(auditLogPath);
|
rotateIfNeeded(auditLogPath);
|
||||||
if (!fs.existsSync(auditLogPath)) {
|
if (!fs.existsSync(auditLogPath)) {
|
||||||
fs.writeFileSync(auditLogPath, "", "utf8");
|
fs.writeFileSync(auditLogPath, "", "utf8");
|
||||||
}
|
}
|
||||||
fs.appendFileSync(auditLogPath, `=== Audit-Log Start: ${logTimestamp()} ===\n`, "utf8");
|
fs.appendFileSync(auditLogPath, `=== Audit-Log Start: ${logTimestamp()} ===\n`, "utf8");
|
||||||
} catch {
|
} catch {
|
||||||
auditLogPath = null;
|
auditLogPath = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function logAuditEvent(level: AuditLevel, message: string, fields?: Record<string, unknown>): void {
|
export function logAuditEvent(level: AuditLevel, message: string, fields?: Record<string, unknown>): void {
|
||||||
if (!auditLogPath) {
|
if (!auditLogPath) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
rotateIfNeeded(auditLogPath);
|
rotateIfNeeded(auditLogPath);
|
||||||
if (!fs.existsSync(auditLogPath)) {
|
if (!fs.existsSync(auditLogPath)) {
|
||||||
fs.writeFileSync(auditLogPath, "", "utf8");
|
fs.writeFileSync(auditLogPath, "", "utf8");
|
||||||
}
|
}
|
||||||
fs.appendFileSync(
|
fs.appendFileSync(
|
||||||
auditLogPath,
|
auditLogPath,
|
||||||
`${logTimestamp()} [${level}] ${message}${formatFields(fields)}\n`,
|
`${logTimestamp()} [${level}] ${message}${formatFields(fields)}\n`,
|
||||||
"utf8"
|
"utf8"
|
||||||
);
|
);
|
||||||
} catch {
|
} catch {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getAuditLogPath(): string | null {
|
export function getAuditLogPath(): string | null {
|
||||||
if (!auditLogPath) {
|
if (!auditLogPath) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
return fs.existsSync(auditLogPath) ? auditLogPath : null;
|
return fs.existsSync(auditLogPath) ? auditLogPath : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function shutdownAuditLog(): void {
|
export function shutdownAuditLog(): void {
|
||||||
if (!auditLogPath) {
|
if (!auditLogPath) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
fs.appendFileSync(auditLogPath, `=== Audit-Log Ende: ${logTimestamp()} ===\n`, "utf8");
|
fs.appendFileSync(auditLogPath, `=== Audit-Log Ende: ${logTimestamp()} ===\n`, "utf8");
|
||||||
} catch {
|
} catch {
|
||||||
}
|
}
|
||||||
auditLogPath = null;
|
auditLogPath = null;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,39 +1,39 @@
|
|||||||
import crypto from "node:crypto";
|
import crypto from "node:crypto";
|
||||||
|
|
||||||
const APP_KEY_MATERIAL = "MDD-v2-backup-aes256gcm-2026";
|
const APP_KEY_MATERIAL = "MDD-v2-backup-aes256gcm-2026";
|
||||||
const ALGORITHM = "aes-256-gcm";
|
const ALGORITHM = "aes-256-gcm";
|
||||||
const IV_LENGTH = 12;
|
const IV_LENGTH = 12;
|
||||||
const AUTH_TAG_LENGTH = 16;
|
const AUTH_TAG_LENGTH = 16;
|
||||||
const MAGIC = Buffer.from("MDD1");
|
const MAGIC = Buffer.from("MDD1");
|
||||||
|
|
||||||
function deriveKey(): Buffer {
|
function deriveKey(): Buffer {
|
||||||
return crypto.createHash("sha256").update(APP_KEY_MATERIAL).digest();
|
return crypto.createHash("sha256").update(APP_KEY_MATERIAL).digest();
|
||||||
}
|
}
|
||||||
|
|
||||||
export function encryptBackup(plaintext: string): Buffer {
|
export function encryptBackup(plaintext: string): Buffer {
|
||||||
const key = deriveKey();
|
const key = deriveKey();
|
||||||
const iv = crypto.randomBytes(IV_LENGTH);
|
const iv = crypto.randomBytes(IV_LENGTH);
|
||||||
const cipher = crypto.createCipheriv(ALGORITHM, key, iv, { authTagLength: AUTH_TAG_LENGTH });
|
const cipher = crypto.createCipheriv(ALGORITHM, key, iv, { authTagLength: AUTH_TAG_LENGTH });
|
||||||
const encrypted = Buffer.concat([cipher.update(plaintext, "utf8"), cipher.final()]);
|
const encrypted = Buffer.concat([cipher.update(plaintext, "utf8"), cipher.final()]);
|
||||||
const authTag = cipher.getAuthTag();
|
const authTag = cipher.getAuthTag();
|
||||||
return Buffer.concat([MAGIC, iv, authTag, encrypted]);
|
return Buffer.concat([MAGIC, iv, authTag, encrypted]);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function decryptBackup(data: Buffer): string {
|
export function decryptBackup(data: Buffer): string {
|
||||||
if (data.length < MAGIC.length + IV_LENGTH + AUTH_TAG_LENGTH) {
|
if (data.length < MAGIC.length + IV_LENGTH + AUTH_TAG_LENGTH) {
|
||||||
throw new Error("Backup-Datei zu kurz oder ungültig");
|
throw new Error("Backup-Datei zu kurz oder ungültig");
|
||||||
}
|
}
|
||||||
const magic = data.subarray(0, MAGIC.length);
|
const magic = data.subarray(0, MAGIC.length);
|
||||||
if (!magic.equals(MAGIC)) {
|
if (!magic.equals(MAGIC)) {
|
||||||
throw new Error("Keine gültige MDD-Backup-Datei (falsche Signatur)");
|
throw new Error("Keine gültige MDD-Backup-Datei (falsche Signatur)");
|
||||||
}
|
}
|
||||||
const iv = data.subarray(MAGIC.length, MAGIC.length + IV_LENGTH);
|
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 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 ciphertext = data.subarray(MAGIC.length + IV_LENGTH + AUTH_TAG_LENGTH);
|
||||||
|
|
||||||
const key = deriveKey();
|
const key = deriveKey();
|
||||||
const decipher = crypto.createDecipheriv(ALGORITHM, key, iv, { authTagLength: AUTH_TAG_LENGTH });
|
const decipher = crypto.createDecipheriv(ALGORITHM, key, iv, { authTagLength: AUTH_TAG_LENGTH });
|
||||||
decipher.setAuthTag(authTag);
|
decipher.setAuthTag(authTag);
|
||||||
const decrypted = Buffer.concat([decipher.update(ciphertext), decipher.final()]);
|
const decrypted = Buffer.concat([decipher.update(ciphertext), decipher.final()]);
|
||||||
return decrypted.toString("utf8");
|
return decrypted.toString("utf8");
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,115 +1,115 @@
|
|||||||
import type { AppSettings, SessionState, HistoryEntry } from "../shared/types";
|
import type { AppSettings, SessionState, HistoryEntry } from "../shared/types";
|
||||||
|
|
||||||
export type BackupKind = "full" | "settings-only";
|
export type BackupKind = "full" | "settings-only";
|
||||||
|
|
||||||
export interface BackupRemoteDiagnostics {
|
export interface BackupMcpRemote {
|
||||||
allowlist: string[];
|
allowlist: string[];
|
||||||
port: number;
|
port: number;
|
||||||
hostMode: "local" | "network";
|
hostMode: "local" | "network";
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface BackupPayload {
|
export interface BackupPayload {
|
||||||
version: 2;
|
version: 2;
|
||||||
kind: BackupKind;
|
kind: BackupKind;
|
||||||
appVersion: string;
|
appVersion: string;
|
||||||
exportedAt: string;
|
exportedAt: string;
|
||||||
settings: AppSettings;
|
settings: AppSettings;
|
||||||
session?: SessionState;
|
session?: SessionState;
|
||||||
history?: HistoryEntry[];
|
history?: HistoryEntry[];
|
||||||
remoteDiagnostics?: BackupRemoteDiagnostics;
|
mcpRemote?: BackupMcpRemote;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface BuildBackupInput {
|
export interface BuildBackupInput {
|
||||||
settings: AppSettings;
|
settings: AppSettings;
|
||||||
appVersion: string;
|
appVersion: string;
|
||||||
exportedAt: string;
|
exportedAt: string;
|
||||||
/** Only bundled when includeDownloads is true. */
|
/** Only bundled when includeDownloads is true. */
|
||||||
session: SessionState;
|
session: SessionState;
|
||||||
history: HistoryEntry[];
|
history: HistoryEntry[];
|
||||||
remoteDiagnostics?: BackupRemoteDiagnostics;
|
mcpRemote?: BackupMcpRemote;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Build the backup payload. By default ("Download-Liste mitsichern" off) the
|
* Build the backup payload. By default ("Download-Liste mitsichern" off) the
|
||||||
* payload contains ONLY settings — no session, no history. The download list is
|
* payload contains ONLY settings — no session, no history. The download list is
|
||||||
* bundled solely when settings.backupIncludeDownloads is true. An explicit kind
|
* bundled solely when settings.backupIncludeDownloads is true. An explicit kind
|
||||||
* marker makes the import side unambiguous and survives hand-edited files.
|
* marker makes the import side unambiguous and survives hand-edited files.
|
||||||
*/
|
*/
|
||||||
export function buildBackupPayload(input: BuildBackupInput): BackupPayload {
|
export function buildBackupPayload(input: BuildBackupInput): BackupPayload {
|
||||||
const includeDownloads = Boolean(input.settings.backupIncludeDownloads);
|
const includeDownloads = Boolean(input.settings.backupIncludeDownloads);
|
||||||
const base: BackupPayload = {
|
const base: BackupPayload = {
|
||||||
version: 2,
|
version: 2,
|
||||||
kind: includeDownloads ? "full" : "settings-only",
|
kind: includeDownloads ? "full" : "settings-only",
|
||||||
appVersion: input.appVersion,
|
appVersion: input.appVersion,
|
||||||
exportedAt: input.exportedAt,
|
exportedAt: input.exportedAt,
|
||||||
settings: input.settings
|
settings: input.settings
|
||||||
};
|
};
|
||||||
if (includeDownloads) {
|
if (includeDownloads) {
|
||||||
base.session = input.session;
|
base.session = input.session;
|
||||||
base.history = input.history;
|
base.history = input.history;
|
||||||
}
|
}
|
||||||
if (Boolean(input.settings.backupIncludeRemoteDiagnostics) && input.remoteDiagnostics) {
|
if (Boolean(input.settings.backupIncludeMcp) && input.mcpRemote) {
|
||||||
base.remoteDiagnostics = input.remoteDiagnostics;
|
base.mcpRemote = input.mcpRemote;
|
||||||
}
|
}
|
||||||
return base;
|
return base;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface RemoteDiagnosticsRestore {
|
export interface McpRemoteRestore {
|
||||||
host?: "127.0.0.1" | "0.0.0.0";
|
host?: "127.0.0.1" | "0.0.0.0";
|
||||||
port?: number;
|
port?: number;
|
||||||
allowlist?: string[];
|
allowlist?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export function resolveRemoteDiagnosticsRestore(section: unknown): RemoteDiagnosticsRestore | null {
|
export function resolveMcpRemoteRestore(section: unknown): McpRemoteRestore | null {
|
||||||
if (!section || typeof section !== "object") {
|
if (!section || typeof section !== "object") {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
const s = section as { allowlist?: unknown; port?: unknown; hostMode?: unknown };
|
const s = section as { allowlist?: unknown; port?: unknown; hostMode?: unknown };
|
||||||
const allowlist = Array.isArray(s.allowlist)
|
const allowlist = Array.isArray(s.allowlist)
|
||||||
? s.allowlist.filter((entry): entry is string => typeof entry === "string" && entry.trim().length > 0).map((entry) => entry.trim())
|
? s.allowlist.filter((entry): entry is string => typeof entry === "string" && entry.trim().length > 0).map((entry) => entry.trim())
|
||||||
: undefined;
|
: undefined;
|
||||||
const port = (typeof s.port === "number" && Number.isInteger(s.port) && s.port >= 1024 && s.port <= 65535) ? s.port : 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;
|
let host: "127.0.0.1" | "0.0.0.0" | undefined;
|
||||||
if (s.hostMode === "network") {
|
if (s.hostMode === "network") {
|
||||||
host = allowlist && allowlist.length > 0 ? "0.0.0.0" : "127.0.0.1";
|
host = allowlist && allowlist.length > 0 ? "0.0.0.0" : "127.0.0.1";
|
||||||
} else if (s.hostMode === "local") {
|
} else if (s.hostMode === "local") {
|
||||||
host = "127.0.0.1";
|
host = "127.0.0.1";
|
||||||
}
|
}
|
||||||
if (host === undefined && port === undefined && allowlist === undefined) {
|
if (host === undefined && port === undefined && allowlist === undefined) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
return { host, port, allowlist };
|
return { host, port, allowlist };
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ImportPlan {
|
export interface ImportPlan {
|
||||||
valid: boolean;
|
valid: boolean;
|
||||||
/** Restore the download list (session + history) and relaunch. */
|
/** Restore the download list (session + history) and relaunch. */
|
||||||
restoreDownloads: boolean;
|
restoreDownloads: boolean;
|
||||||
message: string;
|
message: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Decide how to apply an imported backup based on what the FILE physically
|
* 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
|
* 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
|
* 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
|
* 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
|
* toggle is currently off, and a settings-only backup never disturbs a running
|
||||||
* queue.
|
* queue.
|
||||||
*/
|
*/
|
||||||
export function planBackupImport(parsed: unknown): ImportPlan {
|
export function planBackupImport(parsed: unknown): ImportPlan {
|
||||||
if (!parsed || typeof parsed !== "object") {
|
if (!parsed || typeof parsed !== "object") {
|
||||||
return { valid: false, restoreDownloads: false, message: "Kein gültiges Backup (settings fehlen)" };
|
return { valid: false, restoreDownloads: false, message: "Kein gültiges Backup (settings fehlen)" };
|
||||||
}
|
}
|
||||||
const record = parsed as Record<string, unknown>;
|
const record = parsed as Record<string, unknown>;
|
||||||
if (!record.settings || typeof record.settings !== "object") {
|
if (!record.settings || typeof record.settings !== "object") {
|
||||||
return { valid: false, restoreDownloads: false, message: "Kein gültiges Backup (settings fehlen)" };
|
return { valid: false, restoreDownloads: false, message: "Kein gültiges Backup (settings fehlen)" };
|
||||||
}
|
}
|
||||||
const hasSession = Boolean(record.session) && typeof record.session === "object";
|
const hasSession = Boolean(record.session) && typeof record.session === "object";
|
||||||
return {
|
return {
|
||||||
valid: true,
|
valid: true,
|
||||||
restoreDownloads: hasSession,
|
restoreDownloads: hasSession,
|
||||||
message: hasSession
|
message: hasSession
|
||||||
? "Backup wiederhergestellt – App startet automatisch neu…"
|
? "Backup wiederhergestellt – App startet automatisch neu…"
|
||||||
: "Einstellungen wiederhergestellt"
|
: "Einstellungen wiederhergestellt"
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,346 +1,346 @@
|
|||||||
import fs from "node:fs";
|
import fs from "node:fs";
|
||||||
import { session, type Session } from "electron";
|
import { session, type Session } from "electron";
|
||||||
import { UnrestrictedLink } from "./realdebrid";
|
import { UnrestrictedLink } from "./realdebrid";
|
||||||
import { filenameFromUrl, sleep } from "./utils";
|
import { filenameFromUrl, sleep } from "./utils";
|
||||||
import { logger } from "./logger";
|
import { logger } from "./logger";
|
||||||
|
|
||||||
const BESTDEBRID_BASE_URL = "https://bestdebrid.com";
|
const BESTDEBRID_BASE_URL = "https://bestdebrid.com";
|
||||||
const BESTDEBRID_DOWNLOADER_URL = `${BESTDEBRID_BASE_URL}/en/downloader/`;
|
const BESTDEBRID_DOWNLOADER_URL = `${BESTDEBRID_BASE_URL}/en/downloader/`;
|
||||||
const BESTDEBRID_GENERATE_URL = `${BESTDEBRID_BASE_URL}/api/v1/generateLink`;
|
const BESTDEBRID_GENERATE_URL = `${BESTDEBRID_BASE_URL}/api/v1/generateLink`;
|
||||||
const BESTDEBRID_PERSISTENT_PARTITION = "persist:bestdebrid-web";
|
const BESTDEBRID_PERSISTENT_PARTITION = "persist:bestdebrid-web";
|
||||||
const BESTDEBRID_TRANSIENT_PARTITION = "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";
|
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 {
|
function abortError(): Error {
|
||||||
return new Error("aborted:bestdebrid-web");
|
return new Error("aborted:bestdebrid-web");
|
||||||
}
|
}
|
||||||
|
|
||||||
function withTimeoutSignal(signal: AbortSignal | undefined, timeoutMs: number): AbortSignal {
|
function withTimeoutSignal(signal: AbortSignal | undefined, timeoutMs: number): AbortSignal {
|
||||||
const timeoutSignal = AbortSignal.timeout(timeoutMs);
|
const timeoutSignal = AbortSignal.timeout(timeoutMs);
|
||||||
if (!signal) {
|
if (!signal) {
|
||||||
return timeoutSignal;
|
return timeoutSignal;
|
||||||
}
|
}
|
||||||
return AbortSignal.any([signal, timeoutSignal]);
|
return AbortSignal.any([signal, timeoutSignal]);
|
||||||
}
|
}
|
||||||
|
|
||||||
function throwIfAborted(signal?: AbortSignal): void {
|
function throwIfAborted(signal?: AbortSignal): void {
|
||||||
if (signal?.aborted) {
|
if (signal?.aborted) {
|
||||||
throw abortError();
|
throw abortError();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseJson(text: string): Record<string, unknown> | null {
|
function parseJson(text: string): Record<string, unknown> | null {
|
||||||
try {
|
try {
|
||||||
const parsed = JSON.parse(text) as unknown;
|
const parsed = JSON.parse(text) as unknown;
|
||||||
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
return parsed as Record<string, unknown>;
|
return parsed as Record<string, unknown>;
|
||||||
} catch {
|
} catch {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
interface NetscapeCookie {
|
interface NetscapeCookie {
|
||||||
domain: string;
|
domain: string;
|
||||||
includeSubdomains: boolean;
|
includeSubdomains: boolean;
|
||||||
httpOnly: boolean;
|
httpOnly: boolean;
|
||||||
path: string;
|
path: string;
|
||||||
secure: boolean;
|
secure: boolean;
|
||||||
expirationDate: number;
|
expirationDate: number;
|
||||||
name: string;
|
name: string;
|
||||||
value: string;
|
value: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeCookieDomain(domain: string): string {
|
function normalizeCookieDomain(domain: string): string {
|
||||||
return String(domain || "").trim().replace(/^\./, "").toLowerCase();
|
return String(domain || "").trim().replace(/^\./, "").toLowerCase();
|
||||||
}
|
}
|
||||||
|
|
||||||
function dedupeCookies(cookies: NetscapeCookie[]): NetscapeCookie[] {
|
function dedupeCookies(cookies: NetscapeCookie[]): NetscapeCookie[] {
|
||||||
const deduped = new Map<string, NetscapeCookie>();
|
const deduped = new Map<string, NetscapeCookie>();
|
||||||
for (const cookie of cookies) {
|
for (const cookie of cookies) {
|
||||||
const key = `${normalizeCookieDomain(cookie.domain)}\t${cookie.path}\t${cookie.name}`;
|
const key = `${normalizeCookieDomain(cookie.domain)}\t${cookie.path}\t${cookie.name}`;
|
||||||
const existing = deduped.get(key);
|
const existing = deduped.get(key);
|
||||||
if (!existing) {
|
if (!existing) {
|
||||||
deduped.set(key, cookie);
|
deduped.set(key, cookie);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (cookie.httpOnly && !existing.httpOnly) {
|
if (cookie.httpOnly && !existing.httpOnly) {
|
||||||
deduped.set(key, cookie);
|
deduped.set(key, cookie);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (cookie.expirationDate > existing.expirationDate) {
|
if (cookie.expirationDate > existing.expirationDate) {
|
||||||
deduped.set(key, cookie);
|
deduped.set(key, cookie);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return [...deduped.values()];
|
return [...deduped.values()];
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseNetscapeCookieFile(text: string): NetscapeCookie[] {
|
function parseNetscapeCookieFile(text: string): NetscapeCookie[] {
|
||||||
const cookies: NetscapeCookie[] = [];
|
const cookies: NetscapeCookie[] = [];
|
||||||
for (const line of text.split(/\r?\n/)) {
|
for (const line of text.split(/\r?\n/)) {
|
||||||
const trimmed = line.trim();
|
const trimmed = line.trim();
|
||||||
if (!trimmed) {
|
if (!trimmed) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
let normalizedLine = trimmed;
|
let normalizedLine = trimmed;
|
||||||
let httpOnly = false;
|
let httpOnly = false;
|
||||||
if (normalizedLine.startsWith("#HttpOnly_")) {
|
if (normalizedLine.startsWith("#HttpOnly_")) {
|
||||||
httpOnly = true;
|
httpOnly = true;
|
||||||
normalizedLine = normalizedLine.slice("#HttpOnly_".length);
|
normalizedLine = normalizedLine.slice("#HttpOnly_".length);
|
||||||
} else if (normalizedLine.startsWith("#")) {
|
} else if (normalizedLine.startsWith("#")) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const parts = normalizedLine.split("\t");
|
const parts = normalizedLine.split("\t");
|
||||||
if (parts.length < 7) {
|
if (parts.length < 7) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
cookies.push({
|
cookies.push({
|
||||||
domain: parts[0],
|
domain: parts[0],
|
||||||
includeSubdomains: parts[1].toUpperCase() === "TRUE",
|
includeSubdomains: parts[1].toUpperCase() === "TRUE",
|
||||||
httpOnly,
|
httpOnly,
|
||||||
path: parts[2],
|
path: parts[2],
|
||||||
secure: parts[3].toUpperCase() === "TRUE",
|
secure: parts[3].toUpperCase() === "TRUE",
|
||||||
expirationDate: Number(parts[4]) || 0,
|
expirationDate: Number(parts[4]) || 0,
|
||||||
name: parts[5],
|
name: parts[5],
|
||||||
value: parts[6]
|
value: parts[6]
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
return cookies;
|
return cookies;
|
||||||
}
|
}
|
||||||
|
|
||||||
function isLikelyBestDebridAuthCookie(name: string): boolean {
|
function isLikelyBestDebridAuthCookie(name: string): boolean {
|
||||||
const normalized = String(name || "").trim();
|
const normalized = String(name || "").trim();
|
||||||
return /phpsessid|sess(?:ion)?|auth|login/i.test(normalized);
|
return /phpsessid|sess(?:ion)?|auth|login/i.test(normalized);
|
||||||
}
|
}
|
||||||
|
|
||||||
function isAuthenticatedBestDebridHtml(html: string): boolean {
|
function isAuthenticatedBestDebridHtml(html: string): boolean {
|
||||||
const normalized = String(html || "");
|
const normalized = String(html || "");
|
||||||
if (!normalized) {
|
if (!normalized) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
return /href\s*=\s*["']logout["']/i.test(normalized)
|
return /href\s*=\s*["']logout["']/i.test(normalized)
|
||||||
|| /title\s*=\s*["'][^"']*premium until/i.test(normalized)
|
|| /title\s*=\s*["'][^"']*premium until/i.test(normalized)
|
||||||
|| (/user-profile-image/i.test(normalized) && !/>\s*guest\s*</i.test(normalized));
|
|| (/user-profile-image/i.test(normalized) && !/>\s*guest\s*</i.test(normalized));
|
||||||
}
|
}
|
||||||
|
|
||||||
function looksLikeGuestAccessMessage(message: string): boolean {
|
function looksLikeGuestAccessMessage(message: string): boolean {
|
||||||
return /free users are not allowed|purchase a premium plan|premium required/i.test(String(message || ""));
|
return /free users are not allowed|purchase a premium plan|premium required/i.test(String(message || ""));
|
||||||
}
|
}
|
||||||
|
|
||||||
export class BestDebridWebFallback {
|
export class BestDebridWebFallback {
|
||||||
private queue: Promise<unknown> = Promise.resolve();
|
private queue: Promise<unknown> = Promise.resolve();
|
||||||
|
|
||||||
private cookiesImported = false;
|
private cookiesImported = false;
|
||||||
|
|
||||||
private getRememberSession: () => boolean;
|
private getRememberSession: () => boolean;
|
||||||
|
|
||||||
public constructor(getRememberSession: () => boolean) {
|
public constructor(getRememberSession: () => boolean) {
|
||||||
this.getRememberSession = getRememberSession;
|
this.getRememberSession = getRememberSession;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async unrestrict(link: string, signal?: AbortSignal): Promise<UnrestrictedLink | null> {
|
public async unrestrict(link: string, signal?: AbortSignal): Promise<UnrestrictedLink | null> {
|
||||||
const overallSignal = withTimeoutSignal(signal, 60_000);
|
const overallSignal = withTimeoutSignal(signal, 60_000);
|
||||||
return this.runExclusive(async () => {
|
return this.runExclusive(async () => {
|
||||||
throwIfAborted(overallSignal);
|
throwIfAborted(overallSignal);
|
||||||
if (!String(link || "").trim()) {
|
if (!String(link || "").trim()) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!this.cookiesImported) {
|
if (!this.cookiesImported) {
|
||||||
throw new Error("BestDebrid: Keine Cookies importiert. Bitte zuerst über Einstellungen eine Cookie-Datei importieren.");
|
throw new Error("BestDebrid: Keine Cookies importiert. Bitte zuerst über Einstellungen eine Cookie-Datei importieren.");
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = await this.generate(link, overallSignal);
|
const result = await this.generate(link, overallSignal);
|
||||||
if (result.kind === "success") {
|
if (result.kind === "success") {
|
||||||
return result.value;
|
return result.value;
|
||||||
}
|
}
|
||||||
this.cookiesImported = false;
|
this.cookiesImported = false;
|
||||||
throw new Error("BestDebrid: Nicht eingeloggt. Bitte neue Cookie-Datei importieren.");
|
throw new Error("BestDebrid: Nicht eingeloggt. Bitte neue Cookie-Datei importieren.");
|
||||||
}, overallSignal);
|
}, overallSignal);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async importCookiesFromFile(filePath: string): Promise<number> {
|
public async importCookiesFromFile(filePath: string): Promise<number> {
|
||||||
const text = fs.readFileSync(filePath, "utf-8");
|
const text = fs.readFileSync(filePath, "utf-8");
|
||||||
const cookies = parseNetscapeCookieFile(text);
|
const cookies = parseNetscapeCookieFile(text);
|
||||||
const bestDebridCookies = dedupeCookies(cookies.filter((c) =>
|
const bestDebridCookies = dedupeCookies(cookies.filter((c) =>
|
||||||
c.domain.includes("bestdebrid.com")
|
c.domain.includes("bestdebrid.com")
|
||||||
));
|
));
|
||||||
|
|
||||||
if (bestDebridCookies.length === 0) {
|
if (bestDebridCookies.length === 0) {
|
||||||
throw new Error("Keine BestDebrid-Cookies in der Datei gefunden");
|
throw new Error("Keine BestDebrid-Cookies in der Datei gefunden");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!bestDebridCookies.some((cookie) => isLikelyBestDebridAuthCookie(cookie.name))) {
|
if (!bestDebridCookies.some((cookie) => isLikelyBestDebridAuthCookie(cookie.name))) {
|
||||||
throw new Error("BestDebrid: Cookie-Datei enthält keinen Login-Cookie. Bitte nach dem Login erneut exportieren.");
|
throw new Error("BestDebrid: Cookie-Datei enthält keinen Login-Cookie. Bitte nach dem Login erneut exportieren.");
|
||||||
}
|
}
|
||||||
|
|
||||||
const currentSession = session.fromPartition(this.getPartition());
|
const currentSession = session.fromPartition(this.getPartition());
|
||||||
await this.clearPartitionState(currentSession);
|
await this.clearPartitionState(currentSession);
|
||||||
|
|
||||||
for (const cookie of bestDebridCookies) {
|
for (const cookie of bestDebridCookies) {
|
||||||
const url = `https://${cookie.domain.replace(/^\./, "")}${cookie.path}`;
|
const url = `https://${cookie.domain.replace(/^\./, "")}${cookie.path}`;
|
||||||
const details: Parameters<typeof currentSession.cookies.set>[0] = {
|
const details: Parameters<typeof currentSession.cookies.set>[0] = {
|
||||||
url,
|
url,
|
||||||
name: cookie.name,
|
name: cookie.name,
|
||||||
value: cookie.value,
|
value: cookie.value,
|
||||||
path: cookie.path,
|
path: cookie.path,
|
||||||
secure: cookie.secure,
|
secure: cookie.secure,
|
||||||
httpOnly: cookie.httpOnly,
|
httpOnly: cookie.httpOnly,
|
||||||
expirationDate: cookie.expirationDate > 0 ? cookie.expirationDate : undefined
|
expirationDate: cookie.expirationDate > 0 ? cookie.expirationDate : undefined
|
||||||
};
|
};
|
||||||
if (cookie.includeSubdomains || cookie.domain.startsWith(".")) {
|
if (cookie.includeSubdomains || cookie.domain.startsWith(".")) {
|
||||||
details.domain = cookie.domain;
|
details.domain = cookie.domain;
|
||||||
}
|
}
|
||||||
await currentSession.cookies.set(details);
|
await currentSession.cookies.set(details);
|
||||||
}
|
}
|
||||||
|
|
||||||
this.cookiesImported = true;
|
this.cookiesImported = true;
|
||||||
logger.info(`BestDebrid: ${bestDebridCookies.length} Cookies importiert aus ${filePath}`);
|
logger.info(`BestDebrid: ${bestDebridCookies.length} Cookies importiert aus ${filePath}`);
|
||||||
return bestDebridCookies.length;
|
return bestDebridCookies.length;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async clearSessions(): Promise<void> {
|
public async clearSessions(): Promise<void> {
|
||||||
this.cookiesImported = false;
|
this.cookiesImported = false;
|
||||||
for (const partition of [BESTDEBRID_PERSISTENT_PARTITION, BESTDEBRID_TRANSIENT_PARTITION]) {
|
for (const partition of [BESTDEBRID_PERSISTENT_PARTITION, BESTDEBRID_TRANSIENT_PARTITION]) {
|
||||||
const currentSession = session.fromPartition(partition);
|
const currentSession = session.fromPartition(partition);
|
||||||
try {
|
try {
|
||||||
await currentSession.clearStorageData({
|
await currentSession.clearStorageData({
|
||||||
storages: ["cookies", "indexdb", "localstorage", "serviceworkers", "cachestorage"]
|
storages: ["cookies", "indexdb", "localstorage", "serviceworkers", "cachestorage"]
|
||||||
});
|
});
|
||||||
} catch {
|
} catch {
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
await currentSession.clearCache();
|
await currentSession.clearCache();
|
||||||
} catch {
|
} catch {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public dispose(): void {
|
public dispose(): void {
|
||||||
}
|
}
|
||||||
|
|
||||||
private getPartition(): string {
|
private getPartition(): string {
|
||||||
return this.getRememberSession() ? BESTDEBRID_PERSISTENT_PARTITION : BESTDEBRID_TRANSIENT_PARTITION;
|
return this.getRememberSession() ? BESTDEBRID_PERSISTENT_PARTITION : BESTDEBRID_TRANSIENT_PARTITION;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async runExclusive<T>(job: () => Promise<T>, signal?: AbortSignal): Promise<T> {
|
private async runExclusive<T>(job: () => Promise<T>, signal?: AbortSignal): Promise<T> {
|
||||||
const queuedAt = Date.now();
|
const queuedAt = Date.now();
|
||||||
const queueWaitTimeoutMs = 90_000;
|
const queueWaitTimeoutMs = 90_000;
|
||||||
const guardedJob = async (): Promise<T> => {
|
const guardedJob = async (): Promise<T> => {
|
||||||
throwIfAborted(signal);
|
throwIfAborted(signal);
|
||||||
const waited = Date.now() - queuedAt;
|
const waited = Date.now() - queuedAt;
|
||||||
if (waited > queueWaitTimeoutMs) {
|
if (waited > queueWaitTimeoutMs) {
|
||||||
throw new Error(`BestDebrid-Web Queue-Timeout (${Math.floor(waited / 1000)}s gewartet)`);
|
throw new Error(`BestDebrid-Web Queue-Timeout (${Math.floor(waited / 1000)}s gewartet)`);
|
||||||
}
|
}
|
||||||
return job();
|
return job();
|
||||||
};
|
};
|
||||||
const run = this.queue.then(guardedJob, guardedJob);
|
const run = this.queue.then(guardedJob, guardedJob);
|
||||||
this.queue = run.then(() => undefined, () => undefined);
|
this.queue = run.then(() => undefined, () => undefined);
|
||||||
return run;
|
return run;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async generate(link: string, signal?: AbortSignal): Promise<{ kind: "success"; value: UnrestrictedLink } | { kind: "login_required" }> {
|
private async generate(link: string, signal?: AbortSignal): Promise<{ kind: "success"; value: UnrestrictedLink } | { kind: "login_required" }> {
|
||||||
throwIfAborted(signal);
|
throwIfAborted(signal);
|
||||||
const currentSession = session.fromPartition(this.getPartition());
|
const currentSession = session.fromPartition(this.getPartition());
|
||||||
const response = await currentSession.fetch(BESTDEBRID_GENERATE_URL, {
|
const response = await currentSession.fetch(BESTDEBRID_GENERATE_URL, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: {
|
headers: {
|
||||||
Accept: "application/json, text/javascript, */*; q=0.01",
|
Accept: "application/json, text/javascript, */*; q=0.01",
|
||||||
"Content-Type": "application/x-www-form-urlencoded; charset=UTF-8",
|
"Content-Type": "application/x-www-form-urlencoded; charset=UTF-8",
|
||||||
Origin: BESTDEBRID_BASE_URL,
|
Origin: BESTDEBRID_BASE_URL,
|
||||||
Referer: BESTDEBRID_DOWNLOADER_URL,
|
Referer: BESTDEBRID_DOWNLOADER_URL,
|
||||||
"User-Agent": BESTDEBRID_USER_AGENT,
|
"User-Agent": BESTDEBRID_USER_AGENT,
|
||||||
"X-Requested-With": "XMLHttpRequest"
|
"X-Requested-With": "XMLHttpRequest"
|
||||||
},
|
},
|
||||||
body: new URLSearchParams({ link, pass: "", boxlinklist: "" }).toString(),
|
body: new URLSearchParams({ link, pass: "", boxlinklist: "" }).toString(),
|
||||||
signal: withTimeoutSignal(signal, 30_000)
|
signal: withTimeoutSignal(signal, 30_000)
|
||||||
});
|
});
|
||||||
|
|
||||||
const text = await response.text();
|
const text = await response.text();
|
||||||
|
|
||||||
if (!response.ok || text.trim().startsWith("<!") || text.trim().startsWith("<html")) {
|
if (!response.ok || text.trim().startsWith("<!") || text.trim().startsWith("<html")) {
|
||||||
return { kind: "login_required" };
|
return { kind: "login_required" };
|
||||||
}
|
}
|
||||||
|
|
||||||
const payload = parseJson(text.trim());
|
const payload = parseJson(text.trim());
|
||||||
if (!payload) {
|
if (!payload) {
|
||||||
return { kind: "login_required" };
|
return { kind: "login_required" };
|
||||||
}
|
}
|
||||||
|
|
||||||
const error = Number(payload.error ?? -1);
|
const error = Number(payload.error ?? -1);
|
||||||
const message = String(payload.message || "").trim();
|
const message = String(payload.message || "").trim();
|
||||||
|
|
||||||
if (error !== 0) {
|
if (error !== 0) {
|
||||||
if (/login|log in|sign in|not logged|session|auth/i.test(message)) {
|
if (/login|log in|sign in|not logged|session|auth/i.test(message)) {
|
||||||
return { kind: "login_required" };
|
return { kind: "login_required" };
|
||||||
}
|
}
|
||||||
if (looksLikeGuestAccessMessage(message)) {
|
if (looksLikeGuestAccessMessage(message)) {
|
||||||
const authenticated = await this.isAuthenticated(currentSession, signal).catch(() => null);
|
const authenticated = await this.isAuthenticated(currentSession, signal).catch(() => null);
|
||||||
if (authenticated === false) {
|
if (authenticated === false) {
|
||||||
return { kind: "login_required" };
|
return { kind: "login_required" };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
throw new Error(`BestDebrid Web: ${message || "Unbekannter Fehler"}`);
|
throw new Error(`BestDebrid Web: ${message || "Unbekannter Fehler"}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const directUrl = String(payload.link || "").trim();
|
const directUrl = String(payload.link || "").trim();
|
||||||
if (!directUrl) {
|
if (!directUrl) {
|
||||||
throw new Error("BestDebrid Web: Antwort ohne Download-Link");
|
throw new Error("BestDebrid Web: Antwort ohne Download-Link");
|
||||||
}
|
}
|
||||||
|
|
||||||
const fileName = String(payload.filename || "").trim() || filenameFromUrl(directUrl) || filenameFromUrl(link);
|
const fileName = String(payload.filename || "").trim() || filenameFromUrl(directUrl) || filenameFromUrl(link);
|
||||||
const fileSizeRaw = String(payload.size || "").trim();
|
const fileSizeRaw = String(payload.size || "").trim();
|
||||||
let fileSize: number | null = null;
|
let fileSize: number | null = null;
|
||||||
if (fileSizeRaw) {
|
if (fileSizeRaw) {
|
||||||
const match = fileSizeRaw.match(/([\d.]+)\s*(KB|KiB|MB|MiB|GB|GiB|TB|TiB|B)/i);
|
const match = fileSizeRaw.match(/([\d.]+)\s*(KB|KiB|MB|MiB|GB|GiB|TB|TiB|B)/i);
|
||||||
if (match) {
|
if (match) {
|
||||||
const value = parseFloat(match[1]);
|
const value = parseFloat(match[1]);
|
||||||
const unit = match[2].toUpperCase().replace("IB", "B");
|
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 };
|
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));
|
fileSize = Math.floor(value * (multipliers[unit] || 1));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
kind: "success",
|
kind: "success",
|
||||||
value: {
|
value: {
|
||||||
directUrl,
|
directUrl,
|
||||||
fileName,
|
fileName,
|
||||||
fileSize,
|
fileSize,
|
||||||
retriesUsed: 0
|
retriesUsed: 0
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private async isAuthenticated(currentSession: Session, signal?: AbortSignal): Promise<boolean> {
|
private async isAuthenticated(currentSession: Session, signal?: AbortSignal): Promise<boolean> {
|
||||||
throwIfAborted(signal);
|
throwIfAborted(signal);
|
||||||
const response = await currentSession.fetch(BESTDEBRID_DOWNLOADER_URL, {
|
const response = await currentSession.fetch(BESTDEBRID_DOWNLOADER_URL, {
|
||||||
method: "GET",
|
method: "GET",
|
||||||
headers: {
|
headers: {
|
||||||
Accept: "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
|
Accept: "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
|
||||||
Referer: BESTDEBRID_BASE_URL,
|
Referer: BESTDEBRID_BASE_URL,
|
||||||
"User-Agent": BESTDEBRID_USER_AGENT
|
"User-Agent": BESTDEBRID_USER_AGENT
|
||||||
},
|
},
|
||||||
signal: withTimeoutSignal(signal, 20_000)
|
signal: withTimeoutSignal(signal, 20_000)
|
||||||
});
|
});
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
const text = await response.text();
|
const text = await response.text();
|
||||||
return isAuthenticatedBestDebridHtml(text);
|
return isAuthenticatedBestDebridHtml(text);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async clearPartitionState(currentSession: Session): Promise<void> {
|
private async clearPartitionState(currentSession: Session): Promise<void> {
|
||||||
await currentSession.clearStorageData({
|
await currentSession.clearStorageData({
|
||||||
storages: ["cookies", "indexdb", "localstorage", "serviceworkers", "cachestorage"]
|
storages: ["cookies", "indexdb", "localstorage", "serviceworkers", "cachestorage"]
|
||||||
});
|
});
|
||||||
try {
|
try {
|
||||||
await currentSession.clearCache();
|
await currentSession.clearCache();
|
||||||
} catch {
|
} catch {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,266 +1,266 @@
|
|||||||
import fs from "node:fs";
|
import fs from "node:fs";
|
||||||
import path from "node:path";
|
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";
|
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> {
|
async function yieldToLoop(): Promise<void> {
|
||||||
await new Promise<void>((resolve) => {
|
await new Promise<void>((resolve) => {
|
||||||
setTimeout(resolve, 0);
|
setTimeout(resolve, 0);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isArchiveOrTempFile(filePath: string): boolean {
|
export function isArchiveOrTempFile(filePath: string): boolean {
|
||||||
const lowerName = path.basename(filePath).toLowerCase();
|
const lowerName = path.basename(filePath).toLowerCase();
|
||||||
const ext = path.extname(lowerName);
|
const ext = path.extname(lowerName);
|
||||||
if (ARCHIVE_TEMP_EXTENSIONS.has(ext)) {
|
if (ARCHIVE_TEMP_EXTENSIONS.has(ext)) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
if (lowerName.includes(".part") && lowerName.endsWith(".rar")) {
|
if (lowerName.includes(".part") && lowerName.endsWith(".rar")) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
return RAR_SPLIT_RE.test(lowerName);
|
return RAR_SPLIT_RE.test(lowerName);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function cleanupCancelledPackageArtifacts(packageDir: string): number {
|
export function cleanupCancelledPackageArtifacts(packageDir: string): number {
|
||||||
if (!fs.existsSync(packageDir)) {
|
if (!fs.existsSync(packageDir)) {
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
let removed = 0;
|
let removed = 0;
|
||||||
const stack = [packageDir];
|
const stack = [packageDir];
|
||||||
while (stack.length > 0) {
|
while (stack.length > 0) {
|
||||||
const current = stack.pop() as string;
|
const current = stack.pop() as string;
|
||||||
let entries: fs.Dirent[] = [];
|
let entries: fs.Dirent[] = [];
|
||||||
try { entries = fs.readdirSync(current, { withFileTypes: true }); } catch { continue; }
|
try { entries = fs.readdirSync(current, { withFileTypes: true }); } catch { continue; }
|
||||||
for (const entry of entries) {
|
for (const entry of entries) {
|
||||||
const full = path.join(current, entry.name);
|
const full = path.join(current, entry.name);
|
||||||
if (entry.isDirectory() && !entry.isSymbolicLink()) {
|
if (entry.isDirectory() && !entry.isSymbolicLink()) {
|
||||||
stack.push(full);
|
stack.push(full);
|
||||||
} else if (entry.isFile() && isArchiveOrTempFile(full)) {
|
} else if (entry.isFile() && isArchiveOrTempFile(full)) {
|
||||||
try {
|
try {
|
||||||
fs.rmSync(full, { force: true });
|
fs.rmSync(full, { force: true });
|
||||||
removed += 1;
|
removed += 1;
|
||||||
} catch {
|
} catch {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return removed;
|
return removed;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function cleanupCancelledPackageArtifactsAsync(
|
export async function cleanupCancelledPackageArtifactsAsync(
|
||||||
packageDir: string,
|
packageDir: string,
|
||||||
options: { shouldAbort?: () => boolean } = {}
|
options: { shouldAbort?: () => boolean } = {}
|
||||||
): Promise<number> {
|
): Promise<number> {
|
||||||
try {
|
try {
|
||||||
await fs.promises.access(packageDir, fs.constants.F_OK);
|
await fs.promises.access(packageDir, fs.constants.F_OK);
|
||||||
} catch {
|
} catch {
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
let removed = 0;
|
let removed = 0;
|
||||||
let touched = 0;
|
let touched = 0;
|
||||||
const stack = [packageDir];
|
const stack = [packageDir];
|
||||||
while (stack.length > 0) {
|
while (stack.length > 0) {
|
||||||
if (options.shouldAbort?.()) {
|
if (options.shouldAbort?.()) {
|
||||||
return removed;
|
return removed;
|
||||||
}
|
}
|
||||||
const current = stack.pop() as string;
|
const current = stack.pop() as string;
|
||||||
let entries: fs.Dirent[] = [];
|
let entries: fs.Dirent[] = [];
|
||||||
try {
|
try {
|
||||||
entries = await fs.promises.readdir(current, { withFileTypes: true });
|
entries = await fs.promises.readdir(current, { withFileTypes: true });
|
||||||
} catch {
|
} catch {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const entry of entries) {
|
for (const entry of entries) {
|
||||||
if (options.shouldAbort?.()) {
|
if (options.shouldAbort?.()) {
|
||||||
return removed;
|
return removed;
|
||||||
}
|
}
|
||||||
const full = path.join(current, entry.name);
|
const full = path.join(current, entry.name);
|
||||||
if (entry.isDirectory() && !entry.isSymbolicLink()) {
|
if (entry.isDirectory() && !entry.isSymbolicLink()) {
|
||||||
stack.push(full);
|
stack.push(full);
|
||||||
} else if (entry.isFile() && isArchiveOrTempFile(full)) {
|
} else if (entry.isFile() && isArchiveOrTempFile(full)) {
|
||||||
try {
|
try {
|
||||||
await fs.promises.rm(full, { force: true });
|
await fs.promises.rm(full, { force: true });
|
||||||
removed += 1;
|
removed += 1;
|
||||||
} catch {
|
} catch {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
touched += 1;
|
touched += 1;
|
||||||
if (touched % 80 === 0) {
|
if (touched % 80 === 0) {
|
||||||
await yieldToLoop();
|
await yieldToLoop();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return removed;
|
return removed;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function removeDownloadLinkArtifacts(
|
export async function removeDownloadLinkArtifacts(
|
||||||
extractDir: string,
|
extractDir: string,
|
||||||
options: { shouldAbort?: () => boolean } = {}
|
options: { shouldAbort?: () => boolean } = {}
|
||||||
): Promise<number> {
|
): Promise<number> {
|
||||||
try {
|
try {
|
||||||
await fs.promises.access(extractDir);
|
await fs.promises.access(extractDir);
|
||||||
} catch {
|
} catch {
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
let removed = 0;
|
let removed = 0;
|
||||||
const stack = [extractDir];
|
const stack = [extractDir];
|
||||||
while (stack.length > 0) {
|
while (stack.length > 0) {
|
||||||
if (options.shouldAbort?.()) {
|
if (options.shouldAbort?.()) {
|
||||||
return removed;
|
return removed;
|
||||||
}
|
}
|
||||||
const current = stack.pop() as string;
|
const current = stack.pop() as string;
|
||||||
let entries: fs.Dirent[] = [];
|
let entries: fs.Dirent[] = [];
|
||||||
try { entries = await fs.promises.readdir(current, { withFileTypes: true }); } catch { continue; }
|
try { entries = await fs.promises.readdir(current, { withFileTypes: true }); } catch { continue; }
|
||||||
for (const entry of entries) {
|
for (const entry of entries) {
|
||||||
if (options.shouldAbort?.()) {
|
if (options.shouldAbort?.()) {
|
||||||
return removed;
|
return removed;
|
||||||
}
|
}
|
||||||
const full = path.join(current, entry.name);
|
const full = path.join(current, entry.name);
|
||||||
if (entry.isDirectory() && !entry.isSymbolicLink()) {
|
if (entry.isDirectory() && !entry.isSymbolicLink()) {
|
||||||
stack.push(full);
|
stack.push(full);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (!entry.isFile()) {
|
if (!entry.isFile()) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
const ext = path.extname(entry.name).toLowerCase();
|
const ext = path.extname(entry.name).toLowerCase();
|
||||||
const name = entry.name.toLowerCase();
|
const name = entry.name.toLowerCase();
|
||||||
let shouldDelete = LINK_ARTIFACT_EXTENSIONS.has(ext);
|
let shouldDelete = LINK_ARTIFACT_EXTENSIONS.has(ext);
|
||||||
if (!shouldDelete && [".txt", ".html", ".htm", ".nfo"].includes(ext)) {
|
if (!shouldDelete && [".txt", ".html", ".htm", ".nfo"].includes(ext)) {
|
||||||
if (/[._\- ](links?|downloads?|urls?|dlc)([._\- ]|$)/i.test(name)) {
|
if (/[._\- ](links?|downloads?|urls?|dlc)([._\- ]|$)/i.test(name)) {
|
||||||
try {
|
try {
|
||||||
const stat = await fs.promises.stat(full);
|
const stat = await fs.promises.stat(full);
|
||||||
if (stat.size <= MAX_LINK_ARTIFACT_BYTES) {
|
if (stat.size <= MAX_LINK_ARTIFACT_BYTES) {
|
||||||
const text = await fs.promises.readFile(full, "utf8");
|
const text = await fs.promises.readFile(full, "utf8");
|
||||||
shouldDelete = /https?:\/\//i.test(text);
|
shouldDelete = /https?:\/\//i.test(text);
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
shouldDelete = false;
|
shouldDelete = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (shouldDelete) {
|
if (shouldDelete) {
|
||||||
try {
|
try {
|
||||||
await fs.promises.rm(full, { force: true });
|
await fs.promises.rm(full, { force: true });
|
||||||
removed += 1;
|
removed += 1;
|
||||||
} catch {
|
} catch {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return removed;
|
return removed;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function removeSampleArtifacts(
|
export async function removeSampleArtifacts(
|
||||||
extractDir: string,
|
extractDir: string,
|
||||||
options: { shouldAbort?: () => boolean } = {}
|
options: { shouldAbort?: () => boolean } = {}
|
||||||
): Promise<{ files: number; dirs: number }> {
|
): Promise<{ files: number; dirs: number }> {
|
||||||
try {
|
try {
|
||||||
await fs.promises.access(extractDir);
|
await fs.promises.access(extractDir);
|
||||||
} catch {
|
} catch {
|
||||||
return { files: 0, dirs: 0 };
|
return { files: 0, dirs: 0 };
|
||||||
}
|
}
|
||||||
|
|
||||||
let removedFiles = 0;
|
let removedFiles = 0;
|
||||||
let removedDirs = 0;
|
let removedDirs = 0;
|
||||||
const sampleDirs: string[] = [];
|
const sampleDirs: string[] = [];
|
||||||
const stack = [extractDir];
|
const stack = [extractDir];
|
||||||
|
|
||||||
const countFilesRecursive = async (rootDir: string): Promise<number> => {
|
const countFilesRecursive = async (rootDir: string): Promise<number> => {
|
||||||
let count = 0;
|
let count = 0;
|
||||||
const dirs = [rootDir];
|
const dirs = [rootDir];
|
||||||
while (dirs.length > 0) {
|
while (dirs.length > 0) {
|
||||||
const current = dirs.pop() as string;
|
const current = dirs.pop() as string;
|
||||||
let entries: fs.Dirent[] = [];
|
let entries: fs.Dirent[] = [];
|
||||||
try {
|
try {
|
||||||
entries = await fs.promises.readdir(current, { withFileTypes: true });
|
entries = await fs.promises.readdir(current, { withFileTypes: true });
|
||||||
} catch {
|
} catch {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
for (const entry of entries) {
|
for (const entry of entries) {
|
||||||
const full = path.join(current, entry.name);
|
const full = path.join(current, entry.name);
|
||||||
if (entry.isDirectory()) {
|
if (entry.isDirectory()) {
|
||||||
try {
|
try {
|
||||||
const stat = await fs.promises.lstat(full);
|
const stat = await fs.promises.lstat(full);
|
||||||
if (stat.isSymbolicLink()) {
|
if (stat.isSymbolicLink()) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
dirs.push(full);
|
dirs.push(full);
|
||||||
} else if (entry.isFile()) {
|
} else if (entry.isFile()) {
|
||||||
count += 1;
|
count += 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return count;
|
return count;
|
||||||
};
|
};
|
||||||
|
|
||||||
while (stack.length > 0) {
|
while (stack.length > 0) {
|
||||||
if (options.shouldAbort?.()) {
|
if (options.shouldAbort?.()) {
|
||||||
return { files: removedFiles, dirs: removedDirs };
|
return { files: removedFiles, dirs: removedDirs };
|
||||||
}
|
}
|
||||||
const current = stack.pop() as string;
|
const current = stack.pop() as string;
|
||||||
let entries: fs.Dirent[] = [];
|
let entries: fs.Dirent[] = [];
|
||||||
try { entries = await fs.promises.readdir(current, { withFileTypes: true }); } catch { continue; }
|
try { entries = await fs.promises.readdir(current, { withFileTypes: true }); } catch { continue; }
|
||||||
for (const entry of entries) {
|
for (const entry of entries) {
|
||||||
if (options.shouldAbort?.()) {
|
if (options.shouldAbort?.()) {
|
||||||
return { files: removedFiles, dirs: removedDirs };
|
return { files: removedFiles, dirs: removedDirs };
|
||||||
}
|
}
|
||||||
const full = path.join(current, entry.name);
|
const full = path.join(current, entry.name);
|
||||||
if (entry.isDirectory() || entry.isSymbolicLink()) {
|
if (entry.isDirectory() || entry.isSymbolicLink()) {
|
||||||
const base = entry.name.toLowerCase();
|
const base = entry.name.toLowerCase();
|
||||||
if (SAMPLE_DIR_NAMES.has(base)) {
|
if (SAMPLE_DIR_NAMES.has(base)) {
|
||||||
sampleDirs.push(full);
|
sampleDirs.push(full);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (entry.isDirectory()) {
|
if (entry.isDirectory()) {
|
||||||
stack.push(full);
|
stack.push(full);
|
||||||
}
|
}
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (!entry.isFile()) {
|
if (!entry.isFile()) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
const stem = path.parse(entry.name).name.toLowerCase();
|
const stem = path.parse(entry.name).name.toLowerCase();
|
||||||
const ext = path.extname(entry.name).toLowerCase();
|
const ext = path.extname(entry.name).toLowerCase();
|
||||||
const isSampleVideo = SAMPLE_VIDEO_EXTENSIONS.has(ext) && SAMPLE_TOKEN_RE.test(stem);
|
const isSampleVideo = SAMPLE_VIDEO_EXTENSIONS.has(ext) && SAMPLE_TOKEN_RE.test(stem);
|
||||||
|
|
||||||
if (isSampleVideo) {
|
if (isSampleVideo) {
|
||||||
try {
|
try {
|
||||||
await fs.promises.rm(full, { force: true });
|
await fs.promises.rm(full, { force: true });
|
||||||
removedFiles += 1;
|
removedFiles += 1;
|
||||||
} catch {
|
} catch {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
sampleDirs.sort((a, b) => b.length - a.length);
|
sampleDirs.sort((a, b) => b.length - a.length);
|
||||||
for (const dir of sampleDirs) {
|
for (const dir of sampleDirs) {
|
||||||
if (options.shouldAbort?.()) {
|
if (options.shouldAbort?.()) {
|
||||||
return { files: removedFiles, dirs: removedDirs };
|
return { files: removedFiles, dirs: removedDirs };
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const stat = await fs.promises.lstat(dir);
|
const stat = await fs.promises.lstat(dir);
|
||||||
if (stat.isSymbolicLink()) {
|
if (stat.isSymbolicLink()) {
|
||||||
await fs.promises.rm(dir, { force: true });
|
await fs.promises.rm(dir, { force: true });
|
||||||
removedDirs += 1;
|
removedDirs += 1;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const filesInDir = await countFilesRecursive(dir);
|
const filesInDir = await countFilesRecursive(dir);
|
||||||
await fs.promises.rm(dir, { recursive: true, force: true });
|
await fs.promises.rm(dir, { recursive: true, force: true });
|
||||||
removedFiles += filesInDir;
|
removedFiles += filesInDir;
|
||||||
removedDirs += 1;
|
removedDirs += 1;
|
||||||
} catch {
|
} catch {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return { files: removedFiles, dirs: removedDirs };
|
return { files: removedFiles, dirs: removedDirs };
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,59 +1,59 @@
|
|||||||
import fs from "node:fs";
|
import fs from "node:fs";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
|
|
||||||
const PREFIX = "rddiag:v1:";
|
const PREFIX = "rddiag:v1:";
|
||||||
|
|
||||||
function base64urlEncode(value: string): string {
|
function base64urlEncode(value: string): string {
|
||||||
return Buffer.from(value, "utf8")
|
return Buffer.from(value, "utf8")
|
||||||
.toString("base64")
|
.toString("base64")
|
||||||
.replace(/\+/g, "-")
|
.replace(/\+/g, "-")
|
||||||
.replace(/\//g, "_")
|
.replace(/\//g, "_")
|
||||||
.replace(/=+$/, "");
|
.replace(/=+$/, "");
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ConnectionCodeInput {
|
export interface ConnectionCodeInput {
|
||||||
host: string;
|
host: string;
|
||||||
port: number;
|
port: number;
|
||||||
token: string;
|
token: string;
|
||||||
name?: string;
|
name?: string;
|
||||||
scheme?: "http" | "https";
|
scheme?: "http" | "https";
|
||||||
fingerprint?: string;
|
fingerprint?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function encodeConnectionCode(input: ConnectionCodeInput): string {
|
export function encodeConnectionCode(input: ConnectionCodeInput): string {
|
||||||
const host = String(input.host || "").trim();
|
const host = String(input.host || "").trim();
|
||||||
if (!host) throw new Error("Host fehlt fuer Verbindungscode");
|
if (!host) throw new Error("Host fehlt fuer Verbindungscode");
|
||||||
const port = Number(input.port);
|
const port = Number(input.port);
|
||||||
if (!Number.isInteger(port) || port < 1 || port > 65535) throw new Error("Port ungueltig fuer Verbindungscode");
|
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");
|
if (!input.token) throw new Error("Token fehlt fuer Verbindungscode");
|
||||||
const payload: Record<string, unknown> = { v: 1, h: host, p: port, t: input.token };
|
const payload: Record<string, unknown> = { v: 1, h: host, p: port, t: input.token };
|
||||||
if (input.name) payload.n = String(input.name);
|
if (input.name) payload.n = String(input.name);
|
||||||
if (input.fingerprint) payload.fp = String(input.fingerprint);
|
if (input.fingerprint) payload.fp = String(input.fingerprint);
|
||||||
if (input.scheme && input.scheme !== "http") payload.s = String(input.scheme);
|
if (input.scheme && input.scheme !== "http") payload.s = String(input.scheme);
|
||||||
return PREFIX + base64urlEncode(JSON.stringify(payload));
|
return PREFIX + base64urlEncode(JSON.stringify(payload));
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface RemoteMeta {
|
export interface RemoteMeta {
|
||||||
publicHost: string;
|
publicHost: string;
|
||||||
name: string;
|
name: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
function remoteMetaPath(baseDir: string): string {
|
function remoteMetaPath(baseDir: string): string {
|
||||||
return path.join(baseDir, "debug_remote.json");
|
return path.join(baseDir, "debug_remote.json");
|
||||||
}
|
}
|
||||||
|
|
||||||
export function loadRemoteMeta(baseDir: string): RemoteMeta {
|
export function loadRemoteMeta(baseDir: string): RemoteMeta {
|
||||||
try {
|
try {
|
||||||
const parsed = JSON.parse(fs.readFileSync(remoteMetaPath(baseDir), "utf8"));
|
const parsed = JSON.parse(fs.readFileSync(remoteMetaPath(baseDir), "utf8"));
|
||||||
return {
|
return {
|
||||||
publicHost: String(parsed.publicHost || ""),
|
publicHost: String(parsed.publicHost || ""),
|
||||||
name: String(parsed.name || "")
|
name: String(parsed.name || "")
|
||||||
};
|
};
|
||||||
} catch {
|
} catch {
|
||||||
return { publicHost: "", name: "" };
|
return { publicHost: "", name: "" };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function saveRemoteMeta(baseDir: string, meta: RemoteMeta): void {
|
export function saveRemoteMeta(baseDir: string, meta: RemoteMeta): void {
|
||||||
fs.writeFileSync(remoteMetaPath(baseDir), JSON.stringify({ publicHost: meta.publicHost, name: meta.name }, null, 2), "utf8");
|
fs.writeFileSync(remoteMetaPath(baseDir), JSON.stringify({ publicHost: meta.publicHost, name: meta.name }, null, 2), "utf8");
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,141 +1,141 @@
|
|||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import os from "node:os";
|
import os from "node:os";
|
||||||
import { AppSettings } from "../shared/types";
|
import { AppSettings } from "../shared/types";
|
||||||
import { getProviderUsageDayKey } from "../shared/provider-daily-limits";
|
import { getProviderUsageDayKey } from "../shared/provider-daily-limits";
|
||||||
import packageJson from "../../package.json";
|
import packageJson from "../../package.json";
|
||||||
|
|
||||||
export const APP_NAME = "Multi Debrid Downloader";
|
export const APP_NAME = "Multi Debrid Downloader";
|
||||||
export const APP_VERSION: string = packageJson.version;
|
export const APP_VERSION: string = packageJson.version;
|
||||||
export const API_BASE_URL = "https://api.real-debrid.com/rest/1.0";
|
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_UPLOAD_URL = "https://dcrypt.it/decrypt/upload";
|
||||||
export const DCRYPT_PASTE_URL = "https://dcrypt.it/decrypt/paste";
|
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_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_KEY = Buffer.from("cb99b5cbc24db398", "utf8");
|
||||||
export const DLC_AES_IV = Buffer.from("9bc24cb995cb8db3", "utf8");
|
export const DLC_AES_IV = Buffer.from("9bc24cb995cb8db3", "utf8");
|
||||||
|
|
||||||
export const REQUEST_RETRIES = 3;
|
export const REQUEST_RETRIES = 3;
|
||||||
export const CHUNK_SIZE = 512 * 1024;
|
export const CHUNK_SIZE = 512 * 1024;
|
||||||
|
|
||||||
export const WRITE_BUFFER_SIZE = 512 * 1024;
|
export const WRITE_BUFFER_SIZE = 512 * 1024;
|
||||||
export const WRITE_FLUSH_TIMEOUT_MS = 2000;
|
export const WRITE_FLUSH_TIMEOUT_MS = 2000;
|
||||||
export const ALLOCATION_UNIT_SIZE = 4096;
|
export const ALLOCATION_UNIT_SIZE = 4096;
|
||||||
export const STREAM_HIGH_WATER_MARK = 512 * 1024;
|
export const STREAM_HIGH_WATER_MARK = 512 * 1024;
|
||||||
export const DISK_BUSY_THRESHOLD_MS = 300;
|
export const DISK_BUSY_THRESHOLD_MS = 300;
|
||||||
export const DISK_BUSY_STATUS_THRESHOLD_MS = 500;
|
export const DISK_BUSY_STATUS_THRESHOLD_MS = 500;
|
||||||
|
|
||||||
export const SAMPLE_DIR_NAMES = new Set(["sample", "samples"]);
|
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 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 LINK_ARTIFACT_EXTENSIONS = new Set([".url", ".webloc", ".dlc", ".rsdf", ".ccf"]);
|
||||||
export const SAMPLE_TOKEN_RE = /(^|[._\-\s])sample([._\-\s]|$)/i;
|
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 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 RAR_SPLIT_RE = /\.r\d{2,3}$/i;
|
||||||
|
|
||||||
export const MAX_MANIFEST_FILE_BYTES = 5 * 1024 * 1024;
|
export const MAX_MANIFEST_FILE_BYTES = 5 * 1024 * 1024;
|
||||||
export const MAX_LINK_ARTIFACT_BYTES = 256 * 1024;
|
export const MAX_LINK_ARTIFACT_BYTES = 256 * 1024;
|
||||||
export const SPEED_WINDOW_SECONDS = 1;
|
export const SPEED_WINDOW_SECONDS = 1;
|
||||||
export const CLIPBOARD_POLL_INTERVAL_MS = 2000;
|
export const CLIPBOARD_POLL_INTERVAL_MS = 2000;
|
||||||
|
|
||||||
export const DEFAULT_UPDATE_REPO = "Sucukdeluxe/multi-debrid-downloader";
|
export const DEFAULT_UPDATE_REPO = "Administrator/real-debrid-downloader";
|
||||||
|
|
||||||
export function defaultSettings(): AppSettings {
|
export function defaultSettings(): AppSettings {
|
||||||
const baseDir = path.join(os.homedir(), "Downloads", "RealDebrid");
|
const baseDir = path.join(os.homedir(), "Downloads", "RealDebrid");
|
||||||
return {
|
return {
|
||||||
token: "",
|
token: "",
|
||||||
realDebridUseWebLogin: false,
|
realDebridUseWebLogin: false,
|
||||||
megaLogin: "",
|
megaLogin: "",
|
||||||
megaPassword: "",
|
megaPassword: "",
|
||||||
megaCredentials: "",
|
megaCredentials: "",
|
||||||
megaDebridApiEnabled: false,
|
megaDebridApiEnabled: false,
|
||||||
megaDebridWebEnabled: false,
|
megaDebridWebEnabled: false,
|
||||||
megaDebridPreferApi: true,
|
megaDebridPreferApi: true,
|
||||||
bestToken: "",
|
bestToken: "",
|
||||||
bestDebridUseWebLogin: false,
|
bestDebridUseWebLogin: false,
|
||||||
allDebridToken: "",
|
allDebridToken: "",
|
||||||
allDebridUseWebLogin: false,
|
allDebridUseWebLogin: false,
|
||||||
ddownloadLogin: "",
|
ddownloadLogin: "",
|
||||||
ddownloadPassword: "",
|
ddownloadPassword: "",
|
||||||
oneFichierApiKey: "",
|
oneFichierApiKey: "",
|
||||||
debridLinkApiKeys: "",
|
debridLinkApiKeys: "",
|
||||||
debridLinkDisabledKeyIds: [],
|
debridLinkDisabledKeyIds: [],
|
||||||
linkSnappyLogin: "",
|
linkSnappyLogin: "",
|
||||||
linkSnappyPassword: "",
|
linkSnappyPassword: "",
|
||||||
archivePasswordList: "",
|
archivePasswordList: "",
|
||||||
rememberToken: true,
|
rememberToken: true,
|
||||||
providerOrder: ["realdebrid", "megadebrid-api", "bestdebrid"],
|
providerOrder: ["realdebrid", "megadebrid-api", "bestdebrid"],
|
||||||
providerPrimary: "realdebrid",
|
providerPrimary: "realdebrid",
|
||||||
providerSecondary: "megadebrid-api",
|
providerSecondary: "megadebrid-api",
|
||||||
providerTertiary: "bestdebrid",
|
providerTertiary: "bestdebrid",
|
||||||
autoProviderFallback: true,
|
autoProviderFallback: true,
|
||||||
outputDir: baseDir,
|
outputDir: baseDir,
|
||||||
packageName: "",
|
packageName: "",
|
||||||
autoExtract: true,
|
autoExtract: true,
|
||||||
autoRename4sf4sj: false,
|
autoRename4sf4sj: false,
|
||||||
keepGermanAudioOnly: false,
|
keepGermanAudioOnly: false,
|
||||||
germanAudioMode: "tag",
|
germanAudioMode: "tag",
|
||||||
extractDir: path.join(baseDir, "_entpackt"),
|
extractDir: path.join(baseDir, "_entpackt"),
|
||||||
collectMkvToLibrary: false,
|
collectMkvToLibrary: false,
|
||||||
mkvLibraryDir: path.join(baseDir, "_mkv"),
|
mkvLibraryDir: path.join(baseDir, "_mkv"),
|
||||||
createExtractSubfolder: true,
|
createExtractSubfolder: true,
|
||||||
hybridExtract: true,
|
hybridExtract: true,
|
||||||
cleanupMode: "none",
|
cleanupMode: "none",
|
||||||
extractConflictMode: "overwrite",
|
extractConflictMode: "overwrite",
|
||||||
removeLinkFilesAfterExtract: false,
|
removeLinkFilesAfterExtract: false,
|
||||||
removeSamplesAfterExtract: false,
|
removeSamplesAfterExtract: false,
|
||||||
enableIntegrityCheck: true,
|
enableIntegrityCheck: true,
|
||||||
autoResumeOnStart: true,
|
autoResumeOnStart: true,
|
||||||
autoReconnect: false,
|
autoReconnect: false,
|
||||||
reconnectWaitSeconds: 45,
|
reconnectWaitSeconds: 45,
|
||||||
completedCleanupPolicy: "never",
|
completedCleanupPolicy: "never",
|
||||||
maxParallel: 4,
|
maxParallel: 4,
|
||||||
maxParallelExtract: 2,
|
maxParallelExtract: 2,
|
||||||
retryLimit: 0,
|
retryLimit: 0,
|
||||||
speedLimitEnabled: false,
|
speedLimitEnabled: false,
|
||||||
speedLimitKbps: 0,
|
speedLimitKbps: 0,
|
||||||
speedLimitMode: "global",
|
speedLimitMode: "global",
|
||||||
updateRepo: DEFAULT_UPDATE_REPO,
|
updateRepo: DEFAULT_UPDATE_REPO,
|
||||||
autoUpdateCheck: true,
|
autoUpdateCheck: true,
|
||||||
clipboardWatch: false,
|
clipboardWatch: false,
|
||||||
minimizeToTray: false,
|
minimizeToTray: false,
|
||||||
theme: "dark" as const,
|
theme: "dark" as const,
|
||||||
collapseNewPackages: true,
|
collapseNewPackages: true,
|
||||||
historyRetentionMode: "permanent",
|
historyRetentionMode: "permanent",
|
||||||
historyMaxEntries: 500,
|
historyMaxEntries: 500,
|
||||||
historyMaxAgeDays: 0,
|
historyMaxAgeDays: 0,
|
||||||
accountListShowDetailedDebridLinkKeys: false,
|
accountListShowDetailedDebridLinkKeys: false,
|
||||||
autoSortPackagesByProgress: true,
|
autoSortPackagesByProgress: true,
|
||||||
autoSkipExtracted: false,
|
autoSkipExtracted: false,
|
||||||
hideExtractedItems: true,
|
hideExtractedItems: true,
|
||||||
confirmDeleteSelection: true,
|
confirmDeleteSelection: true,
|
||||||
backupIncludeDownloads: false,
|
backupIncludeDownloads: false,
|
||||||
backupIncludeRemoteDiagnostics: false,
|
backupIncludeMcp: false,
|
||||||
notifyUrl: "",
|
notifyUrl: "",
|
||||||
notifyMention: "",
|
notifyMention: "",
|
||||||
notifyOnPackageCompleted: false,
|
notifyOnPackageCompleted: false,
|
||||||
notifyOnPackageFailed: false,
|
notifyOnPackageFailed: false,
|
||||||
notifyOnRunFinished: false,
|
notifyOnRunFinished: false,
|
||||||
totalDownloadedAllTime: 0,
|
totalDownloadedAllTime: 0,
|
||||||
totalCompletedFilesAllTime: 0,
|
totalCompletedFilesAllTime: 0,
|
||||||
totalRuntimeAllTimeMs: 0,
|
totalRuntimeAllTimeMs: 0,
|
||||||
bandwidthSchedules: [],
|
bandwidthSchedules: [],
|
||||||
columnOrder: ["name", "size", "progress", "hoster", "account", "prio", "status", "speed"],
|
columnOrder: ["name", "size", "progress", "hoster", "account", "prio", "status", "speed"],
|
||||||
extractCpuPriority: "high",
|
extractCpuPriority: "high",
|
||||||
autoExtractWhenStopped: true,
|
autoExtractWhenStopped: true,
|
||||||
disabledProviders: [],
|
disabledProviders: [],
|
||||||
hosterRouting: {},
|
hosterRouting: {},
|
||||||
providerDailyLimitBytes: {},
|
providerDailyLimitBytes: {},
|
||||||
providerDailyUsageBytes: {},
|
providerDailyUsageBytes: {},
|
||||||
providerTotalUsageBytes: {},
|
providerTotalUsageBytes: {},
|
||||||
debridLinkApiKeyDailyLimitBytes: {},
|
debridLinkApiKeyDailyLimitBytes: {},
|
||||||
debridLinkApiKeyDailyUsageBytes: {},
|
debridLinkApiKeyDailyUsageBytes: {},
|
||||||
debridLinkApiKeyTotalUsageBytes: {},
|
debridLinkApiKeyTotalUsageBytes: {},
|
||||||
megaDebridDisabledAccountIds: [],
|
megaDebridDisabledAccountIds: [],
|
||||||
megaDebridAccountDailyLimitBytes: {},
|
megaDebridAccountDailyLimitBytes: {},
|
||||||
megaDebridAccountDailyUsageBytes: {},
|
megaDebridAccountDailyUsageBytes: {},
|
||||||
megaDebridAccountTotalUsageBytes: {},
|
megaDebridAccountTotalUsageBytes: {},
|
||||||
debridAccountStatuses: {},
|
debridAccountStatuses: {},
|
||||||
providerDailyUsageDay: getProviderUsageDayKey(),
|
providerDailyUsageDay: getProviderUsageDayKey(),
|
||||||
scheduledStartEpochMs: 0
|
scheduledStartEpochMs: 0
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,316 +1,316 @@
|
|||||||
import fs from "node:fs";
|
import fs from "node:fs";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import crypto from "node:crypto";
|
import crypto from "node:crypto";
|
||||||
import { DCRYPT_PASTE_URL, DCRYPT_UPLOAD_URL, DLC_AES_IV, DLC_AES_KEY, DLC_SERVICE_URL } from "./constants";
|
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 { compactErrorText, inferPackageNameFromLinks, isHttpLink, sanitizeFilename, uniquePreserveOrder } from "./utils";
|
||||||
import { ParsedPackageInput } from "../shared/types";
|
import { ParsedPackageInput } from "../shared/types";
|
||||||
|
|
||||||
const MAX_DLC_FILE_BYTES = 8 * 1024 * 1024;
|
const MAX_DLC_FILE_BYTES = 8 * 1024 * 1024;
|
||||||
|
|
||||||
function isContainerSizeValidationError(error: unknown): boolean {
|
function isContainerSizeValidationError(error: unknown): boolean {
|
||||||
const text = compactErrorText(error);
|
const text = compactErrorText(error);
|
||||||
return /zu groß/i.test(text) || /DLC-Datei ungültig oder zu groß/i.test(text);
|
return /zu groß/i.test(text) || /DLC-Datei ungültig oder zu groß/i.test(text);
|
||||||
}
|
}
|
||||||
|
|
||||||
function decodeDcryptPayload(responseText: string): unknown {
|
function decodeDcryptPayload(responseText: string): unknown {
|
||||||
let text = String(responseText || "").trim();
|
let text = String(responseText || "").trim();
|
||||||
const m = text.match(/<textarea[^>]*>([\s\S]*?)<\/textarea>/i);
|
const m = text.match(/<textarea[^>]*>([\s\S]*?)<\/textarea>/i);
|
||||||
if (m) {
|
if (m) {
|
||||||
text = m[1].replace(/"/g, '"').replace(/&/g, "&").trim();
|
text = m[1].replace(/"/g, '"').replace(/&/g, "&").trim();
|
||||||
}
|
}
|
||||||
if (!text) {
|
if (!text) {
|
||||||
return "";
|
return "";
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
return JSON.parse(text);
|
return JSON.parse(text);
|
||||||
} catch {
|
} catch {
|
||||||
return text;
|
return text;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function extractUrlsRecursive(data: unknown): string[] {
|
function extractUrlsRecursive(data: unknown): string[] {
|
||||||
if (typeof data === "string") {
|
if (typeof data === "string") {
|
||||||
const found = data.match(/https?:\/\/[^\s"'<>]+/gi) ?? [];
|
const found = data.match(/https?:\/\/[^\s"'<>]+/gi) ?? [];
|
||||||
return uniquePreserveOrder(found.filter((url) => isHttpLink(url)));
|
return uniquePreserveOrder(found.filter((url) => isHttpLink(url)));
|
||||||
}
|
}
|
||||||
if (Array.isArray(data)) {
|
if (Array.isArray(data)) {
|
||||||
return uniquePreserveOrder(data.flatMap((item) => extractUrlsRecursive(item)));
|
return uniquePreserveOrder(data.flatMap((item) => extractUrlsRecursive(item)));
|
||||||
}
|
}
|
||||||
if (data && typeof data === "object") {
|
if (data && typeof data === "object") {
|
||||||
return uniquePreserveOrder(Object.values(data as Record<string, unknown>).flatMap((value) => extractUrlsRecursive(value)));
|
return uniquePreserveOrder(Object.values(data as Record<string, unknown>).flatMap((value) => extractUrlsRecursive(value)));
|
||||||
}
|
}
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
function groupLinksByName(links: string[]): ParsedPackageInput[] {
|
function groupLinksByName(links: string[]): ParsedPackageInput[] {
|
||||||
const unique = uniquePreserveOrder(links.filter((link) => isHttpLink(link)));
|
const unique = uniquePreserveOrder(links.filter((link) => isHttpLink(link)));
|
||||||
const grouped = new Map<string, string[]>();
|
const grouped = new Map<string, string[]>();
|
||||||
for (const link of unique) {
|
for (const link of unique) {
|
||||||
const name = sanitizeFilename(inferPackageNameFromLinks([link]) || "Paket");
|
const name = sanitizeFilename(inferPackageNameFromLinks([link]) || "Paket");
|
||||||
const current = grouped.get(name) ?? [];
|
const current = grouped.get(name) ?? [];
|
||||||
current.push(link);
|
current.push(link);
|
||||||
grouped.set(name, current);
|
grouped.set(name, current);
|
||||||
}
|
}
|
||||||
return Array.from(grouped.entries()).map(([name, packageLinks]) => ({ name, links: packageLinks }));
|
return Array.from(grouped.entries()).map(([name, packageLinks]) => ({ name, links: packageLinks }));
|
||||||
}
|
}
|
||||||
|
|
||||||
function extractPackagesFromPayload(payload: unknown): ParsedPackageInput[] {
|
function extractPackagesFromPayload(payload: unknown): ParsedPackageInput[] {
|
||||||
const urls = extractUrlsRecursive(payload);
|
const urls = extractUrlsRecursive(payload);
|
||||||
if (urls.length === 0) {
|
if (urls.length === 0) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
return groupLinksByName(urls);
|
return groupLinksByName(urls);
|
||||||
}
|
}
|
||||||
|
|
||||||
function decryptRcPayload(base64Rc: string): Buffer {
|
function decryptRcPayload(base64Rc: string): Buffer {
|
||||||
const rcBytes = Buffer.from(base64Rc, "base64");
|
const rcBytes = Buffer.from(base64Rc, "base64");
|
||||||
const decipher = crypto.createDecipheriv("aes-128-cbc", DLC_AES_KEY, DLC_AES_IV);
|
const decipher = crypto.createDecipheriv("aes-128-cbc", DLC_AES_KEY, DLC_AES_IV);
|
||||||
decipher.setAutoPadding(false);
|
decipher.setAutoPadding(false);
|
||||||
return Buffer.concat([decipher.update(rcBytes), decipher.final()]);
|
return Buffer.concat([decipher.update(rcBytes), decipher.final()]);
|
||||||
}
|
}
|
||||||
|
|
||||||
function readDlcFileWithLimit(filePath: string): Buffer {
|
function readDlcFileWithLimit(filePath: string): Buffer {
|
||||||
const stat = fs.statSync(filePath);
|
const stat = fs.statSync(filePath);
|
||||||
if (stat.size <= 0 || stat.size > MAX_DLC_FILE_BYTES) {
|
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)`);
|
throw new Error(`DLC-Datei ungültig oder zu groß (${Math.floor(stat.size)} B)`);
|
||||||
}
|
}
|
||||||
return fs.readFileSync(filePath);
|
return fs.readFileSync(filePath);
|
||||||
}
|
}
|
||||||
|
|
||||||
function parsePackagesFromDlcXml(xml: string): ParsedPackageInput[] {
|
function parsePackagesFromDlcXml(xml: string): ParsedPackageInput[] {
|
||||||
const packages: ParsedPackageInput[] = [];
|
const packages: ParsedPackageInput[] = [];
|
||||||
const packageRegex = /<package\s+[^>]*name="([^"]*)"[^>]*>([\s\S]*?)<\/package>/gi;
|
const packageRegex = /<package\s+[^>]*name="([^"]*)"[^>]*>([\s\S]*?)<\/package>/gi;
|
||||||
|
|
||||||
for (let m = packageRegex.exec(xml); m; m = packageRegex.exec(xml)) {
|
for (let m = packageRegex.exec(xml); m; m = packageRegex.exec(xml)) {
|
||||||
const encodedName = m[1] || "";
|
const encodedName = m[1] || "";
|
||||||
const packageBody = m[2] || "";
|
const packageBody = m[2] || "";
|
||||||
let packageName = "";
|
let packageName = "";
|
||||||
if (encodedName) {
|
if (encodedName) {
|
||||||
try {
|
try {
|
||||||
packageName = Buffer.from(encodedName, "base64").toString("utf8");
|
packageName = Buffer.from(encodedName, "base64").toString("utf8");
|
||||||
} catch {
|
} catch {
|
||||||
packageName = encodedName;
|
packageName = encodedName;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const links: string[] = [];
|
const links: string[] = [];
|
||||||
const fileNames: string[] = [];
|
const fileNames: string[] = [];
|
||||||
const fileRegex = /<file>([\s\S]*?)<\/file>/gi;
|
const fileRegex = /<file>([\s\S]*?)<\/file>/gi;
|
||||||
for (let fm = fileRegex.exec(packageBody); fm; fm = fileRegex.exec(packageBody)) {
|
for (let fm = fileRegex.exec(packageBody); fm; fm = fileRegex.exec(packageBody)) {
|
||||||
const fileBody = fm[1] || "";
|
const fileBody = fm[1] || "";
|
||||||
const urlMatch = fileBody.match(/<url>(.*?)<\/url>/i);
|
const urlMatch = fileBody.match(/<url>(.*?)<\/url>/i);
|
||||||
if (!urlMatch) {
|
if (!urlMatch) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const url = Buffer.from((urlMatch[1] || "").trim(), "base64").toString("utf8").trim();
|
const url = Buffer.from((urlMatch[1] || "").trim(), "base64").toString("utf8").trim();
|
||||||
if (!isHttpLink(url)) {
|
if (!isHttpLink(url)) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
let fileName = "";
|
let fileName = "";
|
||||||
const fnMatch = fileBody.match(/<filename>(.*?)<\/filename>/i);
|
const fnMatch = fileBody.match(/<filename>(.*?)<\/filename>/i);
|
||||||
if (fnMatch?.[1]) {
|
if (fnMatch?.[1]) {
|
||||||
try {
|
try {
|
||||||
fileName = Buffer.from(fnMatch[1].trim(), "base64").toString("utf8").trim();
|
fileName = Buffer.from(fnMatch[1].trim(), "base64").toString("utf8").trim();
|
||||||
} catch {
|
} catch {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
links.push(url);
|
links.push(url);
|
||||||
fileNames.push(sanitizeFilename(fileName));
|
fileNames.push(sanitizeFilename(fileName));
|
||||||
} catch {
|
} catch {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (links.length === 0) {
|
if (links.length === 0) {
|
||||||
const urlRegex = /<url>(.*?)<\/url>/gi;
|
const urlRegex = /<url>(.*?)<\/url>/gi;
|
||||||
for (let um = urlRegex.exec(packageBody); um; um = urlRegex.exec(packageBody)) {
|
for (let um = urlRegex.exec(packageBody); um; um = urlRegex.exec(packageBody)) {
|
||||||
try {
|
try {
|
||||||
const url = Buffer.from((um[1] || "").trim(), "base64").toString("utf8").trim();
|
const url = Buffer.from((um[1] || "").trim(), "base64").toString("utf8").trim();
|
||||||
if (isHttpLink(url)) {
|
if (isHttpLink(url)) {
|
||||||
links.push(url);
|
links.push(url);
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const uniqueLinks = uniquePreserveOrder(links);
|
const uniqueLinks = uniquePreserveOrder(links);
|
||||||
const hasFileNames = fileNames.some((fn) => fn.length > 0);
|
const hasFileNames = fileNames.some((fn) => fn.length > 0);
|
||||||
if (uniqueLinks.length > 0) {
|
if (uniqueLinks.length > 0) {
|
||||||
const pkg: ParsedPackageInput = {
|
const pkg: ParsedPackageInput = {
|
||||||
name: sanitizeFilename(packageName || inferPackageNameFromLinks(uniqueLinks) || `Paket-${packages.length + 1}`),
|
name: sanitizeFilename(packageName || inferPackageNameFromLinks(uniqueLinks) || `Paket-${packages.length + 1}`),
|
||||||
links: uniqueLinks
|
links: uniqueLinks
|
||||||
};
|
};
|
||||||
if (hasFileNames) {
|
if (hasFileNames) {
|
||||||
pkg.fileNames = fileNames;
|
pkg.fileNames = fileNames;
|
||||||
}
|
}
|
||||||
packages.push(pkg);
|
packages.push(pkg);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return packages;
|
return packages;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function decryptDlcLocal(filePath: string): Promise<ParsedPackageInput[]> {
|
async function decryptDlcLocal(filePath: string): Promise<ParsedPackageInput[]> {
|
||||||
const content = readDlcFileWithLimit(filePath).toString("ascii").trim();
|
const content = readDlcFileWithLimit(filePath).toString("ascii").trim();
|
||||||
if (content.length < 89) {
|
if (content.length < 89) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
const dlcKey = content.slice(-88);
|
const dlcKey = content.slice(-88);
|
||||||
const dlcData = content.slice(0, -88);
|
const dlcData = content.slice(0, -88);
|
||||||
|
|
||||||
const rcUrl = DLC_SERVICE_URL.replace("{KEY}", encodeURIComponent(dlcKey));
|
const rcUrl = DLC_SERVICE_URL.replace("{KEY}", encodeURIComponent(dlcKey));
|
||||||
const rcResponse = await fetch(rcUrl, { method: "GET", signal: AbortSignal.timeout(30000) });
|
const rcResponse = await fetch(rcUrl, { method: "GET", signal: AbortSignal.timeout(30000) });
|
||||||
if (!rcResponse.ok) {
|
if (!rcResponse.ok) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
const rcText = await rcResponse.text();
|
const rcText = await rcResponse.text();
|
||||||
const rcMatch = rcText.match(/<rc>(.*?)<\/rc>/i);
|
const rcMatch = rcText.match(/<rc>(.*?)<\/rc>/i);
|
||||||
if (!rcMatch) {
|
if (!rcMatch) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
const realKey = decryptRcPayload(rcMatch[1]).subarray(0, 16);
|
const realKey = decryptRcPayload(rcMatch[1]).subarray(0, 16);
|
||||||
const encrypted = Buffer.from(dlcData, "base64");
|
const encrypted = Buffer.from(dlcData, "base64");
|
||||||
const decipher = crypto.createDecipheriv("aes-128-cbc", realKey, realKey);
|
const decipher = crypto.createDecipheriv("aes-128-cbc", realKey, realKey);
|
||||||
decipher.setAutoPadding(false);
|
decipher.setAutoPadding(false);
|
||||||
let decrypted = Buffer.concat([decipher.update(encrypted), decipher.final()]);
|
let decrypted = Buffer.concat([decipher.update(encrypted), decipher.final()]);
|
||||||
|
|
||||||
if (decrypted.length === 0) {
|
if (decrypted.length === 0) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
const pad = decrypted[decrypted.length - 1];
|
const pad = decrypted[decrypted.length - 1];
|
||||||
if (pad > 0 && pad <= 16 && pad <= decrypted.length) {
|
if (pad > 0 && pad <= 16 && pad <= decrypted.length) {
|
||||||
let validPad = true;
|
let validPad = true;
|
||||||
for (let index = 1; index <= pad; index += 1) {
|
for (let index = 1; index <= pad; index += 1) {
|
||||||
if (decrypted[decrypted.length - index] !== pad) {
|
if (decrypted[decrypted.length - index] !== pad) {
|
||||||
validPad = false;
|
validPad = false;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (validPad) {
|
if (validPad) {
|
||||||
decrypted = decrypted.subarray(0, decrypted.length - pad);
|
decrypted = decrypted.subarray(0, decrypted.length - pad);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const xmlData = Buffer.from(decrypted.toString("utf8"), "base64").toString("utf8");
|
const xmlData = Buffer.from(decrypted.toString("utf8"), "base64").toString("utf8");
|
||||||
return parsePackagesFromDlcXml(xmlData);
|
return parsePackagesFromDlcXml(xmlData);
|
||||||
}
|
}
|
||||||
|
|
||||||
function extractLinksFromResponse(text: string): string[] {
|
function extractLinksFromResponse(text: string): string[] {
|
||||||
const payload = decodeDcryptPayload(text);
|
const payload = decodeDcryptPayload(text);
|
||||||
let links = extractUrlsRecursive(payload);
|
let links = extractUrlsRecursive(payload);
|
||||||
if (links.length === 0) {
|
if (links.length === 0) {
|
||||||
links = extractUrlsRecursive(text);
|
links = extractUrlsRecursive(text);
|
||||||
}
|
}
|
||||||
return uniquePreserveOrder(links.filter((l) => isHttpLink(l)));
|
return uniquePreserveOrder(links.filter((l) => isHttpLink(l)));
|
||||||
}
|
}
|
||||||
|
|
||||||
async function tryDcryptUpload(fileContent: Buffer, fileName: string): Promise<string[] | null> {
|
async function tryDcryptUpload(fileContent: Buffer, fileName: string): Promise<string[] | null> {
|
||||||
const blob = new Blob([new Uint8Array(fileContent)]);
|
const blob = new Blob([new Uint8Array(fileContent)]);
|
||||||
const form = new FormData();
|
const form = new FormData();
|
||||||
form.set("dlcfile", blob, fileName);
|
form.set("dlcfile", blob, fileName);
|
||||||
|
|
||||||
const response = await fetch(DCRYPT_UPLOAD_URL, {
|
const response = await fetch(DCRYPT_UPLOAD_URL, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: form,
|
body: form,
|
||||||
signal: AbortSignal.timeout(30000)
|
signal: AbortSignal.timeout(30000)
|
||||||
});
|
});
|
||||||
if (response.status === 413) {
|
if (response.status === 413) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
const text = await response.text();
|
const text = await response.text();
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error(compactErrorText(text));
|
throw new Error(compactErrorText(text));
|
||||||
}
|
}
|
||||||
return extractLinksFromResponse(text);
|
return extractLinksFromResponse(text);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function tryDcryptPaste(fileContent: Buffer): Promise<string[] | null> {
|
async function tryDcryptPaste(fileContent: Buffer): Promise<string[] | null> {
|
||||||
const form = new FormData();
|
const form = new FormData();
|
||||||
form.set("content", fileContent.toString("ascii"));
|
form.set("content", fileContent.toString("ascii"));
|
||||||
|
|
||||||
const response = await fetch(DCRYPT_PASTE_URL, {
|
const response = await fetch(DCRYPT_PASTE_URL, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: form,
|
body: form,
|
||||||
signal: AbortSignal.timeout(30000)
|
signal: AbortSignal.timeout(30000)
|
||||||
});
|
});
|
||||||
if (response.status === 413) {
|
if (response.status === 413) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
const text = await response.text();
|
const text = await response.text();
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error(compactErrorText(text));
|
throw new Error(compactErrorText(text));
|
||||||
}
|
}
|
||||||
return extractLinksFromResponse(text);
|
return extractLinksFromResponse(text);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function decryptDlcViaDcrypt(filePath: string): Promise<ParsedPackageInput[]> {
|
async function decryptDlcViaDcrypt(filePath: string): Promise<ParsedPackageInput[]> {
|
||||||
const fileContent = readDlcFileWithLimit(filePath);
|
const fileContent = readDlcFileWithLimit(filePath);
|
||||||
const fileName = path.basename(filePath);
|
const fileName = path.basename(filePath);
|
||||||
const packageName = sanitizeFilename(path.basename(filePath, ".dlc")) || "Paket";
|
const packageName = sanitizeFilename(path.basename(filePath, ".dlc")) || "Paket";
|
||||||
|
|
||||||
let links = await tryDcryptUpload(fileContent, fileName);
|
let links = await tryDcryptUpload(fileContent, fileName);
|
||||||
if (links === null) {
|
if (links === null) {
|
||||||
links = await tryDcryptPaste(fileContent);
|
links = await tryDcryptPaste(fileContent);
|
||||||
}
|
}
|
||||||
if (links === null) {
|
if (links === null) {
|
||||||
throw new Error("DLC-Datei zu groß für dcrypt.it");
|
throw new Error("DLC-Datei zu groß für dcrypt.it");
|
||||||
}
|
}
|
||||||
if (links.length === 0) {
|
if (links.length === 0) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
return [{ name: packageName, links }];
|
return [{ name: packageName, links }];
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function importDlcContainers(filePaths: string[]): Promise<ParsedPackageInput[]> {
|
export async function importDlcContainers(filePaths: string[]): Promise<ParsedPackageInput[]> {
|
||||||
const out: ParsedPackageInput[] = [];
|
const out: ParsedPackageInput[] = [];
|
||||||
const failures: string[] = [];
|
const failures: string[] = [];
|
||||||
let sawDlc = false;
|
let sawDlc = false;
|
||||||
for (const filePath of filePaths) {
|
for (const filePath of filePaths) {
|
||||||
if (path.extname(filePath).toLowerCase() !== ".dlc") {
|
if (path.extname(filePath).toLowerCase() !== ".dlc") {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
sawDlc = true;
|
sawDlc = true;
|
||||||
let packages: ParsedPackageInput[] = [];
|
let packages: ParsedPackageInput[] = [];
|
||||||
let fileFailed = false;
|
let fileFailed = false;
|
||||||
let fileFailureReasons: string[] = [];
|
let fileFailureReasons: string[] = [];
|
||||||
try {
|
try {
|
||||||
packages = await decryptDlcLocal(filePath);
|
packages = await decryptDlcLocal(filePath);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (isContainerSizeValidationError(error)) {
|
if (isContainerSizeValidationError(error)) {
|
||||||
failures.push(`${path.basename(filePath)}: ${compactErrorText(error)}`);
|
failures.push(`${path.basename(filePath)}: ${compactErrorText(error)}`);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
fileFailed = true;
|
fileFailed = true;
|
||||||
fileFailureReasons.push(`lokal: ${compactErrorText(error)}`);
|
fileFailureReasons.push(`lokal: ${compactErrorText(error)}`);
|
||||||
packages = [];
|
packages = [];
|
||||||
}
|
}
|
||||||
if (packages.length === 0) {
|
if (packages.length === 0) {
|
||||||
try {
|
try {
|
||||||
packages = await decryptDlcViaDcrypt(filePath);
|
packages = await decryptDlcViaDcrypt(filePath);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (isContainerSizeValidationError(error)) {
|
if (isContainerSizeValidationError(error)) {
|
||||||
failures.push(`${path.basename(filePath)}: ${compactErrorText(error)}`);
|
failures.push(`${path.basename(filePath)}: ${compactErrorText(error)}`);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
fileFailed = true;
|
fileFailed = true;
|
||||||
fileFailureReasons.push(`dcrypt: ${compactErrorText(error)}`);
|
fileFailureReasons.push(`dcrypt: ${compactErrorText(error)}`);
|
||||||
packages = [];
|
packages = [];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (packages.length === 0 && fileFailed) {
|
if (packages.length === 0 && fileFailed) {
|
||||||
failures.push(`${path.basename(filePath)}: ${fileFailureReasons.join("; ")}`);
|
failures.push(`${path.basename(filePath)}: ${fileFailureReasons.join("; ")}`);
|
||||||
}
|
}
|
||||||
out.push(...packages);
|
out.push(...packages);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (out.length === 0 && sawDlc && failures.length > 0) {
|
if (out.length === 0 && sawDlc && failures.length > 0) {
|
||||||
const details = failures.slice(0, 2).join(" | ");
|
const details = failures.slice(0, 2).join(" | ");
|
||||||
const suffix = failures.length > 2 ? ` (+${failures.length - 2} weitere)` : "";
|
const suffix = failures.length > 2 ? ` (+${failures.length - 2} weitere)` : "";
|
||||||
throw new Error(`DLC konnte nicht importiert werden: ${details}${suffix}`);
|
throw new Error(`DLC konnte nicht importiert werden: ${details}${suffix}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,190 +1,190 @@
|
|||||||
import fs from "node:fs";
|
import fs from "node:fs";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { AsyncLocalStorage } from "node:async_hooks";
|
import { AsyncLocalStorage } from "node:async_hooks";
|
||||||
import { logTimestamp } from "./log-timestamp";
|
import { logTimestamp } from "./log-timestamp";
|
||||||
|
|
||||||
export interface ConversionPhase {
|
export interface ConversionPhase {
|
||||||
atMs: number;
|
atMs: number;
|
||||||
phase: string;
|
phase: string;
|
||||||
provider?: string;
|
provider?: string;
|
||||||
account?: string;
|
account?: string;
|
||||||
tokenState?: string;
|
tokenState?: string;
|
||||||
queueWaitMs?: number;
|
queueWaitMs?: number;
|
||||||
workMs?: number;
|
workMs?: number;
|
||||||
outcome?: string;
|
outcome?: string;
|
||||||
detail?: string;
|
detail?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ConversionTrace {
|
export interface ConversionTrace {
|
||||||
startedAt: number;
|
startedAt: number;
|
||||||
itemId: string;
|
itemId: string;
|
||||||
itemName: string;
|
itemName: string;
|
||||||
link: string;
|
link: string;
|
||||||
providerOrder: string;
|
providerOrder: string;
|
||||||
notes: Record<string, string | number>;
|
notes: Record<string, string | number>;
|
||||||
phases: ConversionPhase[];
|
phases: ConversionPhase[];
|
||||||
}
|
}
|
||||||
|
|
||||||
const conversionContext = new AsyncLocalStorage<ConversionTrace>();
|
const conversionContext = new AsyncLocalStorage<ConversionTrace>();
|
||||||
|
|
||||||
function shortLink(link: string): string {
|
function shortLink(link: string): string {
|
||||||
const raw = String(link || "").trim();
|
const raw = String(link || "").trim();
|
||||||
return raw.length > 90 ? `${raw.slice(0, 90)}…` : raw;
|
return raw.length > 90 ? `${raw.slice(0, 90)}…` : raw;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function traceConversionPhase(phase: Omit<ConversionPhase, "atMs">): void {
|
export function traceConversionPhase(phase: Omit<ConversionPhase, "atMs">): void {
|
||||||
const trace = conversionContext.getStore();
|
const trace = conversionContext.getStore();
|
||||||
if (!trace) {
|
if (!trace) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
trace.phases.push({ ...phase, atMs: Date.now() - trace.startedAt });
|
trace.phases.push({ ...phase, atMs: Date.now() - trace.startedAt });
|
||||||
}
|
}
|
||||||
|
|
||||||
export function traceConversionNote(key: string, value: string | number): void {
|
export function traceConversionNote(key: string, value: string | number): void {
|
||||||
const trace = conversionContext.getStore();
|
const trace = conversionContext.getStore();
|
||||||
if (!trace) {
|
if (!trace) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
trace.notes[key] = value;
|
trace.notes[key] = value;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function hasActiveConversionTrace(): boolean {
|
export function hasActiveConversionTrace(): boolean {
|
||||||
return conversionContext.getStore() !== undefined;
|
return conversionContext.getStore() !== undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function formatConversionBlock(
|
export function formatConversionBlock(
|
||||||
trace: ConversionTrace,
|
trace: ConversionTrace,
|
||||||
outcome: string,
|
outcome: string,
|
||||||
detail: string,
|
detail: string,
|
||||||
totalMs: number
|
totalMs: number
|
||||||
): string {
|
): string {
|
||||||
const noteParts = Object.entries(trace.notes)
|
const noteParts = Object.entries(trace.notes)
|
||||||
.map(([key, value]) => `${key}=${value}`)
|
.map(([key, value]) => `${key}=${value}`)
|
||||||
.join(" ");
|
.join(" ");
|
||||||
const header = `${logTimestamp()} [CONV] item=${trace.itemName || trace.itemId} | order=${trace.providerOrder || "?"}`
|
const header = `${logTimestamp()} [CONV] item=${trace.itemName || trace.itemId} | order=${trace.providerOrder || "?"}`
|
||||||
+ ` | result=${outcome}${detail ? ` (${detail})` : ""} | total=${totalMs}ms${noteParts ? ` | ${noteParts}` : ""}`
|
+ ` | result=${outcome}${detail ? ` (${detail})` : ""} | total=${totalMs}ms${noteParts ? ` | ${noteParts}` : ""}`
|
||||||
+ ` | link=${shortLink(trace.link)}`;
|
+ ` | link=${shortLink(trace.link)}`;
|
||||||
const lines = trace.phases.map((p) => {
|
const lines = trace.phases.map((p) => {
|
||||||
const parts: string[] = [];
|
const parts: string[] = [];
|
||||||
if (p.provider) parts.push(`provider=${p.provider}`);
|
if (p.provider) parts.push(`provider=${p.provider}`);
|
||||||
if (p.account) parts.push(`account=${p.account}`);
|
if (p.account) parts.push(`account=${p.account}`);
|
||||||
if (p.tokenState) parts.push(`token=${p.tokenState}`);
|
if (p.tokenState) parts.push(`token=${p.tokenState}`);
|
||||||
if (typeof p.queueWaitMs === "number") parts.push(`queueWaitMs=${p.queueWaitMs}`);
|
if (typeof p.queueWaitMs === "number") parts.push(`queueWaitMs=${p.queueWaitMs}`);
|
||||||
if (typeof p.workMs === "number") parts.push(`workMs=${p.workMs}`);
|
if (typeof p.workMs === "number") parts.push(`workMs=${p.workMs}`);
|
||||||
if (p.outcome) parts.push(`outcome=${p.outcome}`);
|
if (p.outcome) parts.push(`outcome=${p.outcome}`);
|
||||||
if (p.detail) parts.push(`detail=${String(p.detail).replace(/\r?\n/g, "\\n")}`);
|
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 ` +${p.atMs}ms ${p.phase}${parts.length ? ` | ${parts.join(" | ")}` : ""}`;
|
||||||
});
|
});
|
||||||
return [header, ...lines].join("\n");
|
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_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);
|
const CONVERSION_LOG_RETENTION_DAYS = Number(process.env.RD_CONVERSION_LOG_RETENTION_DAYS || 14);
|
||||||
|
|
||||||
let conversionLogPath: string | null = null;
|
let conversionLogPath: string | null = null;
|
||||||
|
|
||||||
function rotateIfNeeded(filePath: string): void {
|
function rotateIfNeeded(filePath: string): void {
|
||||||
try {
|
try {
|
||||||
const stat = fs.statSync(filePath);
|
const stat = fs.statSync(filePath);
|
||||||
if (stat.size < CONVERSION_LOG_MAX_FILE_BYTES) {
|
if (stat.size < CONVERSION_LOG_MAX_FILE_BYTES) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const backup = `${filePath}.old`;
|
const backup = `${filePath}.old`;
|
||||||
try {
|
try {
|
||||||
fs.rmSync(backup, { force: true });
|
fs.rmSync(backup, { force: true });
|
||||||
} catch {
|
} catch {
|
||||||
}
|
}
|
||||||
fs.renameSync(filePath, backup);
|
fs.renameSync(filePath, backup);
|
||||||
} catch {
|
} catch {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function cleanupOldBackup(filePath: string): void {
|
function cleanupOldBackup(filePath: string): void {
|
||||||
const backup = `${filePath}.old`;
|
const backup = `${filePath}.old`;
|
||||||
try {
|
try {
|
||||||
const stat = fs.statSync(backup);
|
const stat = fs.statSync(backup);
|
||||||
const cutoff = Date.now() - CONVERSION_LOG_RETENTION_DAYS * 24 * 60 * 60 * 1000;
|
const cutoff = Date.now() - CONVERSION_LOG_RETENTION_DAYS * 24 * 60 * 60 * 1000;
|
||||||
if (stat.mtimeMs < cutoff) {
|
if (stat.mtimeMs < cutoff) {
|
||||||
fs.rmSync(backup, { force: true });
|
fs.rmSync(backup, { force: true });
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function initConversionLog(baseDir: string): void {
|
export function initConversionLog(baseDir: string): void {
|
||||||
conversionLogPath = path.join(baseDir, "conversion.log");
|
conversionLogPath = path.join(baseDir, "conversion.log");
|
||||||
try {
|
try {
|
||||||
fs.mkdirSync(path.dirname(conversionLogPath), { recursive: true });
|
fs.mkdirSync(path.dirname(conversionLogPath), { recursive: true });
|
||||||
cleanupOldBackup(conversionLogPath);
|
cleanupOldBackup(conversionLogPath);
|
||||||
if (!fs.existsSync(conversionLogPath)) {
|
if (!fs.existsSync(conversionLogPath)) {
|
||||||
fs.writeFileSync(conversionLogPath, "", "utf8");
|
fs.writeFileSync(conversionLogPath, "", "utf8");
|
||||||
}
|
}
|
||||||
rotateIfNeeded(conversionLogPath);
|
rotateIfNeeded(conversionLogPath);
|
||||||
if (!fs.existsSync(conversionLogPath)) {
|
if (!fs.existsSync(conversionLogPath)) {
|
||||||
fs.writeFileSync(conversionLogPath, "", "utf8");
|
fs.writeFileSync(conversionLogPath, "", "utf8");
|
||||||
}
|
}
|
||||||
fs.appendFileSync(conversionLogPath, `=== Conversion Log Start: ${logTimestamp()} ===\n`, "utf8");
|
fs.appendFileSync(conversionLogPath, `=== Conversion Log Start: ${logTimestamp()} ===\n`, "utf8");
|
||||||
} catch {
|
} catch {
|
||||||
conversionLogPath = null;
|
conversionLogPath = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getConversionLogPath(): string | null {
|
export function getConversionLogPath(): string | null {
|
||||||
if (!conversionLogPath) {
|
if (!conversionLogPath) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
return fs.existsSync(conversionLogPath) ? conversionLogPath : null;
|
return fs.existsSync(conversionLogPath) ? conversionLogPath : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function shutdownConversionLog(): void {
|
export function shutdownConversionLog(): void {
|
||||||
if (!conversionLogPath) {
|
if (!conversionLogPath) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
fs.appendFileSync(conversionLogPath, `=== Conversion Log Ende: ${logTimestamp()} ===\n`, "utf8");
|
fs.appendFileSync(conversionLogPath, `=== Conversion Log Ende: ${logTimestamp()} ===\n`, "utf8");
|
||||||
} catch {
|
} catch {
|
||||||
}
|
}
|
||||||
conversionLogPath = null;
|
conversionLogPath = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function writeConversionBlock(block: string): void {
|
function writeConversionBlock(block: string): void {
|
||||||
if (!conversionLogPath) {
|
if (!conversionLogPath) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
rotateIfNeeded(conversionLogPath);
|
rotateIfNeeded(conversionLogPath);
|
||||||
if (!fs.existsSync(conversionLogPath)) {
|
if (!fs.existsSync(conversionLogPath)) {
|
||||||
fs.writeFileSync(conversionLogPath, "", "utf8");
|
fs.writeFileSync(conversionLogPath, "", "utf8");
|
||||||
}
|
}
|
||||||
fs.appendFileSync(conversionLogPath, `${block}\n`, "utf8");
|
fs.appendFileSync(conversionLogPath, `${block}\n`, "utf8");
|
||||||
} catch {
|
} catch {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function runWithConversionTrace<T>(
|
export async function runWithConversionTrace<T>(
|
||||||
meta: { itemId: string; itemName: string; link: string; providerOrder: string },
|
meta: { itemId: string; itemName: string; link: string; providerOrder: string },
|
||||||
fn: () => Promise<T>
|
fn: () => Promise<T>
|
||||||
): Promise<T> {
|
): Promise<T> {
|
||||||
const trace: ConversionTrace = {
|
const trace: ConversionTrace = {
|
||||||
startedAt: Date.now(),
|
startedAt: Date.now(),
|
||||||
itemId: meta.itemId,
|
itemId: meta.itemId,
|
||||||
itemName: meta.itemName,
|
itemName: meta.itemName,
|
||||||
link: meta.link,
|
link: meta.link,
|
||||||
providerOrder: meta.providerOrder,
|
providerOrder: meta.providerOrder,
|
||||||
notes: {},
|
notes: {},
|
||||||
phases: []
|
phases: []
|
||||||
};
|
};
|
||||||
let outcome = "OK";
|
let outcome = "OK";
|
||||||
let detail = "";
|
let detail = "";
|
||||||
try {
|
try {
|
||||||
const result = await conversionContext.run(trace, fn);
|
const result = await conversionContext.run(trace, fn);
|
||||||
return result;
|
return result;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
outcome = "FAIL";
|
outcome = "FAIL";
|
||||||
detail = String((error as { message?: string })?.message || error || "").replace(/^Error:\s*/i, "").slice(0, 160);
|
detail = String((error as { message?: string })?.message || error || "").replace(/^Error:\s*/i, "").slice(0, 160);
|
||||||
throw error;
|
throw error;
|
||||||
} finally {
|
} finally {
|
||||||
const totalMs = Date.now() - trace.startedAt;
|
const totalMs = Date.now() - trace.startedAt;
|
||||||
writeConversionBlock(formatConversionBlock(trace, outcome, detail, totalMs));
|
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 fs from "node:fs";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { execFileSync } from "node:child_process";
|
import { execFileSync } from "node:child_process";
|
||||||
import { getSessionLogPath } from "./session-log";
|
import { getSessionLogPath } from "./session-log";
|
||||||
import { createStoragePaths, loadSettings } from "./storage";
|
import { createStoragePaths, loadSettings } from "./storage";
|
||||||
import type {
|
import type {
|
||||||
DebugSetupCheckResult,
|
DebugSetupCheckResult,
|
||||||
SupportBundleEstimate,
|
SupportBundleEstimate,
|
||||||
SupportDirectorySizeInfo,
|
SupportDirectorySizeInfo,
|
||||||
SupportDiskSpaceInfo,
|
SupportDiskSpaceInfo,
|
||||||
SupportFileSizeInfo,
|
SupportFileSizeInfo,
|
||||||
SupportTraceConfig
|
SupportTraceConfig
|
||||||
} from "../shared/types";
|
} from "../shared/types";
|
||||||
|
|
||||||
const DEFAULT_PORT = 9868;
|
const DEFAULT_PORT = 9868;
|
||||||
const DEFAULT_HOST = "127.0.0.1";
|
const DEFAULT_HOST = "127.0.0.1";
|
||||||
const SUPPORT_MANIFEST_FILE = "debug_support_manifest.json";
|
const AI_MANIFEST_FILE = "debug_ai_manifest.json";
|
||||||
const LOW_FREE_BYTES_THRESHOLD = Number(process.env.RD_SELF_CHECK_LOW_FREE_BYTES || 20 * 1024 * 1024 * 1024);
|
const LOW_FREE_BYTES_THRESHOLD = Number(process.env.RD_SELF_CHECK_LOW_FREE_BYTES || 20 * 1024 * 1024 * 1024);
|
||||||
const LOW_FREE_PERCENT_THRESHOLD = Number(process.env.RD_SELF_CHECK_LOW_FREE_PERCENT || 5);
|
const LOW_FREE_PERCENT_THRESHOLD = Number(process.env.RD_SELF_CHECK_LOW_FREE_PERCENT || 5);
|
||||||
const LOW_FREE_PERCENT_BYTES_GUARD = Number(process.env.RD_SELF_CHECK_LOW_FREE_PERCENT_BYTES_GUARD || 50 * 1024 * 1024 * 1024);
|
const LOW_FREE_PERCENT_BYTES_GUARD = Number(process.env.RD_SELF_CHECK_LOW_FREE_PERCENT_BYTES_GUARD || 50 * 1024 * 1024 * 1024);
|
||||||
const LARGE_LOG_BYTES_THRESHOLD = Number(process.env.RD_SELF_CHECK_LARGE_LOG_BYTES || 250 * 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 LARGE_BUNDLE_BYTES_THRESHOLD = Number(process.env.RD_SELF_CHECK_LARGE_BUNDLE_BYTES || 150 * 1024 * 1024);
|
||||||
const BUNDLE_OVERVIEW_SLACK_BYTES = 256 * 1024;
|
const BUNDLE_OVERVIEW_SLACK_BYTES = 256 * 1024;
|
||||||
|
|
||||||
function formatByteCount(bytes: number): string {
|
function formatByteCount(bytes: number): string {
|
||||||
if (!Number.isFinite(bytes) || bytes < 0) {
|
if (!Number.isFinite(bytes) || bytes < 0) {
|
||||||
return "0 B";
|
return "0 B";
|
||||||
}
|
}
|
||||||
if (bytes < 1024) {
|
if (bytes < 1024) {
|
||||||
return `${bytes} B`;
|
return `${bytes} B`;
|
||||||
}
|
}
|
||||||
if (bytes < 1024 * 1024) {
|
if (bytes < 1024 * 1024) {
|
||||||
return `${(bytes / 1024).toFixed(1)} KB`;
|
return `${(bytes / 1024).toFixed(1)} KB`;
|
||||||
}
|
}
|
||||||
if (bytes < 1024 * 1024 * 1024) {
|
if (bytes < 1024 * 1024 * 1024) {
|
||||||
return `${(bytes / (1024 * 1024)).toFixed(2)} MB`;
|
return `${(bytes / (1024 * 1024)).toFixed(2)} MB`;
|
||||||
}
|
}
|
||||||
return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`;
|
return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function readToken(baseDir: string): string {
|
function readToken(baseDir: string): string {
|
||||||
try {
|
try {
|
||||||
return fs.readFileSync(path.join(baseDir, "debug_token.txt"), "utf8").trim();
|
return fs.readFileSync(path.join(baseDir, "debug_token.txt"), "utf8").trim();
|
||||||
} catch {
|
} catch {
|
||||||
return "";
|
return "";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function readPort(baseDir: string): number {
|
function readPort(baseDir: string): number {
|
||||||
try {
|
try {
|
||||||
const raw = Number(fs.readFileSync(path.join(baseDir, "debug_port.txt"), "utf8").trim());
|
const raw = Number(fs.readFileSync(path.join(baseDir, "debug_port.txt"), "utf8").trim());
|
||||||
if (Number.isFinite(raw) && raw >= 1024 && raw <= 65535) {
|
if (Number.isFinite(raw) && raw >= 1024 && raw <= 65535) {
|
||||||
return raw;
|
return raw;
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
}
|
}
|
||||||
return DEFAULT_PORT;
|
return DEFAULT_PORT;
|
||||||
}
|
}
|
||||||
|
|
||||||
function readHost(baseDir: string): string {
|
function readHost(baseDir: string): string {
|
||||||
try {
|
try {
|
||||||
const raw = fs.readFileSync(path.join(baseDir, "debug_host.txt"), "utf8").trim();
|
const raw = fs.readFileSync(path.join(baseDir, "debug_host.txt"), "utf8").trim();
|
||||||
if (!raw) {
|
if (!raw) {
|
||||||
return DEFAULT_HOST;
|
return DEFAULT_HOST;
|
||||||
}
|
}
|
||||||
if (/^(localhost|0\.0\.0\.0|127\.0\.0\.1|::1)$/i.test(raw)) {
|
if (/^(localhost|0\.0\.0\.0|127\.0\.0\.1|::1)$/i.test(raw)) {
|
||||||
return raw;
|
return raw;
|
||||||
}
|
}
|
||||||
if (/^[a-z0-9.-]+$/i.test(raw)) {
|
if (/^[a-z0-9.-]+$/i.test(raw)) {
|
||||||
return raw;
|
return raw;
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
}
|
}
|
||||||
return DEFAULT_HOST;
|
return DEFAULT_HOST;
|
||||||
}
|
}
|
||||||
|
|
||||||
function readTraceConfig(baseDir: string): SupportTraceConfig {
|
function readTraceConfig(baseDir: string): SupportTraceConfig {
|
||||||
const fallback: SupportTraceConfig = {
|
const fallback: SupportTraceConfig = {
|
||||||
enabled: false,
|
enabled: false,
|
||||||
includeMainLog: true,
|
includeMainLog: true,
|
||||||
includeAudit: true,
|
includeAudit: true,
|
||||||
logDebugRequests: true,
|
logDebugRequests: true,
|
||||||
autoDisableAt: null,
|
autoDisableAt: null,
|
||||||
updatedAt: new Date(0).toISOString()
|
updatedAt: new Date(0).toISOString()
|
||||||
};
|
};
|
||||||
try {
|
try {
|
||||||
const filePath = path.join(baseDir, "trace_config.json");
|
const filePath = path.join(baseDir, "trace_config.json");
|
||||||
const parsed = JSON.parse(fs.readFileSync(filePath, "utf8")) as Partial<SupportTraceConfig>;
|
const parsed = JSON.parse(fs.readFileSync(filePath, "utf8")) as Partial<SupportTraceConfig>;
|
||||||
return {
|
return {
|
||||||
enabled: Boolean(parsed.enabled),
|
enabled: Boolean(parsed.enabled),
|
||||||
includeMainLog: parsed.includeMainLog === undefined ? true : Boolean(parsed.includeMainLog),
|
includeMainLog: parsed.includeMainLog === undefined ? true : Boolean(parsed.includeMainLog),
|
||||||
includeAudit: parsed.includeAudit === undefined ? true : Boolean(parsed.includeAudit),
|
includeAudit: parsed.includeAudit === undefined ? true : Boolean(parsed.includeAudit),
|
||||||
logDebugRequests: parsed.logDebugRequests === undefined ? true : Boolean(parsed.logDebugRequests),
|
logDebugRequests: parsed.logDebugRequests === undefined ? true : Boolean(parsed.logDebugRequests),
|
||||||
autoDisableAt: typeof parsed.autoDisableAt === "string" && parsed.autoDisableAt.trim() ? parsed.autoDisableAt : null,
|
autoDisableAt: typeof parsed.autoDisableAt === "string" && parsed.autoDisableAt.trim() ? parsed.autoDisableAt : null,
|
||||||
updatedAt: typeof parsed.updatedAt === "string" && parsed.updatedAt.trim() ? parsed.updatedAt : fallback.updatedAt
|
updatedAt: typeof parsed.updatedAt === "string" && parsed.updatedAt.trim() ? parsed.updatedAt : fallback.updatedAt
|
||||||
};
|
};
|
||||||
} catch {
|
} catch {
|
||||||
return fallback;
|
return fallback;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function getFileSizeInfo(filePath: string | null): SupportFileSizeInfo {
|
function getFileSizeInfo(filePath: string | null): SupportFileSizeInfo {
|
||||||
if (!filePath) {
|
if (!filePath) {
|
||||||
return { path: null, exists: false, bytes: 0 };
|
return { path: null, exists: false, bytes: 0 };
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const stat = fs.statSync(filePath);
|
const stat = fs.statSync(filePath);
|
||||||
return {
|
return {
|
||||||
path: filePath,
|
path: filePath,
|
||||||
exists: true,
|
exists: true,
|
||||||
bytes: stat.size
|
bytes: stat.size
|
||||||
};
|
};
|
||||||
} catch {
|
} catch {
|
||||||
return {
|
return {
|
||||||
path: filePath,
|
path: filePath,
|
||||||
exists: false,
|
exists: false,
|
||||||
bytes: 0
|
bytes: 0
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function getDirectorySizeInfo(dirPath: string, skipPath?: string | null): SupportDirectorySizeInfo {
|
function getDirectorySizeInfo(dirPath: string, skipPath?: string | null): SupportDirectorySizeInfo {
|
||||||
if (!fs.existsSync(dirPath)) {
|
if (!fs.existsSync(dirPath)) {
|
||||||
return {
|
return {
|
||||||
path: dirPath,
|
path: dirPath,
|
||||||
exists: false,
|
exists: false,
|
||||||
fileCount: 0,
|
fileCount: 0,
|
||||||
bytes: 0
|
bytes: 0
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
let bytes = 0;
|
let bytes = 0;
|
||||||
let fileCount = 0;
|
let fileCount = 0;
|
||||||
const queue = [dirPath];
|
const queue = [dirPath];
|
||||||
while (queue.length > 0) {
|
while (queue.length > 0) {
|
||||||
const current = queue.pop();
|
const current = queue.pop();
|
||||||
if (!current) {
|
if (!current) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
let entries: fs.Dirent[];
|
let entries: fs.Dirent[];
|
||||||
try {
|
try {
|
||||||
entries = fs.readdirSync(current, { withFileTypes: true });
|
entries = fs.readdirSync(current, { withFileTypes: true });
|
||||||
} catch {
|
} catch {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
for (const entry of entries) {
|
for (const entry of entries) {
|
||||||
const fullPath = path.join(current, entry.name);
|
const fullPath = path.join(current, entry.name);
|
||||||
if (entry.isDirectory()) {
|
if (entry.isDirectory()) {
|
||||||
queue.push(fullPath);
|
queue.push(fullPath);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (skipPath && path.resolve(fullPath) === path.resolve(skipPath)) {
|
if (skipPath && path.resolve(fullPath) === path.resolve(skipPath)) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
bytes += fs.statSync(fullPath).size;
|
bytes += fs.statSync(fullPath).size;
|
||||||
fileCount += 1;
|
fileCount += 1;
|
||||||
} catch {
|
} catch {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
path: dirPath,
|
path: dirPath,
|
||||||
exists: true,
|
exists: true,
|
||||||
fileCount,
|
fileCount,
|
||||||
bytes
|
bytes
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function resolveExistingPath(targetPath: string): string {
|
function resolveExistingPath(targetPath: string): string {
|
||||||
let current = path.resolve(targetPath);
|
let current = path.resolve(targetPath);
|
||||||
while (!fs.existsSync(current)) {
|
while (!fs.existsSync(current)) {
|
||||||
const parent = path.dirname(current);
|
const parent = path.dirname(current);
|
||||||
if (parent === current) {
|
if (parent === current) {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
current = parent;
|
current = parent;
|
||||||
}
|
}
|
||||||
return current;
|
return current;
|
||||||
}
|
}
|
||||||
|
|
||||||
function getWindowsDiskSpaceInfo(existingPath: string): SupportDiskSpaceInfo | null {
|
function getWindowsDiskSpaceInfo(existingPath: string): SupportDiskSpaceInfo | null {
|
||||||
if (process.platform !== "win32") {
|
if (process.platform !== "win32") {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
const root = path.parse(existingPath).root.replace(/[\\/]+$/g, "");
|
const root = path.parse(existingPath).root.replace(/[\\/]+$/g, "");
|
||||||
const driveName = root.replace(":", "");
|
const driveName = root.replace(":", "");
|
||||||
if (!/^[A-Za-z]$/.test(driveName)) {
|
if (!/^[A-Za-z]$/.test(driveName)) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const raw = execFileSync(
|
const raw = execFileSync(
|
||||||
"powershell",
|
"powershell",
|
||||||
[
|
[
|
||||||
"-NoProfile",
|
"-NoProfile",
|
||||||
"-Command",
|
"-Command",
|
||||||
`$drive = Get-PSDrive -Name '${driveName}'; if ($drive) { [pscustomobject]@{ FreeSpace = [int64]$drive.Free; Size = [int64]($drive.Used + $drive.Free) } | ConvertTo-Json -Compress }`
|
`$drive = Get-PSDrive -Name '${driveName}'; if ($drive) { [pscustomobject]@{ FreeSpace = [int64]$drive.Free; Size = [int64]($drive.Used + $drive.Free) } | ConvertTo-Json -Compress }`
|
||||||
],
|
],
|
||||||
{
|
{
|
||||||
encoding: "utf8",
|
encoding: "utf8",
|
||||||
windowsHide: true,
|
windowsHide: true,
|
||||||
stdio: ["ignore", "pipe", "ignore"],
|
stdio: ["ignore", "pipe", "ignore"],
|
||||||
timeout: 3000
|
timeout: 3000
|
||||||
}
|
}
|
||||||
).trim();
|
).trim();
|
||||||
if (!raw) {
|
if (!raw) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
const parsed = JSON.parse(raw) as { FreeSpace?: number | string; Size?: number | string };
|
const parsed = JSON.parse(raw) as { FreeSpace?: number | string; Size?: number | string };
|
||||||
const totalBytes = Number(parsed.Size);
|
const totalBytes = Number(parsed.Size);
|
||||||
const freeBytes = Number(parsed.FreeSpace);
|
const freeBytes = Number(parsed.FreeSpace);
|
||||||
const freePercent = Number.isFinite(totalBytes) && totalBytes > 0
|
const freePercent = Number.isFinite(totalBytes) && totalBytes > 0
|
||||||
? Math.round((freeBytes / totalBytes) * 1000) / 10
|
? Math.round((freeBytes / totalBytes) * 1000) / 10
|
||||||
: null;
|
: null;
|
||||||
return {
|
return {
|
||||||
path: existingPath,
|
path: existingPath,
|
||||||
totalBytes: Number.isFinite(totalBytes) ? totalBytes : null,
|
totalBytes: Number.isFinite(totalBytes) ? totalBytes : null,
|
||||||
freeBytes: Number.isFinite(freeBytes) ? freeBytes : null,
|
freeBytes: Number.isFinite(freeBytes) ? freeBytes : null,
|
||||||
freePercent
|
freePercent
|
||||||
};
|
};
|
||||||
} catch {
|
} catch {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function getDiskSpaceInfo(targetPath: string): SupportDiskSpaceInfo {
|
function getDiskSpaceInfo(targetPath: string): SupportDiskSpaceInfo {
|
||||||
const existingPath = resolveExistingPath(targetPath);
|
const existingPath = resolveExistingPath(targetPath);
|
||||||
try {
|
try {
|
||||||
const stat = fs.statfsSync(existingPath);
|
const stat = fs.statfsSync(existingPath);
|
||||||
const totalBytes = Number(stat.blocks) * Number(stat.bsize);
|
const totalBytes = Number(stat.blocks) * Number(stat.bsize);
|
||||||
const freeBytes = Number(stat.bavail) * Number(stat.bsize);
|
const freeBytes = Number(stat.bavail) * Number(stat.bsize);
|
||||||
const freePercent = totalBytes > 0
|
const freePercent = totalBytes > 0
|
||||||
? Math.round((freeBytes / totalBytes) * 1000) / 10
|
? Math.round((freeBytes / totalBytes) * 1000) / 10
|
||||||
: null;
|
: null;
|
||||||
return {
|
return {
|
||||||
path: existingPath,
|
path: existingPath,
|
||||||
totalBytes,
|
totalBytes,
|
||||||
freeBytes,
|
freeBytes,
|
||||||
freePercent
|
freePercent
|
||||||
};
|
};
|
||||||
} catch {
|
} catch {
|
||||||
const windowsFallback = getWindowsDiskSpaceInfo(existingPath);
|
const windowsFallback = getWindowsDiskSpaceInfo(existingPath);
|
||||||
if (windowsFallback) {
|
if (windowsFallback) {
|
||||||
return windowsFallback;
|
return windowsFallback;
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
path: existingPath,
|
path: existingPath,
|
||||||
totalBytes: null,
|
totalBytes: null,
|
||||||
freeBytes: null,
|
freeBytes: null,
|
||||||
freePercent: null
|
freePercent: null
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function getSupportBundleEstimate(
|
function getSupportBundleEstimate(
|
||||||
baseDir: string,
|
baseDir: string,
|
||||||
logSummary: DebugSetupCheckResult["logSummary"]
|
logSummary: DebugSetupCheckResult["logSummary"]
|
||||||
): SupportBundleEstimate {
|
): SupportBundleEstimate {
|
||||||
const storagePaths = createStoragePaths(baseDir);
|
const storagePaths = createStoragePaths(baseDir);
|
||||||
const staticFiles = [
|
const staticFiles = [
|
||||||
path.join(baseDir, SUPPORT_MANIFEST_FILE),
|
path.join(baseDir, AI_MANIFEST_FILE),
|
||||||
path.join(baseDir, "debug_host.txt"),
|
path.join(baseDir, "debug_host.txt"),
|
||||||
path.join(baseDir, "debug_port.txt"),
|
path.join(baseDir, "debug_port.txt"),
|
||||||
storagePaths.configFile,
|
storagePaths.configFile,
|
||||||
storagePaths.sessionFile,
|
storagePaths.sessionFile,
|
||||||
storagePaths.historyFile,
|
storagePaths.historyFile,
|
||||||
path.join(baseDir, "trace_config.json")
|
path.join(baseDir, "trace_config.json")
|
||||||
].map((filePath) => getFileSizeInfo(filePath));
|
].map((filePath) => getFileSizeInfo(filePath));
|
||||||
|
|
||||||
const staticBytes = staticFiles.reduce((sum, entry) => sum + entry.bytes, 0);
|
const staticBytes = staticFiles.reduce((sum, entry) => sum + entry.bytes, 0);
|
||||||
const duplicatedLiveLogBytes = logSummary.session.bytes + logSummary.packageLogs.bytes + logSummary.itemLogs.bytes;
|
const duplicatedLiveLogBytes = logSummary.session.bytes + logSummary.packageLogs.bytes + logSummary.itemLogs.bytes;
|
||||||
const estimatedEntries = 10
|
const estimatedEntries = 10
|
||||||
+ staticFiles.filter((entry) => entry.exists).length
|
+ staticFiles.filter((entry) => entry.exists).length
|
||||||
+ Number(logSummary.main.exists)
|
+ Number(logSummary.main.exists)
|
||||||
+ Number(logSummary.mainBackup.exists)
|
+ Number(logSummary.mainBackup.exists)
|
||||||
+ Number(logSummary.audit.exists)
|
+ Number(logSummary.audit.exists)
|
||||||
+ Number(logSummary.auditBackup.exists)
|
+ Number(logSummary.auditBackup.exists)
|
||||||
+ Number(logSummary.rename.exists)
|
+ Number(logSummary.rename.exists)
|
||||||
+ Number(logSummary.renameBackup.exists)
|
+ Number(logSummary.renameBackup.exists)
|
||||||
+ Number(logSummary.session.exists)
|
+ Number(logSummary.session.exists)
|
||||||
+ Number(logSummary.trace.exists)
|
+ Number(logSummary.trace.exists)
|
||||||
+ Number(logSummary.traceBackup.exists)
|
+ Number(logSummary.traceBackup.exists)
|
||||||
+ logSummary.sessionLogs.fileCount
|
+ logSummary.sessionLogs.fileCount
|
||||||
+ logSummary.packageLogs.fileCount
|
+ logSummary.packageLogs.fileCount
|
||||||
+ logSummary.itemLogs.fileCount
|
+ logSummary.itemLogs.fileCount
|
||||||
+ logSummary.packageLogs.fileCount
|
+ logSummary.packageLogs.fileCount
|
||||||
+ logSummary.itemLogs.fileCount;
|
+ logSummary.itemLogs.fileCount;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
estimatedBytes: staticBytes + logSummary.totalBytes + duplicatedLiveLogBytes + BUNDLE_OVERVIEW_SLACK_BYTES,
|
estimatedBytes: staticBytes + logSummary.totalBytes + duplicatedLiveLogBytes + BUNDLE_OVERVIEW_SLACK_BYTES,
|
||||||
estimatedEntries,
|
estimatedEntries,
|
||||||
duplicatedLiveLogBytes,
|
duplicatedLiveLogBytes,
|
||||||
note: "Schätzwert vor ZIP-Komprimierung; aktueller Session-Log sowie Live-Paket-/Item-Logs werden im Bundle zusätzlich gespiegelt."
|
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 {
|
export function getDebugSetupCheck(baseDir: string): DebugSetupCheckResult {
|
||||||
const host = readHost(baseDir);
|
const host = readHost(baseDir);
|
||||||
const port = readPort(baseDir);
|
const port = readPort(baseDir);
|
||||||
const token = readToken(baseDir);
|
const token = readToken(baseDir);
|
||||||
const storagePaths = createStoragePaths(baseDir);
|
const storagePaths = createStoragePaths(baseDir);
|
||||||
const settings = loadSettings(storagePaths);
|
const settings = loadSettings(storagePaths);
|
||||||
const tokenPath = path.join(baseDir, "debug_token.txt");
|
const tokenPath = path.join(baseDir, "debug_token.txt");
|
||||||
const supportManifestPath = path.join(baseDir, SUPPORT_MANIFEST_FILE);
|
const aiManifestPath = path.join(baseDir, AI_MANIFEST_FILE);
|
||||||
const traceConfigPath = path.join(baseDir, "trace_config.json");
|
const traceConfigPath = path.join(baseDir, "trace_config.json");
|
||||||
const traceLogPath = path.join(baseDir, "trace.log");
|
const traceLogPath = path.join(baseDir, "trace.log");
|
||||||
const traceConfig = readTraceConfig(baseDir);
|
const traceConfig = readTraceConfig(baseDir);
|
||||||
const sessionLogPath = getSessionLogPath();
|
const sessionLogPath = getSessionLogPath();
|
||||||
const localOnly = /^(127\.0\.0\.1|localhost|::1)$/i.test(host);
|
const localOnly = /^(127\.0\.0\.1|localhost|::1)$/i.test(host);
|
||||||
const warnings: string[] = [];
|
const warnings: string[] = [];
|
||||||
const notes: string[] = [];
|
const notes: string[] = [];
|
||||||
|
|
||||||
const logSummary: DebugSetupCheckResult["logSummary"] = {
|
const logSummary: DebugSetupCheckResult["logSummary"] = {
|
||||||
main: getFileSizeInfo(path.join(baseDir, "rd_downloader.log")),
|
main: getFileSizeInfo(path.join(baseDir, "rd_downloader.log")),
|
||||||
mainBackup: getFileSizeInfo(path.join(baseDir, "rd_downloader.log.old")),
|
mainBackup: getFileSizeInfo(path.join(baseDir, "rd_downloader.log.old")),
|
||||||
audit: getFileSizeInfo(path.join(baseDir, "audit.log")),
|
audit: getFileSizeInfo(path.join(baseDir, "audit.log")),
|
||||||
auditBackup: getFileSizeInfo(path.join(baseDir, "audit.log.old")),
|
auditBackup: getFileSizeInfo(path.join(baseDir, "audit.log.old")),
|
||||||
rename: getFileSizeInfo(path.join(baseDir, "rename.log")),
|
rename: getFileSizeInfo(path.join(baseDir, "rename.log")),
|
||||||
renameBackup: getFileSizeInfo(path.join(baseDir, "rename.log.old")),
|
renameBackup: getFileSizeInfo(path.join(baseDir, "rename.log.old")),
|
||||||
session: getFileSizeInfo(sessionLogPath),
|
session: getFileSizeInfo(sessionLogPath),
|
||||||
trace: getFileSizeInfo(traceLogPath),
|
trace: getFileSizeInfo(traceLogPath),
|
||||||
traceBackup: getFileSizeInfo(path.join(baseDir, "trace.log.old")),
|
traceBackup: getFileSizeInfo(path.join(baseDir, "trace.log.old")),
|
||||||
sessionLogs: getDirectorySizeInfo(path.join(baseDir, "session-logs"), sessionLogPath),
|
sessionLogs: getDirectorySizeInfo(path.join(baseDir, "session-logs"), sessionLogPath),
|
||||||
packageLogs: getDirectorySizeInfo(path.join(baseDir, "package-logs")),
|
packageLogs: getDirectorySizeInfo(path.join(baseDir, "package-logs")),
|
||||||
itemLogs: getDirectorySizeInfo(path.join(baseDir, "item-logs")),
|
itemLogs: getDirectorySizeInfo(path.join(baseDir, "item-logs")),
|
||||||
totalBytes: 0
|
totalBytes: 0
|
||||||
};
|
};
|
||||||
logSummary.totalBytes = [
|
logSummary.totalBytes = [
|
||||||
logSummary.main.bytes,
|
logSummary.main.bytes,
|
||||||
logSummary.mainBackup.bytes,
|
logSummary.mainBackup.bytes,
|
||||||
logSummary.audit.bytes,
|
logSummary.audit.bytes,
|
||||||
logSummary.auditBackup.bytes,
|
logSummary.auditBackup.bytes,
|
||||||
logSummary.rename.bytes,
|
logSummary.rename.bytes,
|
||||||
logSummary.renameBackup.bytes,
|
logSummary.renameBackup.bytes,
|
||||||
logSummary.session.bytes,
|
logSummary.session.bytes,
|
||||||
logSummary.trace.bytes,
|
logSummary.trace.bytes,
|
||||||
logSummary.traceBackup.bytes,
|
logSummary.traceBackup.bytes,
|
||||||
logSummary.sessionLogs.bytes,
|
logSummary.sessionLogs.bytes,
|
||||||
logSummary.packageLogs.bytes,
|
logSummary.packageLogs.bytes,
|
||||||
logSummary.itemLogs.bytes
|
logSummary.itemLogs.bytes
|
||||||
].reduce((sum, value) => sum + value, 0);
|
].reduce((sum, value) => sum + value, 0);
|
||||||
|
|
||||||
const diskSpace: DebugSetupCheckResult["diskSpace"] = {
|
const diskSpace: DebugSetupCheckResult["diskSpace"] = {
|
||||||
runtime: getDiskSpaceInfo(baseDir),
|
runtime: getDiskSpaceInfo(baseDir),
|
||||||
output: getDiskSpaceInfo(settings.outputDir),
|
output: getDiskSpaceInfo(settings.outputDir),
|
||||||
extract: getDiskSpaceInfo(settings.extractDir)
|
extract: getDiskSpaceInfo(settings.extractDir)
|
||||||
};
|
};
|
||||||
const supportBundle = getSupportBundleEstimate(baseDir, logSummary);
|
const supportBundle = getSupportBundleEstimate(baseDir, logSummary);
|
||||||
|
|
||||||
if (!token) {
|
if (!token) {
|
||||||
warnings.push("debug_token.txt fehlt oder ist leer. Der Debug-Server startet dann nicht.");
|
warnings.push("debug_token.txt fehlt oder ist leer. Der Debug-Server startet dann nicht.");
|
||||||
}
|
}
|
||||||
if (localOnly) {
|
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.");
|
warnings.push("Der Debug-Server ist aktuell nur lokal erreichbar. Für Remote-Support debug_host.txt auf 0.0.0.0 setzen.");
|
||||||
} else {
|
} else {
|
||||||
notes.push("Der Debug-Server ist für Remote-Zugriff konfiguriert. Firewall oder Provider-Regeln müssen separat offen sein.");
|
notes.push("Der Debug-Server ist für Remote-Zugriff konfiguriert. Firewall oder Provider-Regeln müssen separat offen sein.");
|
||||||
}
|
}
|
||||||
if (!fs.existsSync(supportManifestPath)) {
|
if (!fs.existsSync(aiManifestPath)) {
|
||||||
warnings.push("debug_support_manifest.json fehlt. App einmal neu starten, damit das Support-Manifest neu geschrieben wird.");
|
warnings.push("debug_ai_manifest.json fehlt. App einmal neu starten, damit die KI-Support-Datei neu geschrieben wird.");
|
||||||
}
|
}
|
||||||
if (!fs.existsSync(traceConfigPath)) {
|
if (!fs.existsSync(traceConfigPath)) {
|
||||||
warnings.push("trace_config.json fehlt. Trace-Funktionen sind lokal noch nicht initialisiert.");
|
warnings.push("trace_config.json fehlt. Trace-Funktionen sind lokal noch nicht initialisiert.");
|
||||||
}
|
}
|
||||||
if (traceConfig.enabled && !traceConfig.autoDisableAt) {
|
if (traceConfig.enabled && !traceConfig.autoDisableAt) {
|
||||||
warnings.push("Support-Trace ist aktiv ohne automatische Abschaltzeit. Einmal neu aktivieren, damit die 2-Stunden-Begrenzung gesetzt wird.");
|
warnings.push("Support-Trace ist aktiv ohne automatische Abschaltzeit. Einmal neu aktivieren, damit die 2-Stunden-Begrenzung gesetzt wird.");
|
||||||
}
|
}
|
||||||
if (traceConfig.enabled && traceConfig.autoDisableAt) {
|
if (traceConfig.enabled && traceConfig.autoDisableAt) {
|
||||||
notes.push(`Support-Trace aktiv bis ${traceConfig.autoDisableAt}.`);
|
notes.push(`Support-Trace aktiv bis ${traceConfig.autoDisableAt}.`);
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const entry of [
|
for (const entry of [
|
||||||
{ label: "Runtime", info: diskSpace.runtime },
|
{ label: "Runtime", info: diskSpace.runtime },
|
||||||
{ label: "Download-Ziel", info: diskSpace.output },
|
{ label: "Download-Ziel", info: diskSpace.output },
|
||||||
{ label: "Entpack-Ziel", info: diskSpace.extract }
|
{ label: "Entpack-Ziel", info: diskSpace.extract }
|
||||||
]) {
|
]) {
|
||||||
if (entry.info.freeBytes === null || entry.info.totalBytes === null) {
|
if (entry.info.freeBytes === null || entry.info.totalBytes === null) {
|
||||||
warnings.push(`${entry.label}: Freier Speicherplatz konnte nicht gelesen werden (${entry.info.path}).`);
|
warnings.push(`${entry.label}: Freier Speicherplatz konnte nicht gelesen werden (${entry.info.path}).`);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const lowByAbsolute = entry.info.freeBytes < LOW_FREE_BYTES_THRESHOLD;
|
const lowByAbsolute = entry.info.freeBytes < LOW_FREE_BYTES_THRESHOLD;
|
||||||
const lowByPercent = entry.info.freePercent !== null
|
const lowByPercent = entry.info.freePercent !== null
|
||||||
&& entry.info.freePercent < LOW_FREE_PERCENT_THRESHOLD
|
&& entry.info.freePercent < LOW_FREE_PERCENT_THRESHOLD
|
||||||
&& entry.info.freeBytes < LOW_FREE_PERCENT_BYTES_GUARD;
|
&& entry.info.freeBytes < LOW_FREE_PERCENT_BYTES_GUARD;
|
||||||
if (lowByAbsolute || lowByPercent) {
|
if (lowByAbsolute || lowByPercent) {
|
||||||
warnings.push(`${entry.label}: wenig freier Speicherplatz (${formatByteCount(entry.info.freeBytes)} frei auf ${entry.info.path}).`);
|
warnings.push(`${entry.label}: wenig freier Speicherplatz (${formatByteCount(entry.info.freeBytes)} frei auf ${entry.info.path}).`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (logSummary.totalBytes >= LARGE_LOG_BYTES_THRESHOLD) {
|
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.`);
|
warnings.push(`Support-Logs sind bereits recht groß (${formatByteCount(logSummary.totalBytes)}). Rotation greift, aber ein Bundle wird entsprechend umfangreicher.`);
|
||||||
} else {
|
} else {
|
||||||
notes.push(`Aktuelle Support-Logmenge: ${formatByteCount(logSummary.totalBytes)}.`);
|
notes.push(`Aktuelle Support-Logmenge: ${formatByteCount(logSummary.totalBytes)}.`);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (supportBundle.estimatedBytes >= LARGE_BUNDLE_BYTES_THRESHOLD) {
|
if (supportBundle.estimatedBytes >= LARGE_BUNDLE_BYTES_THRESHOLD) {
|
||||||
warnings.push(`Support-Bundle wird voraussichtlich groß (${formatByteCount(supportBundle.estimatedBytes)} vor ZIP-Komprimierung).`);
|
warnings.push(`Support-Bundle wird voraussichtlich groß (${formatByteCount(supportBundle.estimatedBytes)} vor ZIP-Komprimierung).`);
|
||||||
} else {
|
} else {
|
||||||
notes.push(`Support-Bundle-Schätzung: etwa ${formatByteCount(supportBundle.estimatedBytes)}.`);
|
notes.push(`Support-Bundle-Schätzung: etwa ${formatByteCount(supportBundle.estimatedBytes)}.`);
|
||||||
}
|
}
|
||||||
|
|
||||||
notes.push("Die App kann Netzwerk-Firewalls oder Provider-Sicherheitsgruppen nicht direkt prüfen.");
|
notes.push("Die App kann Netzwerk-Firewalls oder Provider-Sicherheitsgruppen nicht direkt prüfen.");
|
||||||
|
|
||||||
return {
|
return {
|
||||||
status: warnings.length > 0 ? "warn" : "ok",
|
status: warnings.length > 0 ? "warn" : "ok",
|
||||||
enabled: Boolean(token),
|
enabled: Boolean(token),
|
||||||
runtimeBaseDir: baseDir,
|
runtimeBaseDir: baseDir,
|
||||||
host,
|
host,
|
||||||
port,
|
port,
|
||||||
localOnly,
|
localOnly,
|
||||||
tokenConfigured: Boolean(token),
|
tokenConfigured: Boolean(token),
|
||||||
tokenPath,
|
tokenPath,
|
||||||
supportManifestPath,
|
aiManifestPath,
|
||||||
supportManifestPresent: fs.existsSync(supportManifestPath),
|
aiManifestPresent: fs.existsSync(aiManifestPath),
|
||||||
traceConfigPath: fs.existsSync(traceConfigPath) ? traceConfigPath : null,
|
traceConfigPath: fs.existsSync(traceConfigPath) ? traceConfigPath : null,
|
||||||
traceLogPath: fs.existsSync(traceLogPath) ? traceLogPath : null,
|
traceLogPath: fs.existsSync(traceLogPath) ? traceLogPath : null,
|
||||||
traceEnabled: traceConfig.enabled,
|
traceEnabled: traceConfig.enabled,
|
||||||
traceAutoDisableAt: traceConfig.autoDisableAt,
|
traceAutoDisableAt: traceConfig.autoDisableAt,
|
||||||
diskSpace,
|
diskSpace,
|
||||||
logSummary,
|
logSummary,
|
||||||
supportBundle,
|
supportBundle,
|
||||||
warnings,
|
warnings,
|
||||||
notes,
|
notes,
|
||||||
localUrls: {
|
localUrls: {
|
||||||
health: `http://127.0.0.1:${port}/health?token=${token || "<TOKEN>"}`,
|
health: `http://127.0.0.1:${port}/health?token=${token || "<TOKEN>"}`,
|
||||||
meta: `http://127.0.0.1:${port}/meta?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>"}`
|
diagnostics: `http://127.0.0.1:${port}/diagnostics?token=${token || "<TOKEN>"}`
|
||||||
},
|
},
|
||||||
remoteUrlTemplates: {
|
remoteUrlTemplates: {
|
||||||
health: `http://<SERVER_IP_OR_DNS>:${port}/health?token=${token || "<TOKEN>"}`,
|
health: `http://<SERVER_IP_OR_DNS>:${port}/health?token=${token || "<TOKEN>"}`,
|
||||||
meta: `http://<SERVER_IP_OR_DNS>:${port}/meta?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>"}`
|
diagnostics: `http://<SERVER_IP_OR_DNS>:${port}/diagnostics?token=${token || "<TOKEN>"}`
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,252 +1,252 @@
|
|||||||
import fs from "node:fs";
|
import fs from "node:fs";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { logTimestamp } from "./log-timestamp";
|
import { logTimestamp } from "./log-timestamp";
|
||||||
|
|
||||||
type DesktopRenameLevel = "INFO" | "WARN" | "ERROR";
|
type DesktopRenameLevel = "INFO" | "WARN" | "ERROR";
|
||||||
|
|
||||||
const FOLDER_NAME = "Downloader-Log";
|
const FOLDER_NAME = "Downloader-Log";
|
||||||
|
|
||||||
let logDir: string | null = null;
|
let logDir: string | null = null;
|
||||||
let logFilePath: string | null = null;
|
let logFilePath: string | null = null;
|
||||||
let sessionHeader = "";
|
let sessionHeader = "";
|
||||||
|
|
||||||
function fileTimestamp(date: Date = new Date()): string {
|
function fileTimestamp(date: Date = new Date()): string {
|
||||||
const pad = (value: number): string => String(value).padStart(2, "0");
|
const pad = (value: number): string => String(value).padStart(2, "0");
|
||||||
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}_`
|
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}_`
|
||||||
+ `${pad(date.getHours())}-${pad(date.getMinutes())}-${pad(date.getSeconds())}`;
|
+ `${pad(date.getHours())}-${pad(date.getMinutes())}-${pad(date.getSeconds())}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function sanitizeFieldValue(value: unknown): string {
|
function sanitizeFieldValue(value: unknown): string {
|
||||||
if (value === undefined || value === null) {
|
if (value === undefined || value === null) {
|
||||||
return "";
|
return "";
|
||||||
}
|
}
|
||||||
if (typeof value === "string") {
|
if (typeof value === "string") {
|
||||||
return value.replace(/\r?\n/g, "\\n");
|
return value.replace(/\r?\n/g, "\\n");
|
||||||
}
|
}
|
||||||
if (typeof value === "number" || typeof value === "boolean") {
|
if (typeof value === "number" || typeof value === "boolean") {
|
||||||
return String(value);
|
return String(value);
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
return JSON.stringify(value).replace(/\r?\n/g, "\\n");
|
return JSON.stringify(value).replace(/\r?\n/g, "\\n");
|
||||||
} catch {
|
} catch {
|
||||||
return String(value);
|
return String(value);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatFields(fields?: Record<string, unknown>): string {
|
function formatFields(fields?: Record<string, unknown>): string {
|
||||||
if (!fields) {
|
if (!fields) {
|
||||||
return "";
|
return "";
|
||||||
}
|
}
|
||||||
const parts = Object.entries(fields)
|
const parts = Object.entries(fields)
|
||||||
.filter(([, value]) => value !== undefined && value !== null && sanitizeFieldValue(value) !== "")
|
.filter(([, value]) => value !== undefined && value !== null && sanitizeFieldValue(value) !== "")
|
||||||
.map(([key, value]) => `${key}=${sanitizeFieldValue(value)}`);
|
.map(([key, value]) => `${key}=${sanitizeFieldValue(value)}`);
|
||||||
return parts.length > 0 ? ` | ${parts.join(" | ")}` : "";
|
return parts.length > 0 ? ` | ${parts.join(" | ")}` : "";
|
||||||
}
|
}
|
||||||
|
|
||||||
function ensureWritable(): boolean {
|
function ensureWritable(): boolean {
|
||||||
if (!logDir || !logFilePath) {
|
if (!logDir || !logFilePath) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
fs.mkdirSync(logDir, { recursive: true });
|
fs.mkdirSync(logDir, { recursive: true });
|
||||||
if (!fs.existsSync(logFilePath)) {
|
if (!fs.existsSync(logFilePath)) {
|
||||||
fs.writeFileSync(logFilePath, sessionHeader, "utf8");
|
fs.writeFileSync(logFilePath, sessionHeader, "utf8");
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
} catch {
|
} catch {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function initDesktopRenameLog(desktopDir: string | null | undefined): void {
|
export function initDesktopRenameLog(desktopDir: string | null | undefined): void {
|
||||||
try {
|
try {
|
||||||
const base = String(desktopDir || "").trim();
|
const base = String(desktopDir || "").trim();
|
||||||
if (!base) {
|
if (!base) {
|
||||||
logDir = null;
|
logDir = null;
|
||||||
logFilePath = null;
|
logFilePath = null;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
logDir = path.join(base, FOLDER_NAME);
|
logDir = path.join(base, FOLDER_NAME);
|
||||||
logFilePath = path.join(logDir, `rename-session_${fileTimestamp()}.txt`);
|
logFilePath = path.join(logDir, `rename-session_${fileTimestamp()}.txt`);
|
||||||
sessionHeader = `=== Rename-Session gestartet: ${logTimestamp()} ===\n`
|
sessionHeader = `=== Rename-Session gestartet: ${logTimestamp()} ===\n`
|
||||||
+ "Diese Datei protokolliert JEDEN Umbenenn-/Verschiebevorgang dieser Programm-Sitzung\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"
|
+ "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";
|
+ "Platte liegt (und die Quelle verschwunden ist). [INFO]=ok, [ERROR]=Verifikation gescheitert.\n\n";
|
||||||
fs.mkdirSync(logDir, { recursive: true });
|
fs.mkdirSync(logDir, { recursive: true });
|
||||||
fs.writeFileSync(logFilePath, sessionHeader, "utf8");
|
fs.writeFileSync(logFilePath, sessionHeader, "utf8");
|
||||||
} catch {
|
} catch {
|
||||||
logDir = null;
|
logDir = null;
|
||||||
logFilePath = null;
|
logFilePath = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function logDesktopRename(level: DesktopRenameLevel, message: string, fields?: Record<string, unknown>): void {
|
export function logDesktopRename(level: DesktopRenameLevel, message: string, fields?: Record<string, unknown>): void {
|
||||||
if (!ensureWritable() || !logFilePath) {
|
if (!ensureWritable() || !logFilePath) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
fs.appendFileSync(logFilePath, `${logTimestamp()} [${level}] ${message}${formatFields(fields)}\n`, "utf8");
|
fs.appendFileSync(logFilePath, `${logTimestamp()} [${level}] ${message}${formatFields(fields)}\n`, "utf8");
|
||||||
} catch {
|
} catch {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getDesktopRenameLogPath(): string | null {
|
export function getDesktopRenameLogPath(): string | null {
|
||||||
if (!logFilePath) {
|
if (!logFilePath) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
return fs.existsSync(logFilePath) ? logFilePath : null;
|
return fs.existsSync(logFilePath) ? logFilePath : null;
|
||||||
} catch {
|
} catch {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function shutdownDesktopRenameLog(): void {
|
export function shutdownDesktopRenameLog(): void {
|
||||||
if (ensureWritable() && logFilePath) {
|
if (ensureWritable() && logFilePath) {
|
||||||
try {
|
try {
|
||||||
fs.appendFileSync(logFilePath, `=== Rename-Session beendet: ${logTimestamp()} ===\n`, "utf8");
|
fs.appendFileSync(logFilePath, `=== Rename-Session beendet: ${logTimestamp()} ===\n`, "utf8");
|
||||||
} catch {
|
} catch {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
logDir = null;
|
logDir = null;
|
||||||
logFilePath = null;
|
logFilePath = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface RenameVerification {
|
export interface RenameVerification {
|
||||||
ok: boolean;
|
ok: boolean;
|
||||||
level: "INFO" | "WARN" | "ERROR";
|
level: "INFO" | "WARN" | "ERROR";
|
||||||
targetExists: boolean;
|
targetExists: boolean;
|
||||||
onDiskName: string | null;
|
onDiskName: string | null;
|
||||||
nameMatches: boolean;
|
nameMatches: boolean;
|
||||||
sourceGone: boolean;
|
sourceGone: boolean;
|
||||||
targetSize: number | null;
|
targetSize: number | null;
|
||||||
reason: string;
|
reason: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
function toLongPath(filePath: string): string {
|
function toLongPath(filePath: string): string {
|
||||||
const absolute = path.resolve(String(filePath || ""));
|
const absolute = path.resolve(String(filePath || ""));
|
||||||
if (process.platform !== "win32") {
|
if (process.platform !== "win32") {
|
||||||
return absolute;
|
return absolute;
|
||||||
}
|
}
|
||||||
if (!absolute || absolute.startsWith("\\\\?\\")) {
|
if (!absolute || absolute.startsWith("\\\\?\\")) {
|
||||||
return absolute;
|
return absolute;
|
||||||
}
|
}
|
||||||
if (absolute.length < 248) {
|
if (absolute.length < 248) {
|
||||||
return absolute;
|
return absolute;
|
||||||
}
|
}
|
||||||
if (absolute.startsWith("\\\\")) {
|
if (absolute.startsWith("\\\\")) {
|
||||||
return `\\\\?\\UNC\\${absolute.slice(2)}`;
|
return `\\\\?\\UNC\\${absolute.slice(2)}`;
|
||||||
}
|
}
|
||||||
return `\\\\?\\${absolute}`;
|
return `\\\\?\\${absolute}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function resolveOnDiskName(requested: string, entries: string[] | null): string | null {
|
function resolveOnDiskName(requested: string, entries: string[] | null): string | null {
|
||||||
if (entries === null) {
|
if (entries === null) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
const requestedLower = requested.toLowerCase();
|
const requestedLower = requested.toLowerCase();
|
||||||
return entries.find((entry) => entry === requested)
|
return entries.find((entry) => entry === requested)
|
||||||
|| entries.find((entry) => entry.toLowerCase() === requestedLower)
|
|| entries.find((entry) => entry.toLowerCase() === requestedLower)
|
||||||
|| requested;
|
|| requested;
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildVerification(
|
function buildVerification(
|
||||||
sourcePath: string,
|
sourcePath: string,
|
||||||
targetPath: string,
|
targetPath: string,
|
||||||
facts: { targetExists: boolean; targetSize: number | null; dirEntries: string[] | null; sourceExists: boolean }
|
facts: { targetExists: boolean; targetSize: number | null; dirEntries: string[] | null; sourceExists: boolean }
|
||||||
): RenameVerification {
|
): RenameVerification {
|
||||||
const requested = path.basename(targetPath);
|
const requested = path.basename(targetPath);
|
||||||
const dirReadFailed = facts.targetExists && facts.dirEntries === null;
|
const dirReadFailed = facts.targetExists && facts.dirEntries === null;
|
||||||
const onDiskName = facts.targetExists ? resolveOnDiskName(requested, facts.dirEntries) : null;
|
const onDiskName = facts.targetExists ? resolveOnDiskName(requested, facts.dirEntries) : null;
|
||||||
|
|
||||||
const samePath = path.resolve(sourcePath).toLowerCase() === path.resolve(targetPath).toLowerCase();
|
const samePath = path.resolve(sourcePath).toLowerCase() === path.resolve(targetPath).toLowerCase();
|
||||||
const sourceGone = samePath ? true : !facts.sourceExists;
|
const sourceGone = samePath ? true : !facts.sourceExists;
|
||||||
const nameMatches = facts.targetExists && !dirReadFailed && onDiskName === requested;
|
const nameMatches = facts.targetExists && !dirReadFailed && onDiskName === requested;
|
||||||
|
|
||||||
const problems: string[] = [];
|
const problems: string[] = [];
|
||||||
let level: "INFO" | "WARN" | "ERROR" = "INFO";
|
let level: "INFO" | "WARN" | "ERROR" = "INFO";
|
||||||
if (!facts.targetExists) {
|
if (!facts.targetExists) {
|
||||||
problems.push("Zieldatei nach Rename NICHT gefunden");
|
problems.push("Zieldatei nach Rename NICHT gefunden");
|
||||||
level = "ERROR";
|
level = "ERROR";
|
||||||
} else if (!dirReadFailed && !nameMatches) {
|
} else if (!dirReadFailed && !nameMatches) {
|
||||||
problems.push(`On-Disk-Name weicht ab (ist "${onDiskName}", erwartet "${requested}")`);
|
problems.push(`On-Disk-Name weicht ab (ist "${onDiskName}", erwartet "${requested}")`);
|
||||||
level = "ERROR";
|
level = "ERROR";
|
||||||
}
|
}
|
||||||
if (!samePath && facts.targetExists && !sourceGone) {
|
if (!samePath && facts.targetExists && !sourceGone) {
|
||||||
problems.push("Quelldatei existiert noch (moeglicher halb-fertiger Verschiebevorgang)");
|
problems.push("Quelldatei existiert noch (moeglicher halb-fertiger Verschiebevorgang)");
|
||||||
level = "ERROR";
|
level = "ERROR";
|
||||||
}
|
}
|
||||||
if (level === "INFO" && dirReadFailed) {
|
if (level === "INFO" && dirReadFailed) {
|
||||||
problems.push("Zielverzeichnis nicht lesbar — Schreibweise nicht verifiziert");
|
problems.push("Zielverzeichnis nicht lesbar — Schreibweise nicht verifiziert");
|
||||||
level = "WARN";
|
level = "WARN";
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
ok: level === "INFO",
|
ok: level === "INFO",
|
||||||
level,
|
level,
|
||||||
targetExists: facts.targetExists,
|
targetExists: facts.targetExists,
|
||||||
onDiskName,
|
onDiskName,
|
||||||
nameMatches,
|
nameMatches,
|
||||||
sourceGone,
|
sourceGone,
|
||||||
targetSize: facts.targetSize,
|
targetSize: facts.targetSize,
|
||||||
reason: problems.join("; ")
|
reason: problems.join("; ")
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function verifyRename(sourcePath: string, targetPath: string): RenameVerification {
|
export function verifyRename(sourcePath: string, targetPath: string): RenameVerification {
|
||||||
const longTarget = toLongPath(targetPath);
|
const longTarget = toLongPath(targetPath);
|
||||||
let targetExists = false;
|
let targetExists = false;
|
||||||
let targetSize: number | null = null;
|
let targetSize: number | null = null;
|
||||||
try {
|
try {
|
||||||
const stat = fs.statSync(longTarget);
|
const stat = fs.statSync(longTarget);
|
||||||
targetExists = true;
|
targetExists = true;
|
||||||
targetSize = stat.size;
|
targetSize = stat.size;
|
||||||
} catch {
|
} catch {
|
||||||
targetExists = false;
|
targetExists = false;
|
||||||
}
|
}
|
||||||
let dirEntries: string[] | null = null;
|
let dirEntries: string[] | null = null;
|
||||||
if (targetExists) {
|
if (targetExists) {
|
||||||
try {
|
try {
|
||||||
dirEntries = fs.readdirSync(path.dirname(longTarget));
|
dirEntries = fs.readdirSync(path.dirname(longTarget));
|
||||||
} catch {
|
} catch {
|
||||||
dirEntries = null;
|
dirEntries = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
let sourceExists = false;
|
let sourceExists = false;
|
||||||
try {
|
try {
|
||||||
fs.statSync(toLongPath(sourcePath));
|
fs.statSync(toLongPath(sourcePath));
|
||||||
sourceExists = true;
|
sourceExists = true;
|
||||||
} catch {
|
} catch {
|
||||||
sourceExists = false;
|
sourceExists = false;
|
||||||
}
|
}
|
||||||
return buildVerification(sourcePath, targetPath, { targetExists, targetSize, dirEntries, sourceExists });
|
return buildVerification(sourcePath, targetPath, { targetExists, targetSize, dirEntries, sourceExists });
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function verifyRenameAsync(sourcePath: string, targetPath: string): Promise<RenameVerification> {
|
export async function verifyRenameAsync(sourcePath: string, targetPath: string): Promise<RenameVerification> {
|
||||||
const longTarget = toLongPath(targetPath);
|
const longTarget = toLongPath(targetPath);
|
||||||
let targetExists = false;
|
let targetExists = false;
|
||||||
let targetSize: number | null = null;
|
let targetSize: number | null = null;
|
||||||
try {
|
try {
|
||||||
const stat = await fs.promises.stat(longTarget);
|
const stat = await fs.promises.stat(longTarget);
|
||||||
targetExists = true;
|
targetExists = true;
|
||||||
targetSize = stat.size;
|
targetSize = stat.size;
|
||||||
} catch {
|
} catch {
|
||||||
targetExists = false;
|
targetExists = false;
|
||||||
}
|
}
|
||||||
let dirEntries: string[] | null = null;
|
let dirEntries: string[] | null = null;
|
||||||
if (targetExists) {
|
if (targetExists) {
|
||||||
try {
|
try {
|
||||||
dirEntries = await fs.promises.readdir(path.dirname(longTarget));
|
dirEntries = await fs.promises.readdir(path.dirname(longTarget));
|
||||||
} catch {
|
} catch {
|
||||||
dirEntries = null;
|
dirEntries = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
let sourceExists = false;
|
let sourceExists = false;
|
||||||
try {
|
try {
|
||||||
await fs.promises.stat(toLongPath(sourcePath));
|
await fs.promises.stat(toLongPath(sourcePath));
|
||||||
sourceExists = true;
|
sourceExists = true;
|
||||||
} catch {
|
} catch {
|
||||||
sourceExists = false;
|
sourceExists = false;
|
||||||
}
|
}
|
||||||
return buildVerification(sourcePath, targetPath, { targetExists, targetSize, dirEntries, sourceExists });
|
return buildVerification(sourcePath, targetPath, { targetExists, targetSize, dirEntries, sourceExists });
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,166 +1,166 @@
|
|||||||
import { ALLOCATION_UNIT_SIZE } from "./constants";
|
import { ALLOCATION_UNIT_SIZE } from "./constants";
|
||||||
|
|
||||||
export type DownloadCompletionSource =
|
export type DownloadCompletionSource =
|
||||||
| "content-range"
|
| "content-range"
|
||||||
| "content-length"
|
| "content-length"
|
||||||
| "provider-metadata"
|
| "provider-metadata"
|
||||||
| "stream-end";
|
| "stream-end";
|
||||||
|
|
||||||
export type DownloadCompletionPlan = {
|
export type DownloadCompletionPlan = {
|
||||||
expectedTotal: number | null;
|
expectedTotal: number | null;
|
||||||
source: DownloadCompletionSource;
|
source: DownloadCompletionSource;
|
||||||
canFinishEarly: boolean;
|
canFinishEarly: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function planDownloadCompletion(args: {
|
export function planDownloadCompletion(args: {
|
||||||
existingBytes: number;
|
existingBytes: number;
|
||||||
responseStatus: number;
|
responseStatus: number;
|
||||||
contentLength: number;
|
contentLength: number;
|
||||||
totalFromRange: number | null;
|
totalFromRange: number | null;
|
||||||
knownTotal: number | null;
|
knownTotal: number | null;
|
||||||
correctedTotal: number | null;
|
correctedTotal: number | null;
|
||||||
}): DownloadCompletionPlan {
|
}): DownloadCompletionPlan {
|
||||||
const existingBytes = Math.max(0, Math.floor(Number(args.existingBytes) || 0));
|
const existingBytes = Math.max(0, Math.floor(Number(args.existingBytes) || 0));
|
||||||
const responseStatus = Math.floor(Number(args.responseStatus) || 0);
|
const responseStatus = Math.floor(Number(args.responseStatus) || 0);
|
||||||
const contentLength = Math.max(0, Math.floor(Number(args.contentLength) || 0));
|
const contentLength = Math.max(0, Math.floor(Number(args.contentLength) || 0));
|
||||||
const totalFromRange = Number.isFinite(args.totalFromRange || NaN)
|
const totalFromRange = Number.isFinite(args.totalFromRange || NaN)
|
||||||
? Math.max(0, Math.floor(args.totalFromRange || 0))
|
? Math.max(0, Math.floor(args.totalFromRange || 0))
|
||||||
: 0;
|
: 0;
|
||||||
const correctedTotal = Number.isFinite(args.correctedTotal || NaN)
|
const correctedTotal = Number.isFinite(args.correctedTotal || NaN)
|
||||||
? Math.max(0, Math.floor(args.correctedTotal || 0))
|
? Math.max(0, Math.floor(args.correctedTotal || 0))
|
||||||
: 0;
|
: 0;
|
||||||
const knownTotal = Number.isFinite(args.knownTotal || NaN)
|
const knownTotal = Number.isFinite(args.knownTotal || NaN)
|
||||||
? Math.max(0, Math.floor(args.knownTotal || 0))
|
? Math.max(0, Math.floor(args.knownTotal || 0))
|
||||||
: 0;
|
: 0;
|
||||||
|
|
||||||
if (correctedTotal > 0) {
|
if (correctedTotal > 0) {
|
||||||
return {
|
return {
|
||||||
expectedTotal: correctedTotal,
|
expectedTotal: correctedTotal,
|
||||||
source: totalFromRange > 0 ? "content-range" : "content-length",
|
source: totalFromRange > 0 ? "content-range" : "content-length",
|
||||||
canFinishEarly: true
|
canFinishEarly: true
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
if (totalFromRange > 0) {
|
if (totalFromRange > 0) {
|
||||||
return {
|
return {
|
||||||
expectedTotal: totalFromRange,
|
expectedTotal: totalFromRange,
|
||||||
source: "content-range",
|
source: "content-range",
|
||||||
canFinishEarly: true
|
canFinishEarly: true
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
if (contentLength > 0) {
|
if (contentLength > 0) {
|
||||||
return {
|
return {
|
||||||
expectedTotal: responseStatus === 206 ? existingBytes + contentLength : contentLength,
|
expectedTotal: responseStatus === 206 ? existingBytes + contentLength : contentLength,
|
||||||
source: "content-length",
|
source: "content-length",
|
||||||
canFinishEarly: true
|
canFinishEarly: true
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
if (knownTotal > 0) {
|
if (knownTotal > 0) {
|
||||||
return {
|
return {
|
||||||
expectedTotal: knownTotal,
|
expectedTotal: knownTotal,
|
||||||
source: "provider-metadata",
|
source: "provider-metadata",
|
||||||
canFinishEarly: false
|
canFinishEarly: false
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
expectedTotal: null,
|
expectedTotal: null,
|
||||||
source: "stream-end",
|
source: "stream-end",
|
||||||
canFinishEarly: false
|
canFinishEarly: false
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function reconcileFinalizedSize(
|
export function reconcileFinalizedSize(
|
||||||
streamedBytes: number,
|
streamedBytes: number,
|
||||||
statSize: number,
|
statSize: number,
|
||||||
preAllocated: boolean
|
preAllocated: boolean
|
||||||
): number {
|
): number {
|
||||||
const streamed = Math.max(0, Math.floor(Number(streamedBytes) || 0));
|
const streamed = Math.max(0, Math.floor(Number(streamedBytes) || 0));
|
||||||
if (!Number.isFinite(statSize) || statSize < 0) {
|
if (!Number.isFinite(statSize) || statSize < 0) {
|
||||||
return streamed;
|
return streamed;
|
||||||
}
|
}
|
||||||
const onDisk = Math.floor(statSize);
|
const onDisk = Math.floor(statSize);
|
||||||
if (preAllocated && onDisk > streamed) {
|
if (preAllocated && onDisk > streamed) {
|
||||||
return streamed;
|
return streamed;
|
||||||
}
|
}
|
||||||
return onDisk;
|
return onDisk;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function validateDownloadedFileCompletion(args: {
|
export function validateDownloadedFileCompletion(args: {
|
||||||
actualBytes: number;
|
actualBytes: number;
|
||||||
plan: DownloadCompletionPlan;
|
plan: DownloadCompletionPlan;
|
||||||
toleranceBytes?: number;
|
toleranceBytes?: number;
|
||||||
}): {
|
}): {
|
||||||
ok: boolean;
|
ok: boolean;
|
||||||
totalBytes: number;
|
totalBytes: number;
|
||||||
acceptedMetadataMismatch: boolean;
|
acceptedMetadataMismatch: boolean;
|
||||||
error?: string;
|
error?: string;
|
||||||
} {
|
} {
|
||||||
const actualBytes = Math.max(0, Math.floor(Number(args.actualBytes) || 0));
|
const actualBytes = Math.max(0, Math.floor(Number(args.actualBytes) || 0));
|
||||||
const expectedTotal = Number.isFinite(args.plan.expectedTotal || NaN)
|
const expectedTotal = Number.isFinite(args.plan.expectedTotal || NaN)
|
||||||
? Math.max(0, Math.floor(args.plan.expectedTotal || 0))
|
? Math.max(0, Math.floor(args.plan.expectedTotal || 0))
|
||||||
: 0;
|
: 0;
|
||||||
const toleranceBytes = Math.max(0, Math.floor(Number(args.toleranceBytes ?? ALLOCATION_UNIT_SIZE) || 0));
|
const toleranceBytes = Math.max(0, Math.floor(Number(args.toleranceBytes ?? ALLOCATION_UNIT_SIZE) || 0));
|
||||||
|
|
||||||
if (
|
if (
|
||||||
expectedTotal > 0 &&
|
expectedTotal > 0 &&
|
||||||
(args.plan.source === "content-range" || args.plan.source === "content-length") &&
|
(args.plan.source === "content-range" || args.plan.source === "content-length") &&
|
||||||
actualBytes + toleranceBytes < expectedTotal
|
actualBytes + toleranceBytes < expectedTotal
|
||||||
) {
|
) {
|
||||||
return {
|
return {
|
||||||
ok: false,
|
ok: false,
|
||||||
totalBytes: expectedTotal,
|
totalBytes: expectedTotal,
|
||||||
acceptedMetadataMismatch: false,
|
acceptedMetadataMismatch: false,
|
||||||
error: `download_underflow:${actualBytes}/${expectedTotal}`
|
error: `download_underflow:${actualBytes}/${expectedTotal}`
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
if (actualBytes <= 0 && expectedTotal > 0) {
|
if (actualBytes <= 0 && expectedTotal > 0) {
|
||||||
return {
|
return {
|
||||||
ok: false,
|
ok: false,
|
||||||
totalBytes: expectedTotal,
|
totalBytes: expectedTotal,
|
||||||
acceptedMetadataMismatch: false,
|
acceptedMetadataMismatch: false,
|
||||||
error: `download_underflow:${actualBytes}/${expectedTotal}`
|
error: `download_underflow:${actualBytes}/${expectedTotal}`
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
if (args.plan.source === "provider-metadata") {
|
if (args.plan.source === "provider-metadata") {
|
||||||
if (expectedTotal > 0 && actualBytes + toleranceBytes < expectedTotal) {
|
if (expectedTotal > 0 && actualBytes + toleranceBytes < expectedTotal) {
|
||||||
return {
|
return {
|
||||||
ok: false,
|
ok: false,
|
||||||
totalBytes: expectedTotal,
|
totalBytes: expectedTotal,
|
||||||
acceptedMetadataMismatch: false,
|
acceptedMetadataMismatch: false,
|
||||||
error: `download_underflow:${actualBytes}/${expectedTotal}`
|
error: `download_underflow:${actualBytes}/${expectedTotal}`
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
ok: true,
|
ok: true,
|
||||||
totalBytes: actualBytes,
|
totalBytes: actualBytes,
|
||||||
acceptedMetadataMismatch: expectedTotal > 0 && Math.abs(actualBytes - expectedTotal) > toleranceBytes
|
acceptedMetadataMismatch: expectedTotal > 0 && Math.abs(actualBytes - expectedTotal) > toleranceBytes
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
if (args.plan.source === "stream-end") {
|
if (args.plan.source === "stream-end") {
|
||||||
if (actualBytes <= 0) {
|
if (actualBytes <= 0) {
|
||||||
return {
|
return {
|
||||||
ok: false,
|
ok: false,
|
||||||
totalBytes: 0,
|
totalBytes: 0,
|
||||||
acceptedMetadataMismatch: false,
|
acceptedMetadataMismatch: false,
|
||||||
error: "download_underflow:0/0"
|
error: "download_underflow:0/0"
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
ok: true,
|
ok: true,
|
||||||
totalBytes: actualBytes,
|
totalBytes: actualBytes,
|
||||||
acceptedMetadataMismatch: false
|
acceptedMetadataMismatch: false
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
ok: true,
|
ok: true,
|
||||||
totalBytes: Math.max(actualBytes, expectedTotal),
|
totalBytes: Math.max(actualBytes, expectedTotal),
|
||||||
acceptedMetadataMismatch: false
|
acceptedMetadataMismatch: false
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@ -1,45 +1,45 @@
|
|||||||
export interface ErrorRingEntry {
|
export interface ErrorRingEntry {
|
||||||
ts: string;
|
ts: string;
|
||||||
level: string;
|
level: string;
|
||||||
message: string;
|
message: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ErrorRing {
|
export interface ErrorRing {
|
||||||
push: (entry: ErrorRingEntry) => void;
|
push: (entry: ErrorRingEntry) => void;
|
||||||
snapshot: () => ErrorRingEntry[];
|
snapshot: () => ErrorRingEntry[];
|
||||||
clear: () => void;
|
clear: () => void;
|
||||||
size: () => number;
|
size: () => number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createErrorRing(capacity: number): ErrorRing {
|
export function createErrorRing(capacity: number): ErrorRing {
|
||||||
const limit = Math.max(1, Math.floor(capacity));
|
const limit = Math.max(1, Math.floor(capacity));
|
||||||
const buffer: ErrorRingEntry[] = [];
|
const buffer: ErrorRingEntry[] = [];
|
||||||
return {
|
return {
|
||||||
push(entry: ErrorRingEntry): void {
|
push(entry: ErrorRingEntry): void {
|
||||||
buffer.push(entry);
|
buffer.push(entry);
|
||||||
while (buffer.length > limit) {
|
while (buffer.length > limit) {
|
||||||
buffer.shift();
|
buffer.shift();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
snapshot(): ErrorRingEntry[] {
|
snapshot(): ErrorRingEntry[] {
|
||||||
return buffer.slice();
|
return buffer.slice();
|
||||||
},
|
},
|
||||||
clear(): void {
|
clear(): void {
|
||||||
buffer.length = 0;
|
buffer.length = 0;
|
||||||
},
|
},
|
||||||
size(): number {
|
size(): number {
|
||||||
return buffer.length;
|
return buffer.length;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const RECENT_ERROR_CAPACITY = 200;
|
const RECENT_ERROR_CAPACITY = 200;
|
||||||
const recentErrors = createErrorRing(RECENT_ERROR_CAPACITY);
|
const recentErrors = createErrorRing(RECENT_ERROR_CAPACITY);
|
||||||
|
|
||||||
export function recordRecentError(level: string, message: string, ts: string): void {
|
export function recordRecentError(level: string, message: string, ts: string): void {
|
||||||
recentErrors.push({ level, message, ts });
|
recentErrors.push({ level, message, ts });
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getRecentErrors(): ErrorRingEntry[] {
|
export function getRecentErrors(): ErrorRingEntry[] {
|
||||||
return recentErrors.snapshot();
|
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
|
// 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
|
// generic "write failed" or "timeout" can be reported as the specific root cause
|
||||||
// (disk full, permission denied, ...). Pure + side-effect-free for testing.
|
// (disk full, permission denied, ...). Pure + side-effect-free for testing.
|
||||||
|
|
||||||
const DISK_ERROR_REASONS: Record<string, string> = {
|
const DISK_ERROR_REASONS: Record<string, string> = {
|
||||||
ENOSPC: "Festplatte voll (ENOSPC)",
|
ENOSPC: "Festplatte voll (ENOSPC)",
|
||||||
EDQUOT: "Speicher-Kontingent erschöpft (EDQUOT)",
|
EDQUOT: "Speicher-Kontingent erschöpft (EDQUOT)",
|
||||||
EROFS: "Laufwerk schreibgeschützt (EROFS)",
|
EROFS: "Laufwerk schreibgeschützt (EROFS)",
|
||||||
EACCES: "Zugriff verweigert (EACCES)",
|
EACCES: "Zugriff verweigert (EACCES)",
|
||||||
EPERM: "Operation nicht erlaubt (EPERM)",
|
EPERM: "Operation nicht erlaubt (EPERM)",
|
||||||
EMFILE: "Zu viele offene Dateien (EMFILE)",
|
EMFILE: "Zu viele offene Dateien (EMFILE)",
|
||||||
ENFILE: "System-Limit offener Dateien erreicht (ENFILE)",
|
ENFILE: "System-Limit offener Dateien erreicht (ENFILE)",
|
||||||
EBUSY: "Datei/Laufwerk belegt (EBUSY)",
|
EBUSY: "Datei/Laufwerk belegt (EBUSY)",
|
||||||
ENODEV: "Gerät nicht vorhanden (ENODEV)",
|
ENODEV: "Gerät nicht vorhanden (ENODEV)",
|
||||||
ENXIO: "Gerät getrennt (ENXIO)",
|
ENXIO: "Gerät getrennt (ENXIO)",
|
||||||
EIO: "Ein-/Ausgabefehler des Datenträgers (EIO)"
|
EIO: "Ein-/Ausgabefehler des Datenträgers (EIO)"
|
||||||
};
|
};
|
||||||
|
|
||||||
export function classifyDiskError(err: unknown): string | null {
|
export function classifyDiskError(err: unknown): string | null {
|
||||||
const code = extractErrorCode(err);
|
const code = extractErrorCode(err);
|
||||||
if (code && DISK_ERROR_REASONS[code]) {
|
if (code && DISK_ERROR_REASONS[code]) {
|
||||||
return DISK_ERROR_REASONS[code];
|
return DISK_ERROR_REASONS[code];
|
||||||
}
|
}
|
||||||
// Some errors arrive as plain strings/messages without a `.code`; fall back to
|
// Some errors arrive as plain strings/messages without a `.code`; fall back to
|
||||||
// scanning the text for a known code token.
|
// scanning the text for a known code token.
|
||||||
const text = errorText(err);
|
const text = errorText(err);
|
||||||
for (const knownCode of Object.keys(DISK_ERROR_REASONS)) {
|
for (const knownCode of Object.keys(DISK_ERROR_REASONS)) {
|
||||||
if (text.includes(knownCode)) {
|
if (text.includes(knownCode)) {
|
||||||
return DISK_ERROR_REASONS[knownCode];
|
return DISK_ERROR_REASONS[knownCode];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function extractErrorCode(err: unknown): string {
|
function extractErrorCode(err: unknown): string {
|
||||||
if (err && typeof err === "object") {
|
if (err && typeof err === "object") {
|
||||||
const code = (err as { code?: unknown }).code;
|
const code = (err as { code?: unknown }).code;
|
||||||
if (typeof code === "string") {
|
if (typeof code === "string") {
|
||||||
return code.toUpperCase();
|
return code.toUpperCase();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return "";
|
return "";
|
||||||
}
|
}
|
||||||
|
|
||||||
function errorText(err: unknown): string {
|
function errorText(err: unknown): string {
|
||||||
if (typeof err === "string") {
|
if (typeof err === "string") {
|
||||||
return err;
|
return err;
|
||||||
}
|
}
|
||||||
if (err && typeof err === "object") {
|
if (err && typeof err === "object") {
|
||||||
const message = (err as { message?: unknown }).message;
|
const message = (err as { message?: unknown }).message;
|
||||||
if (typeof message === "string") {
|
if (typeof message === "string") {
|
||||||
return message;
|
return message;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return String(err ?? "");
|
return String(err ?? "");
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,159 +1,159 @@
|
|||||||
import fs from "node:fs";
|
import fs from "node:fs";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import crypto from "node:crypto";
|
import crypto from "node:crypto";
|
||||||
import { ParsedHashEntry } from "../shared/types";
|
import { ParsedHashEntry } from "../shared/types";
|
||||||
import { MAX_MANIFEST_FILE_BYTES } from "./constants";
|
import { MAX_MANIFEST_FILE_BYTES } from "./constants";
|
||||||
|
|
||||||
const manifestCache = new Map<string, { at: number; entries: Map<string, ParsedHashEntry> }>();
|
const manifestCache = new Map<string, { at: number; entries: Map<string, ParsedHashEntry> }>();
|
||||||
const MANIFEST_CACHE_TTL_MS = 15000;
|
const MANIFEST_CACHE_TTL_MS = 15000;
|
||||||
|
|
||||||
function normalizeManifestKey(value: string): string {
|
function normalizeManifestKey(value: string): string {
|
||||||
return String(value || "")
|
return String(value || "")
|
||||||
.replace(/\\/g, "/")
|
.replace(/\\/g, "/")
|
||||||
.replace(/^\.\//, "")
|
.replace(/^\.\//, "")
|
||||||
.trim()
|
.trim()
|
||||||
.toLowerCase();
|
.toLowerCase();
|
||||||
}
|
}
|
||||||
|
|
||||||
export function parseHashLine(line: string): ParsedHashEntry | null {
|
export function parseHashLine(line: string): ParsedHashEntry | null {
|
||||||
const text = String(line || "").trim();
|
const text = String(line || "").trim();
|
||||||
if (!text || text.startsWith(";")) {
|
if (!text || text.startsWith(";")) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
const md = text.match(/^([0-9a-fA-F]{32}|[0-9a-fA-F]{40})\s+\*?(.+)$/);
|
const md = text.match(/^([0-9a-fA-F]{32}|[0-9a-fA-F]{40})\s+\*?(.+)$/);
|
||||||
if (md) {
|
if (md) {
|
||||||
const digest = md[1].toLowerCase();
|
const digest = md[1].toLowerCase();
|
||||||
return {
|
return {
|
||||||
fileName: md[2].trim(),
|
fileName: md[2].trim(),
|
||||||
algorithm: digest.length === 32 ? "md5" : "sha1",
|
algorithm: digest.length === 32 ? "md5" : "sha1",
|
||||||
digest
|
digest
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
const sfv = text.match(/^(.+?)\s+([0-9A-Fa-f]{8})$/);
|
const sfv = text.match(/^(.+?)\s+([0-9A-Fa-f]{8})$/);
|
||||||
if (sfv) {
|
if (sfv) {
|
||||||
return {
|
return {
|
||||||
fileName: sfv[1].trim(),
|
fileName: sfv[1].trim(),
|
||||||
algorithm: "crc32",
|
algorithm: "crc32",
|
||||||
digest: sfv[2].toLowerCase()
|
digest: sfv[2].toLowerCase()
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function readHashManifest(packageDir: string): Map<string, ParsedHashEntry> {
|
export function readHashManifest(packageDir: string): Map<string, ParsedHashEntry> {
|
||||||
const cacheKey = path.resolve(packageDir);
|
const cacheKey = path.resolve(packageDir);
|
||||||
const cached = manifestCache.get(cacheKey);
|
const cached = manifestCache.get(cacheKey);
|
||||||
if (cached && Date.now() - cached.at <= MANIFEST_CACHE_TTL_MS) {
|
if (cached && Date.now() - cached.at <= MANIFEST_CACHE_TTL_MS) {
|
||||||
return new Map(cached.entries);
|
return new Map(cached.entries);
|
||||||
}
|
}
|
||||||
|
|
||||||
const map = new Map<string, ParsedHashEntry>();
|
const map = new Map<string, ParsedHashEntry>();
|
||||||
const patterns: Array<[string, "crc32" | "md5" | "sha1"]> = [
|
const patterns: Array<[string, "crc32" | "md5" | "sha1"]> = [
|
||||||
[".sfv", "crc32"],
|
[".sfv", "crc32"],
|
||||||
[".md5", "md5"],
|
[".md5", "md5"],
|
||||||
[".sha1", "sha1"]
|
[".sha1", "sha1"]
|
||||||
];
|
];
|
||||||
|
|
||||||
if (!fs.existsSync(packageDir)) {
|
if (!fs.existsSync(packageDir)) {
|
||||||
return map;
|
return map;
|
||||||
}
|
}
|
||||||
|
|
||||||
const manifestFiles = fs.readdirSync(packageDir, { withFileTypes: true })
|
const manifestFiles = fs.readdirSync(packageDir, { withFileTypes: true })
|
||||||
.filter((entry) => {
|
.filter((entry) => {
|
||||||
if (!entry.isFile()) {
|
if (!entry.isFile()) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
const ext = path.extname(entry.name).toLowerCase();
|
const ext = path.extname(entry.name).toLowerCase();
|
||||||
return patterns.some(([pattern]) => pattern === ext);
|
return patterns.some(([pattern]) => pattern === ext);
|
||||||
})
|
})
|
||||||
.sort((a, b) => a.name.localeCompare(b.name, undefined, { numeric: true, sensitivity: "base" }));
|
.sort((a, b) => a.name.localeCompare(b.name, undefined, { numeric: true, sensitivity: "base" }));
|
||||||
|
|
||||||
for (const entry of manifestFiles) {
|
for (const entry of manifestFiles) {
|
||||||
if (!entry.isFile()) {
|
if (!entry.isFile()) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const ext = path.extname(entry.name).toLowerCase();
|
const ext = path.extname(entry.name).toLowerCase();
|
||||||
const hit = patterns.find(([pattern]) => pattern === ext);
|
const hit = patterns.find(([pattern]) => pattern === ext);
|
||||||
if (!hit) {
|
if (!hit) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const filePath = path.join(packageDir, entry.name);
|
const filePath = path.join(packageDir, entry.name);
|
||||||
let lines: string[];
|
let lines: string[];
|
||||||
try {
|
try {
|
||||||
const stat = fs.statSync(filePath);
|
const stat = fs.statSync(filePath);
|
||||||
if (stat.size > MAX_MANIFEST_FILE_BYTES) {
|
if (stat.size > MAX_MANIFEST_FILE_BYTES) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
lines = fs.readFileSync(filePath, "utf8").split(/\r?\n/);
|
lines = fs.readFileSync(filePath, "utf8").split(/\r?\n/);
|
||||||
} catch {
|
} catch {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
for (const line of lines) {
|
for (const line of lines) {
|
||||||
const parsed = parseHashLine(line);
|
const parsed = parseHashLine(line);
|
||||||
if (!parsed) {
|
if (!parsed) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const key = normalizeManifestKey(parsed.fileName);
|
const key = normalizeManifestKey(parsed.fileName);
|
||||||
if (map.has(key)) {
|
if (map.has(key)) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
map.set(key, parsed);
|
map.set(key, parsed);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
manifestCache.set(cacheKey, { at: Date.now(), entries: new Map(map) });
|
manifestCache.set(cacheKey, { at: Date.now(), entries: new Map(map) });
|
||||||
return map;
|
return map;
|
||||||
}
|
}
|
||||||
|
|
||||||
const crcTable = new Int32Array(256);
|
const crcTable = new Int32Array(256);
|
||||||
for (let i = 0; i < 256; i++) {
|
for (let i = 0; i < 256; i++) {
|
||||||
let c = i;
|
let c = i;
|
||||||
for (let j = 0; j < 8; j++) c = c & 1 ? (0xedb88320 ^ (c >>> 1)) : (c >>> 1);
|
for (let j = 0; j < 8; j++) c = c & 1 ? (0xedb88320 ^ (c >>> 1)) : (c >>> 1);
|
||||||
crcTable[i] = c;
|
crcTable[i] = c;
|
||||||
}
|
}
|
||||||
|
|
||||||
function crc32Buffer(data: Buffer, seed = 0): number {
|
function crc32Buffer(data: Buffer, seed = 0): number {
|
||||||
let crc = seed ^ -1;
|
let crc = seed ^ -1;
|
||||||
for (let i = 0; i < data.length; i++) {
|
for (let i = 0; i < data.length; i++) {
|
||||||
crc = (crc >>> 8) ^ crcTable[(crc ^ data[i]) & 0xff];
|
crc = (crc >>> 8) ^ crcTable[(crc ^ data[i]) & 0xff];
|
||||||
}
|
}
|
||||||
return crc ^ -1;
|
return crc ^ -1;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function hashFile(filePath: string, algorithm: "crc32" | "md5" | "sha1"): Promise<string> {
|
async function hashFile(filePath: string, algorithm: "crc32" | "md5" | "sha1"): Promise<string> {
|
||||||
if (algorithm === "crc32") {
|
if (algorithm === "crc32") {
|
||||||
const stream = fs.createReadStream(filePath, { highWaterMark: 1024 * 1024 });
|
const stream = fs.createReadStream(filePath, { highWaterMark: 1024 * 1024 });
|
||||||
let crc = 0;
|
let crc = 0;
|
||||||
for await (const chunk of stream) {
|
for await (const chunk of stream) {
|
||||||
crc = crc32Buffer(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk), crc);
|
crc = crc32Buffer(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk), crc);
|
||||||
await new Promise(r => setImmediate(r));
|
await new Promise(r => setImmediate(r));
|
||||||
}
|
}
|
||||||
return (crc >>> 0).toString(16).padStart(8, "0").toLowerCase();
|
return (crc >>> 0).toString(16).padStart(8, "0").toLowerCase();
|
||||||
}
|
}
|
||||||
|
|
||||||
const hash = crypto.createHash(algorithm);
|
const hash = crypto.createHash(algorithm);
|
||||||
const stream = fs.createReadStream(filePath, { highWaterMark: 1024 * 1024 });
|
const stream = fs.createReadStream(filePath, { highWaterMark: 1024 * 1024 });
|
||||||
return await new Promise<string>((resolve, reject) => {
|
return await new Promise<string>((resolve, reject) => {
|
||||||
stream.on("data", (chunk: string | Buffer) => hash.update(typeof chunk === "string" ? Buffer.from(chunk) : chunk));
|
stream.on("data", (chunk: string | Buffer) => hash.update(typeof chunk === "string" ? Buffer.from(chunk) : chunk));
|
||||||
stream.on("error", reject);
|
stream.on("error", reject);
|
||||||
stream.on("end", () => resolve(hash.digest("hex").toLowerCase()));
|
stream.on("end", () => resolve(hash.digest("hex").toLowerCase()));
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function validateFileAgainstManifest(filePath: string, packageDir: string): Promise<{ ok: boolean; message: string }> {
|
export async function validateFileAgainstManifest(filePath: string, packageDir: string): Promise<{ ok: boolean; message: string }> {
|
||||||
const manifest = readHashManifest(packageDir);
|
const manifest = readHashManifest(packageDir);
|
||||||
if (manifest.size === 0) {
|
if (manifest.size === 0) {
|
||||||
return { ok: true, message: "Kein Hash verfügbar" };
|
return { ok: true, message: "Kein Hash verfügbar" };
|
||||||
}
|
}
|
||||||
const keyByBaseName = normalizeManifestKey(path.basename(filePath));
|
const keyByBaseName = normalizeManifestKey(path.basename(filePath));
|
||||||
const keyByRelativePath = normalizeManifestKey(path.relative(packageDir, filePath));
|
const keyByRelativePath = normalizeManifestKey(path.relative(packageDir, filePath));
|
||||||
const entry = manifest.get(keyByRelativePath) || manifest.get(keyByBaseName);
|
const entry = manifest.get(keyByRelativePath) || manifest.get(keyByBaseName);
|
||||||
if (!entry) {
|
if (!entry) {
|
||||||
return { ok: true, message: "Kein Hash für Datei" };
|
return { ok: true, message: "Kein Hash für Datei" };
|
||||||
}
|
}
|
||||||
|
|
||||||
const actual = await hashFile(filePath, entry.algorithm);
|
const actual = await hashFile(filePath, entry.algorithm);
|
||||||
if (actual === entry.digest.toLowerCase()) {
|
if (actual === entry.digest.toLowerCase()) {
|
||||||
return { ok: true, message: `${entry.algorithm.toUpperCase()} ok` };
|
return { ok: true, message: `${entry.algorithm.toUpperCase()} ok` };
|
||||||
}
|
}
|
||||||
return { ok: false, message: `${entry.algorithm.toUpperCase()} mismatch` };
|
return { ok: false, message: `${entry.algorithm.toUpperCase()} mismatch` };
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,232 +1,232 @@
|
|||||||
import fs from "node:fs";
|
import fs from "node:fs";
|
||||||
import { logTimestamp } from "./log-timestamp";
|
import { logTimestamp } from "./log-timestamp";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import crypto from "node:crypto";
|
import crypto from "node:crypto";
|
||||||
|
|
||||||
const ITEM_LOG_FLUSH_INTERVAL_MS = 200;
|
const ITEM_LOG_FLUSH_INTERVAL_MS = 200;
|
||||||
const ITEM_LOG_RETENTION_DAYS = 30;
|
const ITEM_LOG_RETENTION_DAYS = 30;
|
||||||
|
|
||||||
type ItemLogLevel = "INFO" | "WARN" | "ERROR";
|
type ItemLogLevel = "INFO" | "WARN" | "ERROR";
|
||||||
|
|
||||||
export interface ItemLogMeta {
|
export interface ItemLogMeta {
|
||||||
itemId: string;
|
itemId: string;
|
||||||
packageId: string;
|
packageId: string;
|
||||||
packageName: string;
|
packageName: string;
|
||||||
fileName: string;
|
fileName: string;
|
||||||
targetPath: string;
|
targetPath: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
let itemLogsDir: string | null = null;
|
let itemLogsDir: string | null = null;
|
||||||
const knownLogPaths = new Map<string, string>();
|
const knownLogPaths = new Map<string, string>();
|
||||||
const pendingLinesByItem = new Map<string, string[]>();
|
const pendingLinesByItem = new Map<string, string[]>();
|
||||||
const initializedThisProcess = new Set<string>();
|
const initializedThisProcess = new Set<string>();
|
||||||
let flushTimer: NodeJS.Timeout | null = null;
|
let flushTimer: NodeJS.Timeout | null = null;
|
||||||
|
|
||||||
function normalizeItemId(itemId: string): string {
|
function normalizeItemId(itemId: string): string {
|
||||||
const trimmed = String(itemId || "").trim();
|
const trimmed = String(itemId || "").trim();
|
||||||
if (!trimmed) {
|
if (!trimmed) {
|
||||||
return "";
|
return "";
|
||||||
}
|
}
|
||||||
const safePrefix = trimmed
|
const safePrefix = trimmed
|
||||||
.replace(/[^a-zA-Z0-9._-]/g, "_")
|
.replace(/[^a-zA-Z0-9._-]/g, "_")
|
||||||
.replace(/_+/g, "_")
|
.replace(/_+/g, "_")
|
||||||
.slice(0, 64)
|
.slice(0, 64)
|
||||||
.replace(/^_+|_+$/g, "");
|
.replace(/^_+|_+$/g, "");
|
||||||
const hash = crypto.createHash("sha1").update(trimmed).digest("hex").slice(0, 12);
|
const hash = crypto.createHash("sha1").update(trimmed).digest("hex").slice(0, 12);
|
||||||
return `${safePrefix || "item"}_${hash}`;
|
return `${safePrefix || "item"}_${hash}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function sanitizeFieldValue(value: unknown): string {
|
function sanitizeFieldValue(value: unknown): string {
|
||||||
if (value === undefined || value === null) {
|
if (value === undefined || value === null) {
|
||||||
return "";
|
return "";
|
||||||
}
|
}
|
||||||
if (typeof value === "string") {
|
if (typeof value === "string") {
|
||||||
return value.replace(/\r?\n/g, "\\n");
|
return value.replace(/\r?\n/g, "\\n");
|
||||||
}
|
}
|
||||||
if (typeof value === "number" || typeof value === "boolean") {
|
if (typeof value === "number" || typeof value === "boolean") {
|
||||||
return String(value);
|
return String(value);
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
return JSON.stringify(value).replace(/\r?\n/g, "\\n");
|
return JSON.stringify(value).replace(/\r?\n/g, "\\n");
|
||||||
} catch {
|
} catch {
|
||||||
return String(value);
|
return String(value);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatFields(fields?: Record<string, unknown>): string {
|
function formatFields(fields?: Record<string, unknown>): string {
|
||||||
if (!fields) {
|
if (!fields) {
|
||||||
return "";
|
return "";
|
||||||
}
|
}
|
||||||
const parts = Object.entries(fields)
|
const parts = Object.entries(fields)
|
||||||
.filter(([, value]) => value !== undefined && value !== null && sanitizeFieldValue(value) !== "")
|
.filter(([, value]) => value !== undefined && value !== null && sanitizeFieldValue(value) !== "")
|
||||||
.map(([key, value]) => `${key}=${sanitizeFieldValue(value)}`);
|
.map(([key, value]) => `${key}=${sanitizeFieldValue(value)}`);
|
||||||
return parts.length > 0 ? ` | ${parts.join(" | ")}` : "";
|
return parts.length > 0 ? ` | ${parts.join(" | ")}` : "";
|
||||||
}
|
}
|
||||||
|
|
||||||
function getItemLogFilePathFromNormalized(normalized: string): string | null {
|
function getItemLogFilePathFromNormalized(normalized: string): string | null {
|
||||||
if (!normalized || !itemLogsDir) {
|
if (!normalized || !itemLogsDir) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
const existing = knownLogPaths.get(normalized);
|
const existing = knownLogPaths.get(normalized);
|
||||||
if (existing) {
|
if (existing) {
|
||||||
return existing;
|
return existing;
|
||||||
}
|
}
|
||||||
const logPath = path.join(itemLogsDir, `item_${normalized}.txt`);
|
const logPath = path.join(itemLogsDir, `item_${normalized}.txt`);
|
||||||
knownLogPaths.set(normalized, logPath);
|
knownLogPaths.set(normalized, logPath);
|
||||||
return logPath;
|
return logPath;
|
||||||
}
|
}
|
||||||
|
|
||||||
function getItemLogFilePath(itemId: string): string | null {
|
function getItemLogFilePath(itemId: string): string | null {
|
||||||
return getItemLogFilePathFromNormalized(normalizeItemId(itemId));
|
return getItemLogFilePathFromNormalized(normalizeItemId(itemId));
|
||||||
}
|
}
|
||||||
|
|
||||||
function flushPending(): void {
|
function flushPending(): void {
|
||||||
for (const [itemId, lines] of pendingLinesByItem.entries()) {
|
for (const [itemId, lines] of pendingLinesByItem.entries()) {
|
||||||
if (lines.length === 0) {
|
if (lines.length === 0) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const logPath = getItemLogFilePathFromNormalized(itemId);
|
const logPath = getItemLogFilePathFromNormalized(itemId);
|
||||||
if (!logPath) {
|
if (!logPath) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const chunk = lines.join("");
|
const chunk = lines.join("");
|
||||||
pendingLinesByItem.set(itemId, []);
|
pendingLinesByItem.set(itemId, []);
|
||||||
try {
|
try {
|
||||||
fs.appendFileSync(logPath, chunk, "utf8");
|
fs.appendFileSync(logPath, chunk, "utf8");
|
||||||
} catch {
|
} catch {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function scheduleFlush(): void {
|
function scheduleFlush(): void {
|
||||||
if (flushTimer) {
|
if (flushTimer) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
flushTimer = setTimeout(() => {
|
flushTimer = setTimeout(() => {
|
||||||
flushTimer = null;
|
flushTimer = null;
|
||||||
flushPending();
|
flushPending();
|
||||||
}, ITEM_LOG_FLUSH_INTERVAL_MS);
|
}, ITEM_LOG_FLUSH_INTERVAL_MS);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function cleanupOldItemLogs(dir: string): Promise<void> {
|
async function cleanupOldItemLogs(dir: string): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const files = await fs.promises.readdir(dir);
|
const files = await fs.promises.readdir(dir);
|
||||||
const cutoff = Date.now() - ITEM_LOG_RETENTION_DAYS * 24 * 60 * 60 * 1000;
|
const cutoff = Date.now() - ITEM_LOG_RETENTION_DAYS * 24 * 60 * 60 * 1000;
|
||||||
for (const file of files) {
|
for (const file of files) {
|
||||||
if (!file.startsWith("item_") || !file.endsWith(".txt")) {
|
if (!file.startsWith("item_") || !file.endsWith(".txt")) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const filePath = path.join(dir, file);
|
const filePath = path.join(dir, file);
|
||||||
try {
|
try {
|
||||||
const stat = await fs.promises.stat(filePath);
|
const stat = await fs.promises.stat(filePath);
|
||||||
if (stat.mtimeMs < cutoff) {
|
if (stat.mtimeMs < cutoff) {
|
||||||
await fs.promises.unlink(filePath);
|
await fs.promises.unlink(filePath);
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function appendLine(itemId: string, line: string): void {
|
function appendLine(itemId: string, line: string): void {
|
||||||
const normalized = normalizeItemId(itemId);
|
const normalized = normalizeItemId(itemId);
|
||||||
if (!normalized) {
|
if (!normalized) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const lines = pendingLinesByItem.get(normalized) || [];
|
const lines = pendingLinesByItem.get(normalized) || [];
|
||||||
lines.push(line);
|
lines.push(line);
|
||||||
pendingLinesByItem.set(normalized, lines);
|
pendingLinesByItem.set(normalized, lines);
|
||||||
scheduleFlush();
|
scheduleFlush();
|
||||||
}
|
}
|
||||||
|
|
||||||
export function initItemLogs(baseDir: string): void {
|
export function initItemLogs(baseDir: string): void {
|
||||||
itemLogsDir = path.join(baseDir, "item-logs");
|
itemLogsDir = path.join(baseDir, "item-logs");
|
||||||
try {
|
try {
|
||||||
fs.mkdirSync(itemLogsDir, { recursive: true });
|
fs.mkdirSync(itemLogsDir, { recursive: true });
|
||||||
} catch {
|
} catch {
|
||||||
itemLogsDir = null;
|
itemLogsDir = null;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
void cleanupOldItemLogs(itemLogsDir);
|
void cleanupOldItemLogs(itemLogsDir);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ensureItemLog(meta: ItemLogMeta): string | null {
|
export function ensureItemLog(meta: ItemLogMeta): string | null {
|
||||||
const normalizedItemId = normalizeItemId(meta.itemId);
|
const normalizedItemId = normalizeItemId(meta.itemId);
|
||||||
const logPath = getItemLogFilePath(meta.itemId);
|
const logPath = getItemLogFilePath(meta.itemId);
|
||||||
if (!logPath) {
|
if (!logPath) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
fs.mkdirSync(path.dirname(logPath), { recursive: true });
|
fs.mkdirSync(path.dirname(logPath), { recursive: true });
|
||||||
if (!fs.existsSync(logPath)) {
|
if (!fs.existsSync(logPath)) {
|
||||||
fs.writeFileSync(logPath, "", "utf8");
|
fs.writeFileSync(logPath, "", "utf8");
|
||||||
}
|
}
|
||||||
if (!initializedThisProcess.has(normalizedItemId)) {
|
if (!initializedThisProcess.has(normalizedItemId)) {
|
||||||
initializedThisProcess.add(normalizedItemId);
|
initializedThisProcess.add(normalizedItemId);
|
||||||
const startedAt = logTimestamp();
|
const startedAt = logTimestamp();
|
||||||
fs.appendFileSync(
|
fs.appendFileSync(
|
||||||
logPath,
|
logPath,
|
||||||
`=== Item-Log Start: ${startedAt} | itemId=${sanitizeFieldValue(String(meta.itemId || ""))} | logKey=${normalizedItemId} | fileName=${sanitizeFieldValue(meta.fileName)} ===\n`,
|
`=== Item-Log Start: ${startedAt} | itemId=${sanitizeFieldValue(String(meta.itemId || ""))} | logKey=${normalizedItemId} | fileName=${sanitizeFieldValue(meta.fileName)} ===\n`,
|
||||||
"utf8"
|
"utf8"
|
||||||
);
|
);
|
||||||
fs.appendFileSync(
|
fs.appendFileSync(
|
||||||
logPath,
|
logPath,
|
||||||
`${logTimestamp()} [INFO] Item-Kontext initialisiert${formatFields({
|
`${logTimestamp()} [INFO] Item-Kontext initialisiert${formatFields({
|
||||||
packageId: meta.packageId,
|
packageId: meta.packageId,
|
||||||
packageName: meta.packageName,
|
packageName: meta.packageName,
|
||||||
fileName: meta.fileName,
|
fileName: meta.fileName,
|
||||||
targetPath: meta.targetPath
|
targetPath: meta.targetPath
|
||||||
})}\n`,
|
})}\n`,
|
||||||
"utf8"
|
"utf8"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
return logPath;
|
return logPath;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function logItemEvent(
|
export function logItemEvent(
|
||||||
itemId: string,
|
itemId: string,
|
||||||
level: ItemLogLevel,
|
level: ItemLogLevel,
|
||||||
message: string,
|
message: string,
|
||||||
fields?: Record<string, unknown>
|
fields?: Record<string, unknown>
|
||||||
): void {
|
): void {
|
||||||
const logPath = getItemLogFilePath(itemId);
|
const logPath = getItemLogFilePath(itemId);
|
||||||
if (!logPath) {
|
if (!logPath) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const line = `${logTimestamp()} [${level}] ${message}${formatFields(fields)}\n`;
|
const line = `${logTimestamp()} [${level}] ${message}${formatFields(fields)}\n`;
|
||||||
appendLine(itemId, line);
|
appendLine(itemId, line);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getItemLogPath(itemId: string): string | null {
|
export function getItemLogPath(itemId: string): string | null {
|
||||||
const logPath = getItemLogFilePath(itemId);
|
const logPath = getItemLogFilePath(itemId);
|
||||||
if (!logPath) {
|
if (!logPath) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
return fs.existsSync(logPath) ? logPath : null;
|
return fs.existsSync(logPath) ? logPath : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function shutdownItemLogs(): void {
|
export function shutdownItemLogs(): void {
|
||||||
if (flushTimer) {
|
if (flushTimer) {
|
||||||
clearTimeout(flushTimer);
|
clearTimeout(flushTimer);
|
||||||
flushTimer = null;
|
flushTimer = null;
|
||||||
}
|
}
|
||||||
flushPending();
|
flushPending();
|
||||||
for (const itemId of knownLogPaths.keys()) {
|
for (const itemId of knownLogPaths.keys()) {
|
||||||
const logPath = getItemLogFilePathFromNormalized(itemId);
|
const logPath = getItemLogFilePathFromNormalized(itemId);
|
||||||
if (!logPath) {
|
if (!logPath) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
fs.appendFileSync(logPath, `=== Item-Log Ende: ${logTimestamp()} ===\n`, "utf8");
|
fs.appendFileSync(logPath, `=== Item-Log Ende: ${logTimestamp()} ===\n`, "utf8");
|
||||||
} catch {
|
} catch {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
pendingLinesByItem.clear();
|
pendingLinesByItem.clear();
|
||||||
knownLogPaths.clear();
|
knownLogPaths.clear();
|
||||||
initializedThisProcess.clear();
|
initializedThisProcess.clear();
|
||||||
itemLogsDir = null;
|
itemLogsDir = null;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,116 +1,116 @@
|
|||||||
import type { ParsedPackageInput, UiSnapshot } from "../shared/types";
|
import type { ParsedPackageInput, UiSnapshot } from "../shared/types";
|
||||||
import { sanitizeFilename } from "./utils";
|
import { sanitizeFilename } from "./utils";
|
||||||
|
|
||||||
export type LinkExportSelection = {
|
export type LinkExportSelection = {
|
||||||
packages: ParsedPackageInput[];
|
packages: ParsedPackageInput[];
|
||||||
packageCount: number;
|
packageCount: number;
|
||||||
linkCount: number;
|
linkCount: number;
|
||||||
defaultFileName: string;
|
defaultFileName: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
function formatTimestampForFileName(date: Date): string {
|
function formatTimestampForFileName(date: Date): string {
|
||||||
const y = date.getFullYear();
|
const y = date.getFullYear();
|
||||||
const mo = String(date.getMonth() + 1).padStart(2, "0");
|
const mo = String(date.getMonth() + 1).padStart(2, "0");
|
||||||
const d = String(date.getDate()).padStart(2, "0");
|
const d = String(date.getDate()).padStart(2, "0");
|
||||||
const h = String(date.getHours()).padStart(2, "0");
|
const h = String(date.getHours()).padStart(2, "0");
|
||||||
const mi = String(date.getMinutes()).padStart(2, "0");
|
const mi = String(date.getMinutes()).padStart(2, "0");
|
||||||
const s = String(date.getSeconds()).padStart(2, "0");
|
const s = String(date.getSeconds()).padStart(2, "0");
|
||||||
return `${y}-${mo}-${d}_${h}-${mi}-${s}`;
|
return `${y}-${mo}-${d}_${h}-${mi}-${s}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildDefaultFileName(packages: ParsedPackageInput[]): string {
|
function buildDefaultFileName(packages: ParsedPackageInput[]): string {
|
||||||
if (packages.length === 1) {
|
if (packages.length === 1) {
|
||||||
const only = packages[0];
|
const only = packages[0];
|
||||||
if (only.links.length === 1) {
|
if (only.links.length === 1) {
|
||||||
const itemName = sanitizeFilename(only.fileNames?.[0] || only.name || "link-export");
|
const itemName = sanitizeFilename(only.fileNames?.[0] || only.name || "link-export");
|
||||||
return `${itemName}.txt`;
|
return `${itemName}.txt`;
|
||||||
}
|
}
|
||||||
return `${sanitizeFilename(only.name || "paket-export")}.txt`;
|
return `${sanitizeFilename(only.name || "paket-export")}.txt`;
|
||||||
}
|
}
|
||||||
return `rd-link-export-${formatTimestampForFileName(new Date())}.txt`;
|
return `rd-link-export-${formatTimestampForFileName(new Date())}.txt`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function buildLinkExportSelection(snapshot: UiSnapshot, packageIds: string[], itemIds: string[]): LinkExportSelection {
|
export function buildLinkExportSelection(snapshot: UiSnapshot, packageIds: string[], itemIds: string[]): LinkExportSelection {
|
||||||
const selectedPackageIds = new Set(packageIds);
|
const selectedPackageIds = new Set(packageIds);
|
||||||
const selectedItemIds = new Set(itemIds);
|
const selectedItemIds = new Set(itemIds);
|
||||||
const packages: ParsedPackageInput[] = [];
|
const packages: ParsedPackageInput[] = [];
|
||||||
|
|
||||||
for (const packageId of snapshot.session.packageOrder) {
|
for (const packageId of snapshot.session.packageOrder) {
|
||||||
const pkg = snapshot.session.packages[packageId];
|
const pkg = snapshot.session.packages[packageId];
|
||||||
if (!pkg) {
|
if (!pkg) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
const useWholePackage = selectedPackageIds.has(packageId);
|
const useWholePackage = selectedPackageIds.has(packageId);
|
||||||
const relevantItemIds = useWholePackage
|
const relevantItemIds = useWholePackage
|
||||||
? pkg.itemIds
|
? pkg.itemIds
|
||||||
: pkg.itemIds.filter((itemId) => selectedItemIds.has(itemId));
|
: pkg.itemIds.filter((itemId) => selectedItemIds.has(itemId));
|
||||||
|
|
||||||
if (relevantItemIds.length === 0) {
|
if (relevantItemIds.length === 0) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
const links: string[] = [];
|
const links: string[] = [];
|
||||||
const fileNames: string[] = [];
|
const fileNames: string[] = [];
|
||||||
for (const itemId of relevantItemIds) {
|
for (const itemId of relevantItemIds) {
|
||||||
const item = snapshot.session.items[itemId];
|
const item = snapshot.session.items[itemId];
|
||||||
if (!item || !String(item.url || "").trim()) {
|
if (!item || !String(item.url || "").trim()) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
links.push(String(item.url).trim());
|
links.push(String(item.url).trim());
|
||||||
const rawFileName = String(item.fileName || "").trim();
|
const rawFileName = String(item.fileName || "").trim();
|
||||||
fileNames.push(rawFileName ? sanitizeFilename(rawFileName) : "");
|
fileNames.push(rawFileName ? sanitizeFilename(rawFileName) : "");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (links.length === 0) {
|
if (links.length === 0) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
const exportEntry: ParsedPackageInput = {
|
const exportEntry: ParsedPackageInput = {
|
||||||
name: sanitizeFilename(pkg.name || "Paket"),
|
name: sanitizeFilename(pkg.name || "Paket"),
|
||||||
links
|
links
|
||||||
};
|
};
|
||||||
if (fileNames.some((fileName) => fileName.length > 0)) {
|
if (fileNames.some((fileName) => fileName.length > 0)) {
|
||||||
exportEntry.fileNames = fileNames;
|
exportEntry.fileNames = fileNames;
|
||||||
}
|
}
|
||||||
packages.push(exportEntry);
|
packages.push(exportEntry);
|
||||||
}
|
}
|
||||||
|
|
||||||
const linkCount = packages.reduce((sum, pkg) => sum + pkg.links.length, 0);
|
const linkCount = packages.reduce((sum, pkg) => sum + pkg.links.length, 0);
|
||||||
return {
|
return {
|
||||||
packages,
|
packages,
|
||||||
packageCount: packages.length,
|
packageCount: packages.length,
|
||||||
linkCount,
|
linkCount,
|
||||||
defaultFileName: buildDefaultFileName(packages)
|
defaultFileName: buildDefaultFileName(packages)
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function serializeLinkExportText(packages: ParsedPackageInput[]): string {
|
export function serializeLinkExportText(packages: ParsedPackageInput[]): string {
|
||||||
const lines: string[] = [
|
const lines: string[] = [
|
||||||
"# rd-link-export: 1",
|
"# rd-link-export: 1",
|
||||||
"# Re-import in Real-Debrid-Downloader keeps package names and optional file names.",
|
"# Re-import in Real-Debrid-Downloader keeps package names and optional file names.",
|
||||||
""
|
""
|
||||||
];
|
];
|
||||||
|
|
||||||
for (const pkg of packages) {
|
for (const pkg of packages) {
|
||||||
if (!pkg || !pkg.name || !Array.isArray(pkg.links) || pkg.links.length === 0) {
|
if (!pkg || !pkg.name || !Array.isArray(pkg.links) || pkg.links.length === 0) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
lines.push(`# package: ${sanitizeFilename(pkg.name)}`);
|
lines.push(`# package: ${sanitizeFilename(pkg.name)}`);
|
||||||
for (let index = 0; index < pkg.links.length; index += 1) {
|
for (let index = 0; index < pkg.links.length; index += 1) {
|
||||||
const link = String(pkg.links[index] || "").trim();
|
const link = String(pkg.links[index] || "").trim();
|
||||||
if (!link) {
|
if (!link) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const rawFileName = String(pkg.fileNames?.[index] || "").trim();
|
const rawFileName = String(pkg.fileNames?.[index] || "").trim();
|
||||||
const fileName = rawFileName ? sanitizeFilename(rawFileName) : "";
|
const fileName = rawFileName ? sanitizeFilename(rawFileName) : "";
|
||||||
if (fileName) {
|
if (fileName) {
|
||||||
lines.push(`# file: ${fileName}`);
|
lines.push(`# file: ${fileName}`);
|
||||||
}
|
}
|
||||||
lines.push(link);
|
lines.push(link);
|
||||||
}
|
}
|
||||||
lines.push("");
|
lines.push("");
|
||||||
}
|
}
|
||||||
|
|
||||||
return `${lines.join("\n").trim()}\n`;
|
return `${lines.join("\n").trim()}\n`;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,42 +1,42 @@
|
|||||||
import { ParsedPackageInput } from "../shared/types";
|
import { ParsedPackageInput } from "../shared/types";
|
||||||
import { inferPackageNameFromLinks, parsePackagesFromLinksText, sanitizeFilename, uniquePreserveOrder } from "./utils";
|
import { inferPackageNameFromLinks, parsePackagesFromLinksText, sanitizeFilename, uniquePreserveOrder } from "./utils";
|
||||||
|
|
||||||
export function mergePackageInputs(packages: ParsedPackageInput[]): ParsedPackageInput[] {
|
export function mergePackageInputs(packages: ParsedPackageInput[]): ParsedPackageInput[] {
|
||||||
const grouped = new Map<string, { links: string[]; fileNameByLink: Map<string, string> }>();
|
const grouped = new Map<string, { links: string[]; fileNameByLink: Map<string, string> }>();
|
||||||
for (const pkg of packages) {
|
for (const pkg of packages) {
|
||||||
const name = sanitizeFilename(pkg.name || inferPackageNameFromLinks(pkg.links));
|
const name = sanitizeFilename(pkg.name || inferPackageNameFromLinks(pkg.links));
|
||||||
const current = grouped.get(name) ?? { links: [], fileNameByLink: new Map<string, string>() };
|
const current = grouped.get(name) ?? { links: [], fileNameByLink: new Map<string, string>() };
|
||||||
for (let index = 0; index < pkg.links.length; index += 1) {
|
for (let index = 0; index < pkg.links.length; index += 1) {
|
||||||
const link = String(pkg.links[index] || "").trim();
|
const link = String(pkg.links[index] || "").trim();
|
||||||
if (!link) {
|
if (!link) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (!current.links.includes(link)) {
|
if (!current.links.includes(link)) {
|
||||||
current.links.push(link);
|
current.links.push(link);
|
||||||
}
|
}
|
||||||
const rawFileName = String(pkg.fileNames?.[index] || "").trim();
|
const rawFileName = String(pkg.fileNames?.[index] || "").trim();
|
||||||
const fileName = rawFileName ? sanitizeFilename(rawFileName) : "";
|
const fileName = rawFileName ? sanitizeFilename(rawFileName) : "";
|
||||||
if (fileName && !current.fileNameByLink.has(link)) {
|
if (fileName && !current.fileNameByLink.has(link)) {
|
||||||
current.fileNameByLink.set(link, fileName);
|
current.fileNameByLink.set(link, fileName);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
grouped.set(name, current);
|
grouped.set(name, current);
|
||||||
}
|
}
|
||||||
return Array.from(grouped.entries()).map(([name, entry]) => {
|
return Array.from(grouped.entries()).map(([name, entry]) => {
|
||||||
const links = uniquePreserveOrder(entry.links);
|
const links = uniquePreserveOrder(entry.links);
|
||||||
const fileNames = links.map((link) => entry.fileNameByLink.get(link) || "");
|
const fileNames = links.map((link) => entry.fileNameByLink.get(link) || "");
|
||||||
return {
|
return {
|
||||||
name,
|
name,
|
||||||
links,
|
links,
|
||||||
...(fileNames.some((fileName) => fileName.length > 0) ? { fileNames } : {})
|
...(fileNames.some((fileName) => fileName.length > 0) ? { fileNames } : {})
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function parseCollectorInput(rawText: string, packageName = ""): ParsedPackageInput[] {
|
export function parseCollectorInput(rawText: string, packageName = ""): ParsedPackageInput[] {
|
||||||
const parsed = parsePackagesFromLinksText(rawText, packageName);
|
const parsed = parsePackagesFromLinksText(rawText, packageName);
|
||||||
if (parsed.length === 0) {
|
if (parsed.length === 0) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
return mergePackageInputs(parsed);
|
return mergePackageInputs(parsed);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,11 +1,11 @@
|
|||||||
export function logTimestamp(date: Date = new Date()): string {
|
export function logTimestamp(date: Date = new Date()): string {
|
||||||
const pad = (value: number, length = 2): string => String(value).padStart(length, "0");
|
const pad = (value: number, length = 2): string => String(value).padStart(length, "0");
|
||||||
const offsetMinutes = -date.getTimezoneOffset();
|
const offsetMinutes = -date.getTimezoneOffset();
|
||||||
const sign = offsetMinutes >= 0 ? "+" : "-";
|
const sign = offsetMinutes >= 0 ? "+" : "-";
|
||||||
const absOffset = Math.abs(offsetMinutes);
|
const absOffset = Math.abs(offsetMinutes);
|
||||||
const offset = `${sign}${pad(Math.floor(absOffset / 60))}:${pad(absOffset % 60)}`;
|
const offset = `${sign}${pad(Math.floor(absOffset / 60))}:${pad(absOffset % 60)}`;
|
||||||
return (
|
return (
|
||||||
`${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}` +
|
`${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}` +
|
||||||
`T${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}.${pad(date.getMilliseconds(), 3)}${offset}`
|
`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 fs from "node:fs";
|
||||||
import { logTimestamp } from "./log-timestamp";
|
import { logTimestamp } from "./log-timestamp";
|
||||||
import { recordRecentError } from "./error-ring";
|
import { recordRecentError } from "./error-ring";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
|
|
||||||
export function isDebugFlagEnabled(value: string | undefined): boolean {
|
export function isDebugFlagEnabled(value: string | undefined): boolean {
|
||||||
if (!value) {
|
if (!value) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
return /^(1|true|yes|on)$/i.test(value.trim());
|
return /^(1|true|yes|on)$/i.test(value.trim());
|
||||||
}
|
}
|
||||||
|
|
||||||
// Read once at startup. Enabling verbose DEBUG logging on the (unattended) server
|
// Read once at startup. Enabling verbose DEBUG logging on the (unattended) server
|
||||||
// is a deliberate support action that requires a restart — the runtime-toggleable
|
// is a deliberate support action that requires a restart — the runtime-toggleable
|
||||||
// channel is the trace log, not this.
|
// channel is the trace log, not this.
|
||||||
const DEBUG_ENABLED = isDebugFlagEnabled(process.env.RD_DEBUG);
|
const DEBUG_ENABLED = isDebugFlagEnabled(process.env.RD_DEBUG);
|
||||||
|
|
||||||
export function isDebugLoggingEnabled(): boolean {
|
export function isDebugLoggingEnabled(): boolean {
|
||||||
return DEBUG_ENABLED;
|
return DEBUG_ENABLED;
|
||||||
}
|
}
|
||||||
|
|
||||||
let logFilePath = path.resolve(process.cwd(), "rd_downloader.log");
|
let logFilePath = path.resolve(process.cwd(), "rd_downloader.log");
|
||||||
let fallbackLogFilePath: string | null = null;
|
let fallbackLogFilePath: string | null = null;
|
||||||
const LOG_FLUSH_INTERVAL_MS = 120;
|
const LOG_FLUSH_INTERVAL_MS = 120;
|
||||||
const LOG_BUFFER_LIMIT_CHARS = 1_000_000;
|
const LOG_BUFFER_LIMIT_CHARS = 1_000_000;
|
||||||
const LOG_MAX_FILE_BYTES = 10 * 1024 * 1024;
|
const LOG_MAX_FILE_BYTES = 10 * 1024 * 1024;
|
||||||
const rotateCheckAtByFile = new Map<string, number>();
|
const rotateCheckAtByFile = new Map<string, number>();
|
||||||
|
|
||||||
type LogListener = (line: string) => void;
|
type LogListener = (line: string) => void;
|
||||||
const logListeners = new Set<LogListener>();
|
const logListeners = new Set<LogListener>();
|
||||||
let legacyLogListener: LogListener | null = null;
|
let legacyLogListener: LogListener | null = null;
|
||||||
|
|
||||||
let pendingLines: string[] = [];
|
let pendingLines: string[] = [];
|
||||||
let pendingChars = 0;
|
let pendingChars = 0;
|
||||||
let flushTimer: NodeJS.Timeout | null = null;
|
let flushTimer: NodeJS.Timeout | null = null;
|
||||||
let flushInFlight = false;
|
let flushInFlight = false;
|
||||||
let exitHookAttached = false;
|
let exitHookAttached = false;
|
||||||
|
|
||||||
export function setLogListener(listener: LogListener | null): void {
|
export function setLogListener(listener: LogListener | null): void {
|
||||||
if (legacyLogListener) {
|
if (legacyLogListener) {
|
||||||
logListeners.delete(legacyLogListener);
|
logListeners.delete(legacyLogListener);
|
||||||
}
|
}
|
||||||
legacyLogListener = listener;
|
legacyLogListener = listener;
|
||||||
if (listener) {
|
if (listener) {
|
||||||
logListeners.add(listener);
|
logListeners.add(listener);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function addLogListener(listener: LogListener): void {
|
export function addLogListener(listener: LogListener): void {
|
||||||
logListeners.add(listener);
|
logListeners.add(listener);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function removeLogListener(listener: LogListener): void {
|
export function removeLogListener(listener: LogListener): void {
|
||||||
logListeners.delete(listener);
|
logListeners.delete(listener);
|
||||||
if (legacyLogListener === listener) {
|
if (legacyLogListener === listener) {
|
||||||
legacyLogListener = null;
|
legacyLogListener = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function configureLogger(baseDir: string): void {
|
export function configureLogger(baseDir: string): void {
|
||||||
logFilePath = path.join(baseDir, "rd_downloader.log");
|
logFilePath = path.join(baseDir, "rd_downloader.log");
|
||||||
const cwdLogPath = path.resolve(process.cwd(), "rd_downloader.log");
|
const cwdLogPath = path.resolve(process.cwd(), "rd_downloader.log");
|
||||||
fallbackLogFilePath = cwdLogPath === logFilePath ? null : cwdLogPath;
|
fallbackLogFilePath = cwdLogPath === logFilePath ? null : cwdLogPath;
|
||||||
}
|
}
|
||||||
|
|
||||||
function appendLine(filePath: string, line: string): { ok: boolean; errorText: string } {
|
function appendLine(filePath: string, line: string): { ok: boolean; errorText: string } {
|
||||||
try {
|
try {
|
||||||
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
||||||
fs.appendFileSync(filePath, line, "utf8");
|
fs.appendFileSync(filePath, line, "utf8");
|
||||||
return { ok: true, errorText: "" };
|
return { ok: true, errorText: "" };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return { ok: false, errorText: String(error) };
|
return { ok: false, errorText: String(error) };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function appendChunk(filePath: string, chunk: string): Promise<{ ok: boolean; errorText: string }> {
|
async function appendChunk(filePath: string, chunk: string): Promise<{ ok: boolean; errorText: string }> {
|
||||||
try {
|
try {
|
||||||
await fs.promises.mkdir(path.dirname(filePath), { recursive: true });
|
await fs.promises.mkdir(path.dirname(filePath), { recursive: true });
|
||||||
await fs.promises.appendFile(filePath, chunk, "utf8");
|
await fs.promises.appendFile(filePath, chunk, "utf8");
|
||||||
return { ok: true, errorText: "" };
|
return { ok: true, errorText: "" };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return { ok: false, errorText: String(error) };
|
return { ok: false, errorText: String(error) };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function writeStderr(text: string): void {
|
function writeStderr(text: string): void {
|
||||||
try {
|
try {
|
||||||
process.stderr.write(text);
|
process.stderr.write(text);
|
||||||
} catch {
|
} catch {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function flushSyncPending(): void {
|
function flushSyncPending(): void {
|
||||||
if (pendingLines.length === 0) {
|
if (pendingLines.length === 0) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const chunk = pendingLines.join("");
|
const chunk = pendingLines.join("");
|
||||||
pendingLines = [];
|
pendingLines = [];
|
||||||
pendingChars = 0;
|
pendingChars = 0;
|
||||||
|
|
||||||
rotateIfNeeded(logFilePath);
|
rotateIfNeeded(logFilePath);
|
||||||
const primary = appendLine(logFilePath, chunk);
|
const primary = appendLine(logFilePath, chunk);
|
||||||
if (fallbackLogFilePath) {
|
if (fallbackLogFilePath) {
|
||||||
rotateIfNeeded(fallbackLogFilePath);
|
rotateIfNeeded(fallbackLogFilePath);
|
||||||
const fallback = appendLine(fallbackLogFilePath, chunk);
|
const fallback = appendLine(fallbackLogFilePath, chunk);
|
||||||
if (!primary.ok && !fallback.ok) {
|
if (!primary.ok && !fallback.ok) {
|
||||||
writeStderr(`LOGGER write failed (primary+fallback): ${primary.errorText} | ${fallback.errorText}\n`);
|
writeStderr(`LOGGER write failed (primary+fallback): ${primary.errorText} | ${fallback.errorText}\n`);
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!primary.ok) {
|
if (!primary.ok) {
|
||||||
writeStderr(`LOGGER write failed: ${primary.errorText}\n`);
|
writeStderr(`LOGGER write failed: ${primary.errorText}\n`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function scheduleFlush(immediate = false): void {
|
function scheduleFlush(immediate = false): void {
|
||||||
if (flushInFlight) {
|
if (flushInFlight) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (immediate) {
|
if (immediate) {
|
||||||
if (flushTimer) {
|
if (flushTimer) {
|
||||||
clearTimeout(flushTimer);
|
clearTimeout(flushTimer);
|
||||||
flushTimer = null;
|
flushTimer = null;
|
||||||
}
|
}
|
||||||
void flushAsync();
|
void flushAsync();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (flushTimer) {
|
if (flushTimer) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
flushTimer = setTimeout(() => {
|
flushTimer = setTimeout(() => {
|
||||||
flushTimer = null;
|
flushTimer = null;
|
||||||
void flushAsync();
|
void flushAsync();
|
||||||
}, LOG_FLUSH_INTERVAL_MS);
|
}, LOG_FLUSH_INTERVAL_MS);
|
||||||
}
|
}
|
||||||
|
|
||||||
function rotateIfNeeded(filePath: string): void {
|
function rotateIfNeeded(filePath: string): void {
|
||||||
try {
|
try {
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
const lastRotateCheckAt = rotateCheckAtByFile.get(filePath) || 0;
|
const lastRotateCheckAt = rotateCheckAtByFile.get(filePath) || 0;
|
||||||
if (now - lastRotateCheckAt < 60_000) {
|
if (now - lastRotateCheckAt < 60_000) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
rotateCheckAtByFile.set(filePath, now);
|
rotateCheckAtByFile.set(filePath, now);
|
||||||
const stat = fs.statSync(filePath);
|
const stat = fs.statSync(filePath);
|
||||||
if (stat.size < LOG_MAX_FILE_BYTES) {
|
if (stat.size < LOG_MAX_FILE_BYTES) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const backup = `${filePath}.old`;
|
const backup = `${filePath}.old`;
|
||||||
try {
|
try {
|
||||||
fs.rmSync(backup, { force: true });
|
fs.rmSync(backup, { force: true });
|
||||||
} catch {
|
} catch {
|
||||||
}
|
}
|
||||||
fs.renameSync(filePath, backup);
|
fs.renameSync(filePath, backup);
|
||||||
} catch {
|
} catch {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function rotateIfNeededAsync(filePath: string): Promise<void> {
|
async function rotateIfNeededAsync(filePath: string): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
const lastRotateCheckAt = rotateCheckAtByFile.get(filePath) || 0;
|
const lastRotateCheckAt = rotateCheckAtByFile.get(filePath) || 0;
|
||||||
if (now - lastRotateCheckAt < 60_000) {
|
if (now - lastRotateCheckAt < 60_000) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
rotateCheckAtByFile.set(filePath, now);
|
rotateCheckAtByFile.set(filePath, now);
|
||||||
const stat = await fs.promises.stat(filePath);
|
const stat = await fs.promises.stat(filePath);
|
||||||
if (stat.size < LOG_MAX_FILE_BYTES) {
|
if (stat.size < LOG_MAX_FILE_BYTES) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const backup = `${filePath}.old`;
|
const backup = `${filePath}.old`;
|
||||||
await fs.promises.rm(backup, { force: true }).catch(() => {});
|
await fs.promises.rm(backup, { force: true }).catch(() => {});
|
||||||
await fs.promises.rename(filePath, backup);
|
await fs.promises.rename(filePath, backup);
|
||||||
} catch {
|
} catch {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function flushAsync(): Promise<void> {
|
async function flushAsync(): Promise<void> {
|
||||||
if (flushInFlight || pendingLines.length === 0) {
|
if (flushInFlight || pendingLines.length === 0) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
flushInFlight = true;
|
flushInFlight = true;
|
||||||
// Move (not copy) the pending lines out and take ownership. A concurrent write()
|
// 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
|
// 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))
|
// of pendingLines; the old count-based removal (pendingLines.slice(snapshot.length))
|
||||||
// then sliced off the wrong lines and dropped unwritten ones. Resetting the buffer
|
// then sliced off the wrong lines and dropped unwritten ones. Resetting the buffer
|
||||||
// here means await-time writes queue independently and nothing desyncs.
|
// here means await-time writes queue independently and nothing desyncs.
|
||||||
const linesSnapshot = pendingLines;
|
const linesSnapshot = pendingLines;
|
||||||
pendingLines = [];
|
pendingLines = [];
|
||||||
pendingChars = 0;
|
pendingChars = 0;
|
||||||
const chunk = linesSnapshot.join("");
|
const chunk = linesSnapshot.join("");
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await rotateIfNeededAsync(logFilePath);
|
await rotateIfNeededAsync(logFilePath);
|
||||||
const primary = await appendChunk(logFilePath, chunk);
|
const primary = await appendChunk(logFilePath, chunk);
|
||||||
let wroteAny = primary.ok;
|
let wroteAny = primary.ok;
|
||||||
if (fallbackLogFilePath) {
|
if (fallbackLogFilePath) {
|
||||||
await rotateIfNeededAsync(fallbackLogFilePath);
|
await rotateIfNeededAsync(fallbackLogFilePath);
|
||||||
const fallback = await appendChunk(fallbackLogFilePath, chunk);
|
const fallback = await appendChunk(fallbackLogFilePath, chunk);
|
||||||
wroteAny = wroteAny || fallback.ok;
|
wroteAny = wroteAny || fallback.ok;
|
||||||
if (!primary.ok && !fallback.ok) {
|
if (!primary.ok && !fallback.ok) {
|
||||||
writeStderr(`LOGGER write failed (primary+fallback): ${primary.errorText} | ${fallback.errorText}\n`);
|
writeStderr(`LOGGER write failed (primary+fallback): ${primary.errorText} | ${fallback.errorText}\n`);
|
||||||
}
|
}
|
||||||
} else if (!primary.ok) {
|
} else if (!primary.ok) {
|
||||||
writeStderr(`LOGGER write failed: ${primary.errorText}\n`);
|
writeStderr(`LOGGER write failed: ${primary.errorText}\n`);
|
||||||
}
|
}
|
||||||
if (!wroteAny) {
|
if (!wroteAny) {
|
||||||
// Write failed: requeue the unwritten lines AHEAD of anything that arrived
|
// Write failed: requeue the unwritten lines AHEAD of anything that arrived
|
||||||
// during the await (preserve order), then re-apply the buffer cap so a
|
// during the await (preserve order), then re-apply the buffer cap so a
|
||||||
// persistent write failure cannot grow the buffer without bound.
|
// persistent write failure cannot grow the buffer without bound.
|
||||||
pendingLines = linesSnapshot.concat(pendingLines);
|
pendingLines = linesSnapshot.concat(pendingLines);
|
||||||
pendingChars += chunk.length;
|
pendingChars += chunk.length;
|
||||||
while (pendingChars > LOG_BUFFER_LIMIT_CHARS && pendingLines.length > 1) {
|
while (pendingChars > LOG_BUFFER_LIMIT_CHARS && pendingLines.length > 1) {
|
||||||
const removed = pendingLines.shift();
|
const removed = pendingLines.shift();
|
||||||
if (!removed) {
|
if (!removed) {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
pendingChars = Math.max(0, pendingChars - removed.length);
|
pendingChars = Math.max(0, pendingChars - removed.length);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
flushInFlight = false;
|
flushInFlight = false;
|
||||||
if (pendingLines.length > 0) {
|
if (pendingLines.length > 0) {
|
||||||
scheduleFlush();
|
scheduleFlush();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function ensureExitHook(): void {
|
function ensureExitHook(): void {
|
||||||
if (exitHookAttached) {
|
if (exitHookAttached) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
exitHookAttached = true;
|
exitHookAttached = true;
|
||||||
process.once("beforeExit", flushSyncPending);
|
process.once("beforeExit", flushSyncPending);
|
||||||
process.once("exit", flushSyncPending);
|
process.once("exit", flushSyncPending);
|
||||||
}
|
}
|
||||||
|
|
||||||
function write(level: "DEBUG" | "INFO" | "WARN" | "ERROR", message: string): void {
|
function write(level: "DEBUG" | "INFO" | "WARN" | "ERROR", message: string): void {
|
||||||
ensureExitHook();
|
ensureExitHook();
|
||||||
const ts = logTimestamp();
|
const ts = logTimestamp();
|
||||||
const line = `${ts} [${level}] ${message}\n`;
|
const line = `${ts} [${level}] ${message}\n`;
|
||||||
pendingLines.push(line);
|
pendingLines.push(line);
|
||||||
pendingChars += line.length;
|
pendingChars += line.length;
|
||||||
|
|
||||||
// Single chokepoint: every WARN/ERROR also lands in the in-memory ring so
|
// Single chokepoint: every WARN/ERROR also lands in the in-memory ring so
|
||||||
// "what failed recently" is answerable even after the file rotates.
|
// "what failed recently" is answerable even after the file rotates.
|
||||||
if (level === "ERROR" || level === "WARN") {
|
if (level === "ERROR" || level === "WARN") {
|
||||||
recordRecentError(level, message, ts);
|
recordRecentError(level, message, ts);
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const listener of logListeners) {
|
for (const listener of logListeners) {
|
||||||
try { listener(line); } catch { }
|
try { listener(line); } catch { }
|
||||||
}
|
}
|
||||||
|
|
||||||
while (pendingChars > LOG_BUFFER_LIMIT_CHARS && pendingLines.length > 1) {
|
while (pendingChars > LOG_BUFFER_LIMIT_CHARS && pendingLines.length > 1) {
|
||||||
const removed = pendingLines.shift();
|
const removed = pendingLines.shift();
|
||||||
if (!removed) {
|
if (!removed) {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
pendingChars = Math.max(0, pendingChars - removed.length);
|
pendingChars = Math.max(0, pendingChars - removed.length);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (level === "ERROR") {
|
if (level === "ERROR") {
|
||||||
scheduleFlush(true);
|
scheduleFlush(true);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
scheduleFlush();
|
scheduleFlush();
|
||||||
}
|
}
|
||||||
|
|
||||||
export const logger = {
|
export const logger = {
|
||||||
// Gated to a no-op when RD_DEBUG is unset so verbose call sites cost nothing
|
// 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.
|
// (no formatting, no allocation) in the normal/production path.
|
||||||
debug: DEBUG_ENABLED ? (msg: string): void => write("DEBUG", msg) : (_msg: string): void => {},
|
debug: DEBUG_ENABLED ? (msg: string): void => write("DEBUG", msg) : (_msg: string): void => {},
|
||||||
info: (msg: string): void => write("INFO", msg),
|
info: (msg: string): void => write("INFO", msg),
|
||||||
warn: (msg: string): void => write("WARN", msg),
|
warn: (msg: string): void => write("WARN", msg),
|
||||||
error: (msg: string): void => write("ERROR", msg)
|
error: (msg: string): void => write("ERROR", msg)
|
||||||
};
|
};
|
||||||
|
|
||||||
export function getLogFilePath(): string {
|
export function getLogFilePath(): string {
|
||||||
return logFilePath;
|
return logFilePath;
|
||||||
}
|
}
|
||||||
|
|||||||
1762
src/main/main.ts
1762
src/main/main.ts
File diff suppressed because it is too large
Load Diff
@ -1,129 +1,129 @@
|
|||||||
import crypto from "node:crypto";
|
import crypto from "node:crypto";
|
||||||
|
|
||||||
const MEGA_API_BASE = "https://g.api.mega.co.nz/cs";
|
const MEGA_API_BASE = "https://g.api.mega.co.nz/cs";
|
||||||
const MEGA_API_TIMEOUT_MS = 12_000;
|
const MEGA_API_TIMEOUT_MS = 12_000;
|
||||||
|
|
||||||
export interface MegaFileInfo {
|
export interface MegaFileInfo {
|
||||||
name: string;
|
name: string;
|
||||||
size: number;
|
size: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
const NEW_FORMAT_RE = /^https?:\/\/mega\.(?:nz|co\.nz)\/file\/([A-Za-z0-9_-]+)#([A-Za-z0-9_-]+)/i;
|
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;
|
const LEGACY_FORMAT_RE = /^https?:\/\/mega\.(?:nz|co\.nz)\/#!([A-Za-z0-9_-]+)!([A-Za-z0-9_-]+)/i;
|
||||||
|
|
||||||
export function isMegaFileUrl(url: string): boolean {
|
export function isMegaFileUrl(url: string): boolean {
|
||||||
const s = String(url || "").trim();
|
const s = String(url || "").trim();
|
||||||
return NEW_FORMAT_RE.test(s) || LEGACY_FORMAT_RE.test(s);
|
return NEW_FORMAT_RE.test(s) || LEGACY_FORMAT_RE.test(s);
|
||||||
}
|
}
|
||||||
|
|
||||||
function base64UrlDecode(s: string): Buffer | null {
|
function base64UrlDecode(s: string): Buffer | null {
|
||||||
let b64 = String(s || "").trim().replace(/-/g, "+").replace(/_/g, "/");
|
let b64 = String(s || "").trim().replace(/-/g, "+").replace(/_/g, "/");
|
||||||
while (b64.length % 4 !== 0) b64 += "=";
|
while (b64.length % 4 !== 0) b64 += "=";
|
||||||
try {
|
try {
|
||||||
return Buffer.from(b64, "base64");
|
return Buffer.from(b64, "base64");
|
||||||
} catch {
|
} catch {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ParsedMegaLink {
|
export interface ParsedMegaLink {
|
||||||
id: string;
|
id: string;
|
||||||
rawKey: Buffer;
|
rawKey: Buffer;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function parseMegaUrl(url: string): ParsedMegaLink | null {
|
export function parseMegaUrl(url: string): ParsedMegaLink | null {
|
||||||
const s = String(url || "").trim();
|
const s = String(url || "").trim();
|
||||||
const m = NEW_FORMAT_RE.exec(s) || LEGACY_FORMAT_RE.exec(s);
|
const m = NEW_FORMAT_RE.exec(s) || LEGACY_FORMAT_RE.exec(s);
|
||||||
if (!m) return null;
|
if (!m) return null;
|
||||||
const id = m[1];
|
const id = m[1];
|
||||||
const rawKey = base64UrlDecode(m[2]);
|
const rawKey = base64UrlDecode(m[2]);
|
||||||
if (!rawKey || rawKey.length !== 32) return null;
|
if (!rawKey || rawKey.length !== 32) return null;
|
||||||
return { id, rawKey };
|
return { id, rawKey };
|
||||||
}
|
}
|
||||||
|
|
||||||
export function decryptMegaAttributes(encrypted: Buffer, aesKey: Buffer): Record<string, unknown> | null {
|
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(encrypted) || encrypted.length === 0 || encrypted.length % 16 !== 0) return null;
|
||||||
if (!Buffer.isBuffer(aesKey) || aesKey.length !== 16) return null;
|
if (!Buffer.isBuffer(aesKey) || aesKey.length !== 16) return null;
|
||||||
let plain: Buffer;
|
let plain: Buffer;
|
||||||
try {
|
try {
|
||||||
const decipher = crypto.createDecipheriv("aes-128-cbc", aesKey, Buffer.alloc(16));
|
const decipher = crypto.createDecipheriv("aes-128-cbc", aesKey, Buffer.alloc(16));
|
||||||
decipher.setAutoPadding(false);
|
decipher.setAutoPadding(false);
|
||||||
plain = Buffer.concat([decipher.update(encrypted), decipher.final()]);
|
plain = Buffer.concat([decipher.update(encrypted), decipher.final()]);
|
||||||
} catch {
|
} catch {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
const text = plain.toString("utf8").replace(/\0+$/, "").trim();
|
const text = plain.toString("utf8").replace(/\0+$/, "").trim();
|
||||||
if (!text.startsWith("MEGA{")) return null;
|
if (!text.startsWith("MEGA{")) return null;
|
||||||
try {
|
try {
|
||||||
return JSON.parse(text.slice(4));
|
return JSON.parse(text.slice(4));
|
||||||
} catch {
|
} catch {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function withTimeoutSignal(parent: AbortSignal | undefined, timeoutMs: number): AbortSignal {
|
function withTimeoutSignal(parent: AbortSignal | undefined, timeoutMs: number): AbortSignal {
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
const timer = setTimeout(() => controller.abort("mega-api-timeout"), timeoutMs);
|
const timer = setTimeout(() => controller.abort("mega-api-timeout"), timeoutMs);
|
||||||
if (parent) {
|
if (parent) {
|
||||||
if (parent.aborted) {
|
if (parent.aborted) {
|
||||||
controller.abort(parent.reason);
|
controller.abort(parent.reason);
|
||||||
} else {
|
} else {
|
||||||
parent.addEventListener("abort", () => controller.abort(parent.reason), { once: true });
|
parent.addEventListener("abort", () => controller.abort(parent.reason), { once: true });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
controller.signal.addEventListener("abort", () => clearTimeout(timer), { once: true });
|
controller.signal.addEventListener("abort", () => clearTimeout(timer), { once: true });
|
||||||
return controller.signal;
|
return controller.signal;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function resolveMegaFilename(
|
export async function resolveMegaFilename(
|
||||||
url: string,
|
url: string,
|
||||||
signal?: AbortSignal
|
signal?: AbortSignal
|
||||||
): Promise<MegaFileInfo | null> {
|
): Promise<MegaFileInfo | null> {
|
||||||
const parsed = parseMegaUrl(url);
|
const parsed = parseMegaUrl(url);
|
||||||
if (!parsed) return null;
|
if (!parsed) return null;
|
||||||
const aesKey = parsed.rawKey.subarray(0, 16);
|
const aesKey = parsed.rawKey.subarray(0, 16);
|
||||||
|
|
||||||
const apiUrl = `${MEGA_API_BASE}?id=${Math.floor(Math.random() * 1e9)}`;
|
const apiUrl = `${MEGA_API_BASE}?id=${Math.floor(Math.random() * 1e9)}`;
|
||||||
const body = JSON.stringify([{ a: "g", g: 1, p: parsed.id }]);
|
const body = JSON.stringify([{ a: "g", g: 1, p: parsed.id }]);
|
||||||
|
|
||||||
let response: Response;
|
let response: Response;
|
||||||
try {
|
try {
|
||||||
response = await fetch(apiUrl, {
|
response = await fetch(apiUrl, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body,
|
body,
|
||||||
signal: withTimeoutSignal(signal, MEGA_API_TIMEOUT_MS)
|
signal: withTimeoutSignal(signal, MEGA_API_TIMEOUT_MS)
|
||||||
});
|
});
|
||||||
} catch {
|
} catch {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
if (!response.ok) return null;
|
if (!response.ok) return null;
|
||||||
|
|
||||||
let payload: unknown;
|
let payload: unknown;
|
||||||
try {
|
try {
|
||||||
payload = await response.json();
|
payload = await response.json();
|
||||||
} catch {
|
} catch {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (typeof payload === "number") return null;
|
if (typeof payload === "number") return null;
|
||||||
if (!Array.isArray(payload) || payload.length === 0) return null;
|
if (!Array.isArray(payload) || payload.length === 0) return null;
|
||||||
|
|
||||||
const first = payload[0];
|
const first = payload[0];
|
||||||
if (typeof first === "number") return null;
|
if (typeof first === "number") return null;
|
||||||
if (!first || typeof first !== "object") return null;
|
if (!first || typeof first !== "object") return null;
|
||||||
|
|
||||||
const info = first as { s?: unknown; at?: unknown; e?: unknown };
|
const info = first as { s?: unknown; at?: unknown; e?: unknown };
|
||||||
if (typeof info.e === "number" && info.e !== 0) return null;
|
if (typeof info.e === "number" && info.e !== 0) return null;
|
||||||
|
|
||||||
const size = typeof info.s === "number" && info.s > 0 ? info.s : 0;
|
const size = typeof info.s === "number" && info.s > 0 ? info.s : 0;
|
||||||
if (typeof info.at !== "string" || !info.at.trim()) return null;
|
if (typeof info.at !== "string" || !info.at.trim()) return null;
|
||||||
|
|
||||||
const encryptedAttrs = base64UrlDecode(info.at);
|
const encryptedAttrs = base64UrlDecode(info.at);
|
||||||
if (!encryptedAttrs) return null;
|
if (!encryptedAttrs) return null;
|
||||||
|
|
||||||
const attrs = decryptMegaAttributes(encryptedAttrs, aesKey);
|
const attrs = decryptMegaAttributes(encryptedAttrs, aesKey);
|
||||||
if (!attrs || typeof attrs.n !== "string" || !attrs.n.trim()) return null;
|
if (!attrs || typeof attrs.n !== "string" || !attrs.n.trim()) return null;
|
||||||
|
|
||||||
return { name: attrs.n.trim(), size };
|
return { name: attrs.n.trim(), size };
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,458 +1,458 @@
|
|||||||
import { UnrestrictedLink } from "./realdebrid";
|
import { UnrestrictedLink } from "./realdebrid";
|
||||||
import { compactErrorText, filenameFromUrl, sleep } from "./utils";
|
import { compactErrorText, filenameFromUrl, sleep } from "./utils";
|
||||||
import { traceConversionPhase } from "./conversion-trace";
|
import { traceConversionPhase } from "./conversion-trace";
|
||||||
|
|
||||||
type MegaCredentials = {
|
type MegaCredentials = {
|
||||||
login: string;
|
login: string;
|
||||||
password: string;
|
password: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
type CodeEntry = {
|
type CodeEntry = {
|
||||||
code: string;
|
code: string;
|
||||||
linkHint: string;
|
linkHint: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
const LOGIN_URL = "https://www.mega-debrid.eu/index.php?form=login";
|
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_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_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";
|
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;
|
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 {
|
function normalizeLink(link: string): string {
|
||||||
return link.trim().toLowerCase();
|
return link.trim().toLowerCase();
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseSetCookieFromHeaders(headers: Headers): string {
|
function parseSetCookieFromHeaders(headers: Headers): string {
|
||||||
const getSetCookie = (headers as unknown as { getSetCookie?: () => string[] }).getSetCookie;
|
const getSetCookie = (headers as unknown as { getSetCookie?: () => string[] }).getSetCookie;
|
||||||
if (typeof getSetCookie === "function") {
|
if (typeof getSetCookie === "function") {
|
||||||
const values = getSetCookie.call(headers)
|
const values = getSetCookie.call(headers)
|
||||||
.map((entry) => entry.split(";")[0].trim())
|
.map((entry) => entry.split(";")[0].trim())
|
||||||
.filter(Boolean);
|
.filter(Boolean);
|
||||||
if (values.length > 0) {
|
if (values.length > 0) {
|
||||||
return values.join("; ");
|
return values.join("; ");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const raw = headers.get("set-cookie") || "";
|
const raw = headers.get("set-cookie") || "";
|
||||||
if (!raw) {
|
if (!raw) {
|
||||||
return "";
|
return "";
|
||||||
}
|
}
|
||||||
return raw
|
return raw
|
||||||
.split(/,(?=[^;=]+?=)/g)
|
.split(/,(?=[^;=]+?=)/g)
|
||||||
.map((chunk) => chunk.split(";")[0].trim())
|
.map((chunk) => chunk.split(";")[0].trim())
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.join("; ");
|
.join("; ");
|
||||||
}
|
}
|
||||||
|
|
||||||
const PERMANENT_HOSTER_ERRORS = [
|
const PERMANENT_HOSTER_ERRORS = [
|
||||||
"hosternotavailable",
|
"hosternotavailable",
|
||||||
"filenotfound",
|
"filenotfound",
|
||||||
"file_unavailable",
|
"file_unavailable",
|
||||||
"file not found",
|
"file not found",
|
||||||
"link is dead",
|
"link is dead",
|
||||||
"file has been removed",
|
"file has been removed",
|
||||||
"file has been deleted",
|
"file has been deleted",
|
||||||
"file was deleted",
|
"file was deleted",
|
||||||
"file was removed",
|
"file was removed",
|
||||||
"not available",
|
"not available",
|
||||||
"file is no longer available"
|
"file is no longer available"
|
||||||
];
|
];
|
||||||
|
|
||||||
function parsePageErrors(html: string): string[] {
|
function parsePageErrors(html: string): string[] {
|
||||||
const errors: string[] = [];
|
const errors: string[] = [];
|
||||||
const errorRegex = /class=["'][^"']*\berror\b[^"']*["'][^>]*>([^<]+)</gi;
|
const errorRegex = /class=["'][^"']*\berror\b[^"']*["'][^>]*>([^<]+)</gi;
|
||||||
let m: RegExpExecArray | null;
|
let m: RegExpExecArray | null;
|
||||||
while ((m = errorRegex.exec(html)) !== null) {
|
while ((m = errorRegex.exec(html)) !== null) {
|
||||||
const text = m[1].replace(/^Fehler:\s*/i, "").trim();
|
const text = m[1].replace(/^Fehler:\s*/i, "").trim();
|
||||||
if (text) {
|
if (text) {
|
||||||
errors.push(text);
|
errors.push(text);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return errors;
|
return errors;
|
||||||
}
|
}
|
||||||
|
|
||||||
function isPermanentHosterError(errors: string[]): string | null {
|
function isPermanentHosterError(errors: string[]): string | null {
|
||||||
for (const err of errors) {
|
for (const err of errors) {
|
||||||
const lower = err.toLowerCase();
|
const lower = err.toLowerCase();
|
||||||
for (const pattern of PERMANENT_HOSTER_ERRORS) {
|
for (const pattern of PERMANENT_HOSTER_ERRORS) {
|
||||||
if (lower.includes(pattern)) {
|
if (lower.includes(pattern)) {
|
||||||
return err;
|
return err;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseCodes(html: string): CodeEntry[] {
|
function parseCodes(html: string): CodeEntry[] {
|
||||||
const entries: CodeEntry[] = [];
|
const entries: CodeEntry[] = [];
|
||||||
const cardRegex = /<div[^>]*class=['"][^'"]*acp-box[^'"]*['"][^>]*>[\s\S]*?<\/div>/gi;
|
const cardRegex = /<div[^>]*class=['"][^'"]*acp-box[^'"]*['"][^>]*>[\s\S]*?<\/div>/gi;
|
||||||
let cardMatch: RegExpExecArray | null;
|
let cardMatch: RegExpExecArray | null;
|
||||||
while ((cardMatch = cardRegex.exec(html)) !== null) {
|
while ((cardMatch = cardRegex.exec(html)) !== null) {
|
||||||
const block = cardMatch[0];
|
const block = cardMatch[0];
|
||||||
const linkTitle = (block.match(/<h3>\s*Link:\s*([^<]+)<\/h3>/i)?.[1] || "").trim();
|
const linkTitle = (block.match(/<h3>\s*Link:\s*([^<]+)<\/h3>/i)?.[1] || "").trim();
|
||||||
const code = block.match(/processDebrid\(\d+,'([^']+)',0\)/i)?.[1] || "";
|
const code = block.match(/processDebrid\(\d+,'([^']+)',0\)/i)?.[1] || "";
|
||||||
if (!code) {
|
if (!code) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
entries.push({ code, linkHint: normalizeLink(linkTitle) });
|
entries.push({ code, linkHint: normalizeLink(linkTitle) });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (entries.length === 0) {
|
if (entries.length === 0) {
|
||||||
const fallbackRegex = /processDebrid\(\d+,'([^']+)',0\)/gi;
|
const fallbackRegex = /processDebrid\(\d+,'([^']+)',0\)/gi;
|
||||||
let m: RegExpExecArray | null;
|
let m: RegExpExecArray | null;
|
||||||
while ((m = fallbackRegex.exec(html)) !== null) {
|
while ((m = fallbackRegex.exec(html)) !== null) {
|
||||||
entries.push({ code: m[1], linkHint: "" });
|
entries.push({ code: m[1], linkHint: "" });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return entries;
|
return entries;
|
||||||
}
|
}
|
||||||
|
|
||||||
function pickCode(entries: CodeEntry[], link: string): string {
|
function pickCode(entries: CodeEntry[], link: string): string {
|
||||||
if (entries.length === 0) {
|
if (entries.length === 0) {
|
||||||
return "";
|
return "";
|
||||||
}
|
}
|
||||||
const target = normalizeLink(link);
|
const target = normalizeLink(link);
|
||||||
const match = entries.find((entry) => entry.linkHint && entry.linkHint.includes(target));
|
const match = entries.find((entry) => entry.linkHint && entry.linkHint.includes(target));
|
||||||
return (match?.code || entries[0].code || "").trim();
|
return (match?.code || entries[0].code || "").trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseDebridJson(text: string): { link: string; text: string } | null {
|
function parseDebridJson(text: string): { link: string; text: string } | null {
|
||||||
try {
|
try {
|
||||||
const parsed = JSON.parse(text) as { link?: string; text?: string };
|
const parsed = JSON.parse(text) as { link?: string; text?: string };
|
||||||
return {
|
return {
|
||||||
link: String(parsed.link || ""),
|
link: String(parsed.link || ""),
|
||||||
text: String(parsed.text || "")
|
text: String(parsed.text || "")
|
||||||
};
|
};
|
||||||
} catch {
|
} catch {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function abortError(): Error {
|
function abortError(): Error {
|
||||||
return new Error("aborted:mega-web");
|
return new Error("aborted:mega-web");
|
||||||
}
|
}
|
||||||
|
|
||||||
function withTimeoutSignal(signal: AbortSignal | undefined, timeoutMs: number): AbortSignal {
|
function withTimeoutSignal(signal: AbortSignal | undefined, timeoutMs: number): AbortSignal {
|
||||||
const timeoutSignal = AbortSignal.timeout(timeoutMs);
|
const timeoutSignal = AbortSignal.timeout(timeoutMs);
|
||||||
if (!signal) {
|
if (!signal) {
|
||||||
return timeoutSignal;
|
return timeoutSignal;
|
||||||
}
|
}
|
||||||
return AbortSignal.any([signal, timeoutSignal]);
|
return AbortSignal.any([signal, timeoutSignal]);
|
||||||
}
|
}
|
||||||
|
|
||||||
function throwIfAborted(signal?: AbortSignal): void {
|
function throwIfAborted(signal?: AbortSignal): void {
|
||||||
if (signal?.aborted) {
|
if (signal?.aborted) {
|
||||||
throw abortError();
|
throw abortError();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function sleepWithSignal(ms: number, signal?: AbortSignal): Promise<void> {
|
async function sleepWithSignal(ms: number, signal?: AbortSignal): Promise<void> {
|
||||||
if (!signal) {
|
if (!signal) {
|
||||||
await sleep(ms);
|
await sleep(ms);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (signal.aborted) {
|
if (signal.aborted) {
|
||||||
throw abortError();
|
throw abortError();
|
||||||
}
|
}
|
||||||
|
|
||||||
await new Promise<void>((resolve, reject) => {
|
await new Promise<void>((resolve, reject) => {
|
||||||
let timer: NodeJS.Timeout | null = setTimeout(() => {
|
let timer: NodeJS.Timeout | null = setTimeout(() => {
|
||||||
timer = null;
|
timer = null;
|
||||||
signal.removeEventListener("abort", onAbort);
|
signal.removeEventListener("abort", onAbort);
|
||||||
resolve();
|
resolve();
|
||||||
}, Math.max(0, ms));
|
}, Math.max(0, ms));
|
||||||
|
|
||||||
const onAbort = (): void => {
|
const onAbort = (): void => {
|
||||||
if (timer) {
|
if (timer) {
|
||||||
clearTimeout(timer);
|
clearTimeout(timer);
|
||||||
timer = null;
|
timer = null;
|
||||||
}
|
}
|
||||||
signal.removeEventListener("abort", onAbort);
|
signal.removeEventListener("abort", onAbort);
|
||||||
reject(abortError());
|
reject(abortError());
|
||||||
};
|
};
|
||||||
|
|
||||||
signal.addEventListener("abort", onAbort, { once: true });
|
signal.addEventListener("abort", onAbort, { once: true });
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function raceWithAbort<T>(promise: Promise<T>, signal?: AbortSignal, abortErrorFactory: () => Error = abortError): Promise<T> {
|
async function raceWithAbort<T>(promise: Promise<T>, signal?: AbortSignal, abortErrorFactory: () => Error = abortError): Promise<T> {
|
||||||
if (!signal) {
|
if (!signal) {
|
||||||
return promise;
|
return promise;
|
||||||
}
|
}
|
||||||
if (signal.aborted) {
|
if (signal.aborted) {
|
||||||
throw abortErrorFactory();
|
throw abortErrorFactory();
|
||||||
}
|
}
|
||||||
|
|
||||||
return new Promise<T>((resolve, reject) => {
|
return new Promise<T>((resolve, reject) => {
|
||||||
let settled = false;
|
let settled = false;
|
||||||
|
|
||||||
const onAbort = (): void => {
|
const onAbort = (): void => {
|
||||||
if (settled) {
|
if (settled) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
settled = true;
|
settled = true;
|
||||||
signal.removeEventListener("abort", onAbort);
|
signal.removeEventListener("abort", onAbort);
|
||||||
reject(abortErrorFactory());
|
reject(abortErrorFactory());
|
||||||
};
|
};
|
||||||
|
|
||||||
signal.addEventListener("abort", onAbort, { once: true });
|
signal.addEventListener("abort", onAbort, { once: true });
|
||||||
|
|
||||||
promise.then((value) => {
|
promise.then((value) => {
|
||||||
if (settled) {
|
if (settled) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
settled = true;
|
settled = true;
|
||||||
signal.removeEventListener("abort", onAbort);
|
signal.removeEventListener("abort", onAbort);
|
||||||
resolve(value);
|
resolve(value);
|
||||||
}, (error) => {
|
}, (error) => {
|
||||||
if (settled) {
|
if (settled) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
settled = true;
|
settled = true;
|
||||||
signal.removeEventListener("abort", onAbort);
|
signal.removeEventListener("abort", onAbort);
|
||||||
reject(error);
|
reject(error);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export class MegaWebFallback {
|
export class MegaWebFallback {
|
||||||
// Pro Account eine eigene Warteschlange: Umwandlungen auf DEMSELBEN Account laufen
|
// Pro Account eine eigene Warteschlange: Umwandlungen auf DEMSELBEN Account laufen
|
||||||
// seriell (kein Doppel-Login, kein Hammern eines einzelnen Accounts), verschiedene
|
// seriell (kein Doppel-Login, kein Hammern eines einzelnen Accounts), verschiedene
|
||||||
// Accounts laufen parallel. So koennen die Links eines Pakets ueber mehrere Accounts
|
// Accounts laufen parallel. So koennen die Links eines Pakets ueber mehrere Accounts
|
||||||
// gleichzeitig umgewandelt werden statt global eine nach der anderen.
|
// gleichzeitig umgewandelt werden statt global eine nach der anderen.
|
||||||
private queues = new Map<string, Promise<unknown>>();
|
private queues = new Map<string, Promise<unknown>>();
|
||||||
|
|
||||||
private getCredentials: () => MegaCredentials;
|
private getCredentials: () => MegaCredentials;
|
||||||
|
|
||||||
private sessions = new Map<string, { cookie: string; setAt: number }>();
|
private sessions = new Map<string, { cookie: string; setAt: number }>();
|
||||||
|
|
||||||
public constructor(getCredentials: () => MegaCredentials) {
|
public constructor(getCredentials: () => MegaCredentials) {
|
||||||
this.getCredentials = getCredentials;
|
this.getCredentials = getCredentials;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async unrestrict(
|
public async unrestrict(
|
||||||
link: string,
|
link: string,
|
||||||
signal?: AbortSignal,
|
signal?: AbortSignal,
|
||||||
account?: { login: string; password: string }
|
account?: { login: string; password: string }
|
||||||
): Promise<UnrestrictedLink | null> {
|
): Promise<UnrestrictedLink | null> {
|
||||||
const overallSignal = withTimeoutSignal(signal, 180000);
|
const overallSignal = withTimeoutSignal(signal, 180000);
|
||||||
const creds = (account && account.login.trim() && account.password.trim())
|
const creds = (account && account.login.trim() && account.password.trim())
|
||||||
? account
|
? account
|
||||||
: this.getCredentials();
|
: this.getCredentials();
|
||||||
if (!creds.login.trim() || !creds.password.trim()) {
|
if (!creds.login.trim() || !creds.password.trim()) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
const key = creds.login.trim().toLowerCase();
|
const key = creds.login.trim().toLowerCase();
|
||||||
return this.runExclusive(async () => {
|
return this.runExclusive(async () => {
|
||||||
throwIfAborted(overallSignal);
|
throwIfAborted(overallSignal);
|
||||||
let cookie = await this.ensureSession(key, creds.login, creds.password, overallSignal);
|
let cookie = await this.ensureSession(key, creds.login, creds.password, overallSignal);
|
||||||
|
|
||||||
let generated = await this.generate(link, cookie, overallSignal);
|
let generated = await this.generate(link, cookie, overallSignal);
|
||||||
if (!generated) {
|
if (!generated) {
|
||||||
this.sessions.delete(key);
|
this.sessions.delete(key);
|
||||||
cookie = await this.ensureSession(key, creds.login, creds.password, overallSignal);
|
cookie = await this.ensureSession(key, creds.login, creds.password, overallSignal);
|
||||||
generated = await this.generate(link, cookie, overallSignal);
|
generated = await this.generate(link, cookie, overallSignal);
|
||||||
if (!generated) {
|
if (!generated) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
directUrl: generated.directUrl,
|
directUrl: generated.directUrl,
|
||||||
fileName: generated.fileName || filenameFromUrl(link),
|
fileName: generated.fileName || filenameFromUrl(link),
|
||||||
fileSize: null,
|
fileSize: null,
|
||||||
retriesUsed: 0
|
retriesUsed: 0
|
||||||
};
|
};
|
||||||
}, key, overallSignal);
|
}, key, overallSignal);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async ensureSession(key: string, login: string, password: string, signal?: AbortSignal): Promise<string> {
|
private async ensureSession(key: string, login: string, password: string, signal?: AbortSignal): Promise<string> {
|
||||||
const existing = this.sessions.get(key);
|
const existing = this.sessions.get(key);
|
||||||
if (existing && existing.cookie && Date.now() - existing.setAt <= 20 * 60 * 1000) {
|
if (existing && existing.cookie && Date.now() - existing.setAt <= 20 * 60 * 1000) {
|
||||||
return existing.cookie;
|
return existing.cookie;
|
||||||
}
|
}
|
||||||
const cookie = await this.login(login, password, signal);
|
const cookie = await this.login(login, password, signal);
|
||||||
this.sessions.set(key, { cookie, setAt: Date.now() });
|
this.sessions.set(key, { cookie, setAt: Date.now() });
|
||||||
return cookie;
|
return cookie;
|
||||||
}
|
}
|
||||||
|
|
||||||
public invalidateSession(): void {
|
public invalidateSession(): void {
|
||||||
this.sessions.clear();
|
this.sessions.clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
private async runExclusive<T>(job: () => Promise<T>, key: string, signal?: AbortSignal): Promise<T> {
|
private async runExclusive<T>(job: () => Promise<T>, key: string, signal?: AbortSignal): Promise<T> {
|
||||||
const queuedAt = Date.now();
|
const queuedAt = Date.now();
|
||||||
const QUEUE_WAIT_TIMEOUT_MS = 90000;
|
const QUEUE_WAIT_TIMEOUT_MS = 90000;
|
||||||
let workStarted = false;
|
let workStarted = false;
|
||||||
const guardedJob = async (): Promise<T> => {
|
const guardedJob = async (): Promise<T> => {
|
||||||
throwIfAborted(signal);
|
throwIfAborted(signal);
|
||||||
const waited = Date.now() - queuedAt;
|
const waited = Date.now() - queuedAt;
|
||||||
if (waited > QUEUE_WAIT_TIMEOUT_MS) {
|
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` });
|
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)`);
|
throw new Error(`Mega-Web Queue-Timeout (${Math.floor(waited / 1000)}s gewartet)`);
|
||||||
}
|
}
|
||||||
workStarted = true;
|
workStarted = true;
|
||||||
const workStartedAt = Date.now();
|
const workStartedAt = Date.now();
|
||||||
try {
|
try {
|
||||||
const result = await job();
|
const result = await job();
|
||||||
traceConversionPhase({ phase: "web-queue", provider: "megadebrid-web", queueWaitMs: waited, workMs: Date.now() - workStartedAt, outcome: "ok" });
|
traceConversionPhase({ phase: "web-queue", provider: "megadebrid-web", queueWaitMs: waited, workMs: Date.now() - workStartedAt, outcome: "ok" });
|
||||||
return result;
|
return result;
|
||||||
} catch (jobError) {
|
} catch (jobError) {
|
||||||
traceConversionPhase({ phase: "web-queue", provider: "megadebrid-web", queueWaitMs: waited, workMs: Date.now() - workStartedAt, outcome: "error", detail: compactErrorText(jobError).slice(0, 100) });
|
traceConversionPhase({ phase: "web-queue", provider: "megadebrid-web", queueWaitMs: waited, workMs: Date.now() - workStartedAt, outcome: "error", detail: compactErrorText(jobError).slice(0, 100) });
|
||||||
throw jobError;
|
throw jobError;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
const prev = this.queues.get(key) ?? Promise.resolve();
|
const prev = this.queues.get(key) ?? Promise.resolve();
|
||||||
const run = prev.then(guardedJob, guardedJob);
|
const run = prev.then(guardedJob, guardedJob);
|
||||||
this.queues.set(key, run.then(() => undefined, () => undefined));
|
this.queues.set(key, run.then(() => undefined, () => undefined));
|
||||||
return raceWithAbort(run, signal, () =>
|
return raceWithAbort(run, signal, () =>
|
||||||
workStarted
|
workStarted
|
||||||
? abortError()
|
? abortError()
|
||||||
: new Error(`Mega-Web Queue-Timeout (abgebrochen nach ${Math.floor((Date.now() - queuedAt) / 1000)}s Wartezeit, Account war belegt)`)
|
: 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> {
|
private async login(login: string, password: string, signal?: AbortSignal): Promise<string> {
|
||||||
throwIfAborted(signal);
|
throwIfAborted(signal);
|
||||||
const response = await fetch(LOGIN_URL, {
|
const response = await fetch(LOGIN_URL, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: {
|
headers: {
|
||||||
"Content-Type": "application/x-www-form-urlencoded",
|
"Content-Type": "application/x-www-form-urlencoded",
|
||||||
"User-Agent": "Mozilla/5.0"
|
"User-Agent": "Mozilla/5.0"
|
||||||
},
|
},
|
||||||
body: new URLSearchParams({
|
body: new URLSearchParams({
|
||||||
login,
|
login,
|
||||||
password,
|
password,
|
||||||
remember: "on"
|
remember: "on"
|
||||||
}),
|
}),
|
||||||
redirect: "manual",
|
redirect: "manual",
|
||||||
signal: withTimeoutSignal(signal, 30000)
|
signal: withTimeoutSignal(signal, 30000)
|
||||||
});
|
});
|
||||||
|
|
||||||
const cookie = parseSetCookieFromHeaders(response.headers);
|
const cookie = parseSetCookieFromHeaders(response.headers);
|
||||||
if (!cookie) {
|
if (!cookie) {
|
||||||
throw new Error("Mega-Web Login liefert kein Session-Cookie");
|
throw new Error("Mega-Web Login liefert kein Session-Cookie");
|
||||||
}
|
}
|
||||||
|
|
||||||
const verify = await fetch(DEBRID_REFERER, {
|
const verify = await fetch(DEBRID_REFERER, {
|
||||||
method: "GET",
|
method: "GET",
|
||||||
headers: {
|
headers: {
|
||||||
"User-Agent": "Mozilla/5.0",
|
"User-Agent": "Mozilla/5.0",
|
||||||
Cookie: cookie,
|
Cookie: cookie,
|
||||||
Referer: DEBRID_REFERER
|
Referer: DEBRID_REFERER
|
||||||
},
|
},
|
||||||
signal: withTimeoutSignal(signal, 30000)
|
signal: withTimeoutSignal(signal, 30000)
|
||||||
});
|
});
|
||||||
const verifyHtml = await verify.text();
|
const verifyHtml = await verify.text();
|
||||||
const hasDebridForm = /id=["']debridForm["']/i.test(verifyHtml) || /name=["']links["']/i.test(verifyHtml);
|
const hasDebridForm = /id=["']debridForm["']/i.test(verifyHtml) || /name=["']links["']/i.test(verifyHtml);
|
||||||
if (!hasDebridForm) {
|
if (!hasDebridForm) {
|
||||||
throw new Error("Mega-Web Login ungültig oder Session blockiert");
|
throw new Error("Mega-Web Login ungültig oder Session blockiert");
|
||||||
}
|
}
|
||||||
|
|
||||||
return cookie;
|
return cookie;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async generate(link: string, cookie: string, signal?: AbortSignal): Promise<{ directUrl: string; fileName: string } | null> {
|
private async generate(link: string, cookie: string, signal?: AbortSignal): Promise<{ directUrl: string; fileName: string } | null> {
|
||||||
throwIfAborted(signal);
|
throwIfAborted(signal);
|
||||||
const page = await fetch(DEBRID_URL, {
|
const page = await fetch(DEBRID_URL, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: {
|
headers: {
|
||||||
"Content-Type": "application/x-www-form-urlencoded",
|
"Content-Type": "application/x-www-form-urlencoded",
|
||||||
"User-Agent": "Mozilla/5.0",
|
"User-Agent": "Mozilla/5.0",
|
||||||
Cookie: cookie,
|
Cookie: cookie,
|
||||||
Referer: DEBRID_REFERER
|
Referer: DEBRID_REFERER
|
||||||
},
|
},
|
||||||
body: new URLSearchParams({
|
body: new URLSearchParams({
|
||||||
links: link,
|
links: link,
|
||||||
password: "",
|
password: "",
|
||||||
showLinks: "1"
|
showLinks: "1"
|
||||||
}),
|
}),
|
||||||
signal: withTimeoutSignal(signal, 30000)
|
signal: withTimeoutSignal(signal, 30000)
|
||||||
});
|
});
|
||||||
|
|
||||||
const html = await page.text();
|
const html = await page.text();
|
||||||
|
|
||||||
const pageErrors = parsePageErrors(html);
|
const pageErrors = parsePageErrors(html);
|
||||||
const permanentError = isPermanentHosterError(pageErrors);
|
const permanentError = isPermanentHosterError(pageErrors);
|
||||||
if (permanentError) {
|
if (permanentError) {
|
||||||
throw new Error(`Mega-Web: Link permanent ungültig (${permanentError})`);
|
throw new Error(`Mega-Web: Link permanent ungültig (${permanentError})`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const noServerError = pageErrors.find((err) => MEGA_DEBRID_NO_SERVER_RE.test(err));
|
const noServerError = pageErrors.find((err) => MEGA_DEBRID_NO_SERVER_RE.test(err));
|
||||||
if (noServerError) {
|
if (noServerError) {
|
||||||
throw new Error(`Mega-Web: ${noServerError}`);
|
throw new Error(`Mega-Web: ${noServerError}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const code = pickCode(parseCodes(html), link);
|
const code = pickCode(parseCodes(html), link);
|
||||||
if (!code) {
|
if (!code) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
for (let attempt = 1; attempt <= 60; attempt += 1) {
|
for (let attempt = 1; attempt <= 60; attempt += 1) {
|
||||||
throwIfAborted(signal);
|
throwIfAborted(signal);
|
||||||
const res = await fetch(DEBRID_AJAX_URL, {
|
const res = await fetch(DEBRID_AJAX_URL, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: {
|
headers: {
|
||||||
"Content-Type": "application/x-www-form-urlencoded",
|
"Content-Type": "application/x-www-form-urlencoded",
|
||||||
"User-Agent": "Mozilla/5.0",
|
"User-Agent": "Mozilla/5.0",
|
||||||
Cookie: cookie,
|
Cookie: cookie,
|
||||||
Referer: DEBRID_REFERER
|
Referer: DEBRID_REFERER
|
||||||
},
|
},
|
||||||
body: new URLSearchParams({
|
body: new URLSearchParams({
|
||||||
code,
|
code,
|
||||||
autodl: "0"
|
autodl: "0"
|
||||||
}),
|
}),
|
||||||
signal: withTimeoutSignal(signal, 15000)
|
signal: withTimeoutSignal(signal, 15000)
|
||||||
});
|
});
|
||||||
|
|
||||||
const text = (await res.text()).trim();
|
const text = (await res.text()).trim();
|
||||||
if (text === "reload") {
|
if (text === "reload") {
|
||||||
await sleepWithSignal(650, signal);
|
await sleepWithSignal(650, signal);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (text === "false") {
|
if (text === "false") {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const parsed = parseDebridJson(text);
|
const parsed = parseDebridJson(text);
|
||||||
if (!parsed) {
|
if (!parsed) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!parsed.link) {
|
if (!parsed.link) {
|
||||||
if (/hoster does not respond correctly|could not be done for this moment/i.test(parsed.text || "")) {
|
if (/hoster does not respond correctly|could not be done for this moment/i.test(parsed.text || "")) {
|
||||||
await sleepWithSignal(1200, signal);
|
await sleepWithSignal(1200, signal);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const serverMsg = (parsed.text || "").replace(/<[^>]*>/g, " ").replace(/\s+/g, " ").trim();
|
const serverMsg = (parsed.text || "").replace(/<[^>]*>/g, " ").replace(/\s+/g, " ").trim();
|
||||||
if (serverMsg && MEGA_DEBRID_NO_SERVER_RE.test(serverMsg)) {
|
if (serverMsg && MEGA_DEBRID_NO_SERVER_RE.test(serverMsg)) {
|
||||||
throw new Error(`Mega-Web: ${serverMsg}`);
|
throw new Error(`Mega-Web: ${serverMsg}`);
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const fromText = parsed.text
|
const fromText = parsed.text
|
||||||
.replace(/<[^>]*>/g, " ")
|
.replace(/<[^>]*>/g, " ")
|
||||||
.replace(/\s+/g, " ")
|
.replace(/\s+/g, " ")
|
||||||
.trim();
|
.trim();
|
||||||
|
|
||||||
const nameMatch = fromText.match(/([\w .\-\[\]\(\)]+\.(?:rar|r\d{2}|zip|7z|mkv|mp4|avi|mp3|flac))/i);
|
const nameMatch = fromText.match(/([\w .\-\[\]\(\)]+\.(?:rar|r\d{2}|zip|7z|mkv|mp4|avi|mp3|flac))/i);
|
||||||
const fileName = (nameMatch?.[1] || filenameFromUrl(link)).trim();
|
const fileName = (nameMatch?.[1] || filenameFromUrl(link)).trim();
|
||||||
return {
|
return {
|
||||||
directUrl: parsed.link,
|
directUrl: parsed.link,
|
||||||
fileName
|
fileName
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
public dispose(): void {
|
public dispose(): void {
|
||||||
this.sessions.clear();
|
this.sessions.clear();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function compactMegaWebError(error: unknown): string {
|
export function compactMegaWebError(error: unknown): string {
|
||||||
return compactErrorText(error);
|
return compactErrorText(error);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,152 +1,152 @@
|
|||||||
import { logger } from "./logger";
|
import { logger } from "./logger";
|
||||||
|
|
||||||
export interface NotifyPayload {
|
export interface NotifyPayload {
|
||||||
title: string;
|
title: string;
|
||||||
message: string;
|
message: string;
|
||||||
mention?: string;
|
mention?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const NOTIFY_TIMEOUT_MS = 5000;
|
const NOTIFY_TIMEOUT_MS = 5000;
|
||||||
const WEBHOOK_USERNAME = "Real-Debrid Downloader";
|
const WEBHOOK_USERNAME = "Real-Debrid Downloader";
|
||||||
const MIN_SEND_GAP_MS = 450;
|
const MIN_SEND_GAP_MS = 450;
|
||||||
const RETRY_DELAYS_MS = [1000, 2500];
|
const RETRY_DELAYS_MS = [1000, 2500];
|
||||||
const RATE_LIMIT_MAX_WAIT_MS = 15_000;
|
const RATE_LIMIT_MAX_WAIT_MS = 15_000;
|
||||||
const CONTENT_MAX_CHARS = 2000;
|
const CONTENT_MAX_CHARS = 2000;
|
||||||
|
|
||||||
export function isNotifyUrlValid(url: string): boolean {
|
export function isNotifyUrlValid(url: string): boolean {
|
||||||
return /^https?:\/\/\S+$/i.test(String(url || "").trim());
|
return /^https?:\/\/\S+$/i.test(String(url || "").trim());
|
||||||
}
|
}
|
||||||
|
|
||||||
// Accepts a bare Discord user ID (wrapped as <@id> so it actually pings),
|
// Accepts a bare Discord user ID (wrapped as <@id> so it actually pings),
|
||||||
// @everyone/@here, or an already-formed <@...>/<@&...> mention as-is.
|
// @everyone/@here, or an already-formed <@...>/<@&...> mention as-is.
|
||||||
export function normalizeDiscordMention(raw: string): string {
|
export function normalizeDiscordMention(raw: string): string {
|
||||||
const text = String(raw || "").trim();
|
const text = String(raw || "").trim();
|
||||||
if (!text) {
|
if (!text) {
|
||||||
return "";
|
return "";
|
||||||
}
|
}
|
||||||
if (/^\d{5,}$/.test(text)) {
|
if (/^\d{5,}$/.test(text)) {
|
||||||
return `<@${text}>`;
|
return `<@${text}>`;
|
||||||
}
|
}
|
||||||
return text;
|
return text;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Discord counts the limit itself; slicing UTF-16 units can split a surrogate
|
// Discord counts the limit itself; slicing UTF-16 units can split a surrogate
|
||||||
// pair at the boundary, which Discord rejects as invalid content.
|
// pair at the boundary, which Discord rejects as invalid content.
|
||||||
export function truncateContent(content: string, maxChars = CONTENT_MAX_CHARS): string {
|
export function truncateContent(content: string, maxChars = CONTENT_MAX_CHARS): string {
|
||||||
if (content.length <= maxChars) {
|
if (content.length <= maxChars) {
|
||||||
return content;
|
return content;
|
||||||
}
|
}
|
||||||
let cut = content.slice(0, maxChars);
|
let cut = content.slice(0, maxChars);
|
||||||
const last = cut.charCodeAt(cut.length - 1);
|
const last = cut.charCodeAt(cut.length - 1);
|
||||||
if (last >= 0xd800 && last <= 0xdbff) {
|
if (last >= 0xd800 && last <= 0xdbff) {
|
||||||
cut = cut.slice(0, -1);
|
cut = cut.slice(0, -1);
|
||||||
}
|
}
|
||||||
return cut;
|
return cut;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function buildNotifyRequest(url: string, payload: NotifyPayload): { url: string; init: RequestInit } {
|
export function buildNotifyRequest(url: string, payload: NotifyPayload): { url: string; init: RequestInit } {
|
||||||
const mention = normalizeDiscordMention(payload.mention || "");
|
const mention = normalizeDiscordMention(payload.mention || "");
|
||||||
const content = truncateContent(`${mention ? `${mention} ` : ""}**${payload.title}**\n${payload.message}`);
|
const content = truncateContent(`${mention ? `${mention} ` : ""}**${payload.title}**\n${payload.message}`);
|
||||||
return {
|
return {
|
||||||
url: String(url || "").trim(),
|
url: String(url || "").trim(),
|
||||||
init: {
|
init: {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({ username: WEBHOOK_USERNAME, content })
|
body: JSON.stringify({ username: WEBHOOK_USERNAME, content })
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function delayMs(ms: number): Promise<void> {
|
function delayMs(ms: number): Promise<void> {
|
||||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
}
|
}
|
||||||
|
|
||||||
async function consumeBody(response: Response): Promise<string> {
|
async function consumeBody(response: Response): Promise<string> {
|
||||||
try {
|
try {
|
||||||
return await response.text();
|
return await response.text();
|
||||||
} catch {
|
} catch {
|
||||||
return "";
|
return "";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseRetryAfterMs(response: Response, bodyText: string): number {
|
function parseRetryAfterMs(response: Response, bodyText: string): number {
|
||||||
const headerSeconds = Number(response.headers.get("X-RateLimit-Reset-After") || response.headers.get("Retry-After") || "");
|
const headerSeconds = Number(response.headers.get("X-RateLimit-Reset-After") || response.headers.get("Retry-After") || "");
|
||||||
if (Number.isFinite(headerSeconds) && headerSeconds > 0) {
|
if (Number.isFinite(headerSeconds) && headerSeconds > 0) {
|
||||||
return Math.ceil(headerSeconds * 1000);
|
return Math.ceil(headerSeconds * 1000);
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const parsed = JSON.parse(bodyText) as { retry_after?: number };
|
const parsed = JSON.parse(bodyText) as { retry_after?: number };
|
||||||
if (typeof parsed.retry_after === "number" && parsed.retry_after > 0) {
|
if (typeof parsed.retry_after === "number" && parsed.retry_after > 0) {
|
||||||
return Math.ceil(parsed.retry_after * 1000);
|
return Math.ceil(parsed.retry_after * 1000);
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
}
|
}
|
||||||
return 1500;
|
return 1500;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function sendOnce(url: string, payload: NotifyPayload, fetchFn: typeof fetch): Promise<{ ok: boolean; retryable: boolean; waitMs: number; detail: string }> {
|
async function sendOnce(url: string, payload: NotifyPayload, fetchFn: typeof fetch): Promise<{ ok: boolean; retryable: boolean; waitMs: number; detail: string }> {
|
||||||
try {
|
try {
|
||||||
const request = buildNotifyRequest(url, payload);
|
const request = buildNotifyRequest(url, payload);
|
||||||
const response = await fetchFn(request.url, { ...request.init, signal: AbortSignal.timeout(NOTIFY_TIMEOUT_MS) });
|
const response = await fetchFn(request.url, { ...request.init, signal: AbortSignal.timeout(NOTIFY_TIMEOUT_MS) });
|
||||||
const bodyText = await consumeBody(response);
|
const bodyText = await consumeBody(response);
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
return { ok: true, retryable: false, waitMs: 0, detail: "" };
|
return { ok: true, retryable: false, waitMs: 0, detail: "" };
|
||||||
}
|
}
|
||||||
if (response.status === 429) {
|
if (response.status === 429) {
|
||||||
const waitMs = Math.min(RATE_LIMIT_MAX_WAIT_MS, parseRetryAfterMs(response, bodyText));
|
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)` };
|
return { ok: false, retryable: true, waitMs, detail: `HTTP 429 (Rate-Limit, warte ${waitMs}ms)` };
|
||||||
}
|
}
|
||||||
if (response.status >= 500) {
|
if (response.status >= 500) {
|
||||||
return { ok: false, retryable: true, waitMs: 0, detail: `HTTP ${response.status}` };
|
return { ok: false, retryable: true, waitMs: 0, detail: `HTTP ${response.status}` };
|
||||||
}
|
}
|
||||||
return { ok: false, retryable: false, waitMs: 0, detail: `HTTP ${response.status}` };
|
return { ok: false, retryable: false, waitMs: 0, detail: `HTTP ${response.status}` };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return { ok: false, retryable: true, waitMs: 0, detail: String(error) };
|
return { ok: false, retryable: true, waitMs: 0, detail: String(error) };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// All sends share one chain: serialized with a minimum gap so burst completions
|
// 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
|
// (many packages finishing together) stay under Discord's 5-per-2s webhook
|
||||||
// bucket instead of getting dropped as 429s.
|
// bucket instead of getting dropped as 429s.
|
||||||
let sendChain: Promise<void> = Promise.resolve();
|
let sendChain: Promise<void> = Promise.resolve();
|
||||||
let lastSendCompletedAt = 0;
|
let lastSendCompletedAt = 0;
|
||||||
|
|
||||||
export async function sendNotification(
|
export async function sendNotification(
|
||||||
url: string,
|
url: string,
|
||||||
payload: NotifyPayload,
|
payload: NotifyPayload,
|
||||||
fetchFn: typeof fetch = fetch,
|
fetchFn: typeof fetch = fetch,
|
||||||
sleepFn: (ms: number) => Promise<void> = delayMs
|
sleepFn: (ms: number) => Promise<void> = delayMs
|
||||||
): Promise<boolean> {
|
): Promise<boolean> {
|
||||||
if (!isNotifyUrlValid(url)) {
|
if (!isNotifyUrlValid(url)) {
|
||||||
if (String(url || "").trim()) {
|
if (String(url || "").trim()) {
|
||||||
logger.warn(`Benachrichtigung nicht gesendet: ungueltige Webhook-URL (muss mit http(s):// beginnen): ${payload.title}`);
|
logger.warn(`Benachrichtigung nicht gesendet: ungueltige Webhook-URL (muss mit http(s):// beginnen): ${payload.title}`);
|
||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
const result = sendChain.then(async () => {
|
const result = sendChain.then(async () => {
|
||||||
const sinceLast = Date.now() - lastSendCompletedAt;
|
const sinceLast = Date.now() - lastSendCompletedAt;
|
||||||
if (sinceLast < MIN_SEND_GAP_MS) {
|
if (sinceLast < MIN_SEND_GAP_MS) {
|
||||||
await sleepFn(MIN_SEND_GAP_MS - sinceLast);
|
await sleepFn(MIN_SEND_GAP_MS - sinceLast);
|
||||||
}
|
}
|
||||||
let lastDetail = "";
|
let lastDetail = "";
|
||||||
for (let attempt = 0; ; attempt += 1) {
|
for (let attempt = 0; ; attempt += 1) {
|
||||||
const outcome = await sendOnce(url, payload, fetchFn);
|
const outcome = await sendOnce(url, payload, fetchFn);
|
||||||
if (outcome.ok) {
|
if (outcome.ok) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
lastDetail = outcome.detail;
|
lastDetail = outcome.detail;
|
||||||
if (!outcome.retryable || attempt >= RETRY_DELAYS_MS.length) {
|
if (!outcome.retryable || attempt >= RETRY_DELAYS_MS.length) {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
await sleepFn(outcome.waitMs > 0 ? outcome.waitMs : RETRY_DELAYS_MS[attempt]);
|
await sleepFn(outcome.waitMs > 0 ? outcome.waitMs : RETRY_DELAYS_MS[attempt]);
|
||||||
}
|
}
|
||||||
logger.warn(`Benachrichtigung fehlgeschlagen (${lastDetail}): ${payload.title}`);
|
logger.warn(`Benachrichtigung fehlgeschlagen (${lastDetail}): ${payload.title}`);
|
||||||
return false;
|
return false;
|
||||||
});
|
});
|
||||||
sendChain = result.then(() => {
|
sendChain = result.then(() => {
|
||||||
lastSendCompletedAt = Date.now();
|
lastSendCompletedAt = Date.now();
|
||||||
}, () => {
|
}, () => {
|
||||||
lastSendCompletedAt = Date.now();
|
lastSendCompletedAt = Date.now();
|
||||||
});
|
});
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,230 +1,230 @@
|
|||||||
import fs from "node:fs";
|
import fs from "node:fs";
|
||||||
import { logTimestamp } from "./log-timestamp";
|
import { logTimestamp } from "./log-timestamp";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import crypto from "node:crypto";
|
import crypto from "node:crypto";
|
||||||
|
|
||||||
const PACKAGE_LOG_FLUSH_INTERVAL_MS = 200;
|
const PACKAGE_LOG_FLUSH_INTERVAL_MS = 200;
|
||||||
const PACKAGE_LOG_RETENTION_DAYS = 30;
|
const PACKAGE_LOG_RETENTION_DAYS = 30;
|
||||||
|
|
||||||
type PackageLogLevel = "INFO" | "WARN" | "ERROR";
|
type PackageLogLevel = "INFO" | "WARN" | "ERROR";
|
||||||
|
|
||||||
export interface PackageLogMeta {
|
export interface PackageLogMeta {
|
||||||
packageId: string;
|
packageId: string;
|
||||||
name: string;
|
name: string;
|
||||||
outputDir: string;
|
outputDir: string;
|
||||||
extractDir: string;
|
extractDir: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
let packageLogsDir: string | null = null;
|
let packageLogsDir: string | null = null;
|
||||||
const knownLogPaths = new Map<string, string>();
|
const knownLogPaths = new Map<string, string>();
|
||||||
const pendingLinesByPackage = new Map<string, string[]>();
|
const pendingLinesByPackage = new Map<string, string[]>();
|
||||||
const initializedThisProcess = new Set<string>();
|
const initializedThisProcess = new Set<string>();
|
||||||
let flushTimer: NodeJS.Timeout | null = null;
|
let flushTimer: NodeJS.Timeout | null = null;
|
||||||
|
|
||||||
function normalizePackageId(packageId: string): string {
|
function normalizePackageId(packageId: string): string {
|
||||||
const trimmed = String(packageId || "").trim();
|
const trimmed = String(packageId || "").trim();
|
||||||
if (!trimmed) {
|
if (!trimmed) {
|
||||||
return "";
|
return "";
|
||||||
}
|
}
|
||||||
const safePrefix = trimmed
|
const safePrefix = trimmed
|
||||||
.replace(/[^a-zA-Z0-9._-]/g, "_")
|
.replace(/[^a-zA-Z0-9._-]/g, "_")
|
||||||
.replace(/_+/g, "_")
|
.replace(/_+/g, "_")
|
||||||
.slice(0, 64)
|
.slice(0, 64)
|
||||||
.replace(/^_+|_+$/g, "");
|
.replace(/^_+|_+$/g, "");
|
||||||
const hash = crypto.createHash("sha1").update(trimmed).digest("hex").slice(0, 12);
|
const hash = crypto.createHash("sha1").update(trimmed).digest("hex").slice(0, 12);
|
||||||
return `${safePrefix || "pkg"}_${hash}`;
|
return `${safePrefix || "pkg"}_${hash}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function sanitizeFieldValue(value: unknown): string {
|
function sanitizeFieldValue(value: unknown): string {
|
||||||
if (value === undefined || value === null) {
|
if (value === undefined || value === null) {
|
||||||
return "";
|
return "";
|
||||||
}
|
}
|
||||||
if (typeof value === "string") {
|
if (typeof value === "string") {
|
||||||
return value.replace(/\r?\n/g, "\\n");
|
return value.replace(/\r?\n/g, "\\n");
|
||||||
}
|
}
|
||||||
if (typeof value === "number" || typeof value === "boolean") {
|
if (typeof value === "number" || typeof value === "boolean") {
|
||||||
return String(value);
|
return String(value);
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
return JSON.stringify(value).replace(/\r?\n/g, "\\n");
|
return JSON.stringify(value).replace(/\r?\n/g, "\\n");
|
||||||
} catch {
|
} catch {
|
||||||
return String(value);
|
return String(value);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatFields(fields?: Record<string, unknown>): string {
|
function formatFields(fields?: Record<string, unknown>): string {
|
||||||
if (!fields) {
|
if (!fields) {
|
||||||
return "";
|
return "";
|
||||||
}
|
}
|
||||||
const parts = Object.entries(fields)
|
const parts = Object.entries(fields)
|
||||||
.filter(([, value]) => value !== undefined && value !== null && sanitizeFieldValue(value) !== "")
|
.filter(([, value]) => value !== undefined && value !== null && sanitizeFieldValue(value) !== "")
|
||||||
.map(([key, value]) => `${key}=${sanitizeFieldValue(value)}`);
|
.map(([key, value]) => `${key}=${sanitizeFieldValue(value)}`);
|
||||||
return parts.length > 0 ? ` | ${parts.join(" | ")}` : "";
|
return parts.length > 0 ? ` | ${parts.join(" | ")}` : "";
|
||||||
}
|
}
|
||||||
|
|
||||||
function getPackageLogFilePathFromNormalized(normalized: string): string | null {
|
function getPackageLogFilePathFromNormalized(normalized: string): string | null {
|
||||||
if (!normalized || !packageLogsDir) {
|
if (!normalized || !packageLogsDir) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
const existing = knownLogPaths.get(normalized);
|
const existing = knownLogPaths.get(normalized);
|
||||||
if (existing) {
|
if (existing) {
|
||||||
return existing;
|
return existing;
|
||||||
}
|
}
|
||||||
const logPath = path.join(packageLogsDir, `package_${normalized}.txt`);
|
const logPath = path.join(packageLogsDir, `package_${normalized}.txt`);
|
||||||
knownLogPaths.set(normalized, logPath);
|
knownLogPaths.set(normalized, logPath);
|
||||||
return logPath;
|
return logPath;
|
||||||
}
|
}
|
||||||
|
|
||||||
function getPackageLogFilePath(packageId: string): string | null {
|
function getPackageLogFilePath(packageId: string): string | null {
|
||||||
return getPackageLogFilePathFromNormalized(normalizePackageId(packageId));
|
return getPackageLogFilePathFromNormalized(normalizePackageId(packageId));
|
||||||
}
|
}
|
||||||
|
|
||||||
function flushPending(): void {
|
function flushPending(): void {
|
||||||
for (const [packageId, lines] of pendingLinesByPackage.entries()) {
|
for (const [packageId, lines] of pendingLinesByPackage.entries()) {
|
||||||
if (lines.length === 0) {
|
if (lines.length === 0) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const logPath = getPackageLogFilePathFromNormalized(packageId);
|
const logPath = getPackageLogFilePathFromNormalized(packageId);
|
||||||
if (!logPath) {
|
if (!logPath) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const chunk = lines.join("");
|
const chunk = lines.join("");
|
||||||
pendingLinesByPackage.set(packageId, []);
|
pendingLinesByPackage.set(packageId, []);
|
||||||
try {
|
try {
|
||||||
fs.appendFileSync(logPath, chunk, "utf8");
|
fs.appendFileSync(logPath, chunk, "utf8");
|
||||||
} catch {
|
} catch {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function scheduleFlush(): void {
|
function scheduleFlush(): void {
|
||||||
if (flushTimer) {
|
if (flushTimer) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
flushTimer = setTimeout(() => {
|
flushTimer = setTimeout(() => {
|
||||||
flushTimer = null;
|
flushTimer = null;
|
||||||
flushPending();
|
flushPending();
|
||||||
}, PACKAGE_LOG_FLUSH_INTERVAL_MS);
|
}, PACKAGE_LOG_FLUSH_INTERVAL_MS);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function cleanupOldPackageLogs(dir: string): Promise<void> {
|
async function cleanupOldPackageLogs(dir: string): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const files = await fs.promises.readdir(dir);
|
const files = await fs.promises.readdir(dir);
|
||||||
const cutoff = Date.now() - PACKAGE_LOG_RETENTION_DAYS * 24 * 60 * 60 * 1000;
|
const cutoff = Date.now() - PACKAGE_LOG_RETENTION_DAYS * 24 * 60 * 60 * 1000;
|
||||||
for (const file of files) {
|
for (const file of files) {
|
||||||
if (!file.startsWith("package_") || !file.endsWith(".txt")) {
|
if (!file.startsWith("package_") || !file.endsWith(".txt")) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const filePath = path.join(dir, file);
|
const filePath = path.join(dir, file);
|
||||||
try {
|
try {
|
||||||
const stat = await fs.promises.stat(filePath);
|
const stat = await fs.promises.stat(filePath);
|
||||||
if (stat.mtimeMs < cutoff) {
|
if (stat.mtimeMs < cutoff) {
|
||||||
await fs.promises.unlink(filePath);
|
await fs.promises.unlink(filePath);
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function appendLine(packageId: string, line: string): void {
|
function appendLine(packageId: string, line: string): void {
|
||||||
const normalized = normalizePackageId(packageId);
|
const normalized = normalizePackageId(packageId);
|
||||||
if (!normalized) {
|
if (!normalized) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const lines = pendingLinesByPackage.get(normalized) || [];
|
const lines = pendingLinesByPackage.get(normalized) || [];
|
||||||
lines.push(line);
|
lines.push(line);
|
||||||
pendingLinesByPackage.set(normalized, lines);
|
pendingLinesByPackage.set(normalized, lines);
|
||||||
scheduleFlush();
|
scheduleFlush();
|
||||||
}
|
}
|
||||||
|
|
||||||
export function initPackageLogs(baseDir: string): void {
|
export function initPackageLogs(baseDir: string): void {
|
||||||
packageLogsDir = path.join(baseDir, "package-logs");
|
packageLogsDir = path.join(baseDir, "package-logs");
|
||||||
try {
|
try {
|
||||||
fs.mkdirSync(packageLogsDir, { recursive: true });
|
fs.mkdirSync(packageLogsDir, { recursive: true });
|
||||||
} catch {
|
} catch {
|
||||||
packageLogsDir = null;
|
packageLogsDir = null;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
void cleanupOldPackageLogs(packageLogsDir);
|
void cleanupOldPackageLogs(packageLogsDir);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ensurePackageLog(meta: PackageLogMeta): string | null {
|
export function ensurePackageLog(meta: PackageLogMeta): string | null {
|
||||||
const normalizedPackageId = normalizePackageId(meta.packageId);
|
const normalizedPackageId = normalizePackageId(meta.packageId);
|
||||||
const logPath = getPackageLogFilePath(meta.packageId);
|
const logPath = getPackageLogFilePath(meta.packageId);
|
||||||
if (!logPath) {
|
if (!logPath) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
fs.mkdirSync(path.dirname(logPath), { recursive: true });
|
fs.mkdirSync(path.dirname(logPath), { recursive: true });
|
||||||
if (!fs.existsSync(logPath)) {
|
if (!fs.existsSync(logPath)) {
|
||||||
fs.writeFileSync(logPath, "", "utf8");
|
fs.writeFileSync(logPath, "", "utf8");
|
||||||
}
|
}
|
||||||
if (!initializedThisProcess.has(normalizedPackageId)) {
|
if (!initializedThisProcess.has(normalizedPackageId)) {
|
||||||
initializedThisProcess.add(normalizedPackageId);
|
initializedThisProcess.add(normalizedPackageId);
|
||||||
const startedAt = logTimestamp();
|
const startedAt = logTimestamp();
|
||||||
fs.appendFileSync(
|
fs.appendFileSync(
|
||||||
logPath,
|
logPath,
|
||||||
`=== Paket-Log Start: ${startedAt} | packageId=${sanitizeFieldValue(String(meta.packageId || ""))} | logKey=${normalizedPackageId} | name=${sanitizeFieldValue(meta.name)} ===\n`,
|
`=== Paket-Log Start: ${startedAt} | packageId=${sanitizeFieldValue(String(meta.packageId || ""))} | logKey=${normalizedPackageId} | name=${sanitizeFieldValue(meta.name)} ===\n`,
|
||||||
"utf8"
|
"utf8"
|
||||||
);
|
);
|
||||||
fs.appendFileSync(
|
fs.appendFileSync(
|
||||||
logPath,
|
logPath,
|
||||||
`${logTimestamp()} [INFO] Paket-Kontext initialisiert${formatFields({
|
`${logTimestamp()} [INFO] Paket-Kontext initialisiert${formatFields({
|
||||||
name: meta.name,
|
name: meta.name,
|
||||||
outputDir: meta.outputDir,
|
outputDir: meta.outputDir,
|
||||||
extractDir: meta.extractDir
|
extractDir: meta.extractDir
|
||||||
})}\n`,
|
})}\n`,
|
||||||
"utf8"
|
"utf8"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
return logPath;
|
return logPath;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function logPackageEvent(
|
export function logPackageEvent(
|
||||||
packageId: string,
|
packageId: string,
|
||||||
level: PackageLogLevel,
|
level: PackageLogLevel,
|
||||||
message: string,
|
message: string,
|
||||||
fields?: Record<string, unknown>
|
fields?: Record<string, unknown>
|
||||||
): void {
|
): void {
|
||||||
const logPath = getPackageLogFilePath(packageId);
|
const logPath = getPackageLogFilePath(packageId);
|
||||||
if (!logPath) {
|
if (!logPath) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const line = `${logTimestamp()} [${level}] ${message}${formatFields(fields)}\n`;
|
const line = `${logTimestamp()} [${level}] ${message}${formatFields(fields)}\n`;
|
||||||
appendLine(packageId, line);
|
appendLine(packageId, line);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getPackageLogPath(packageId: string): string | null {
|
export function getPackageLogPath(packageId: string): string | null {
|
||||||
const logPath = getPackageLogFilePath(packageId);
|
const logPath = getPackageLogFilePath(packageId);
|
||||||
if (!logPath) {
|
if (!logPath) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
return fs.existsSync(logPath) ? logPath : null;
|
return fs.existsSync(logPath) ? logPath : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function shutdownPackageLogs(): void {
|
export function shutdownPackageLogs(): void {
|
||||||
if (flushTimer) {
|
if (flushTimer) {
|
||||||
clearTimeout(flushTimer);
|
clearTimeout(flushTimer);
|
||||||
flushTimer = null;
|
flushTimer = null;
|
||||||
}
|
}
|
||||||
flushPending();
|
flushPending();
|
||||||
for (const packageId of knownLogPaths.keys()) {
|
for (const packageId of knownLogPaths.keys()) {
|
||||||
const logPath = getPackageLogFilePathFromNormalized(packageId);
|
const logPath = getPackageLogFilePathFromNormalized(packageId);
|
||||||
if (!logPath) {
|
if (!logPath) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
fs.appendFileSync(logPath, `=== Paket-Log Ende: ${logTimestamp()} ===\n`, "utf8");
|
fs.appendFileSync(logPath, `=== Paket-Log Ende: ${logTimestamp()} ===\n`, "utf8");
|
||||||
} catch {
|
} catch {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
pendingLinesByPackage.clear();
|
pendingLinesByPackage.clear();
|
||||||
knownLogPaths.clear();
|
knownLogPaths.clear();
|
||||||
initializedThisProcess.clear();
|
initializedThisProcess.clear();
|
||||||
packageLogsDir = null;
|
packageLogsDir = null;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,477 +1,477 @@
|
|||||||
import { BrowserWindow, session } from "electron";
|
import { BrowserWindow, session } from "electron";
|
||||||
import { UnrestrictedLink } from "./realdebrid";
|
import { UnrestrictedLink } from "./realdebrid";
|
||||||
import { filenameFromUrl, sleep } from "./utils";
|
import { filenameFromUrl, sleep } from "./utils";
|
||||||
import { API_BASE_URL, REQUEST_RETRIES } from "./constants";
|
import { API_BASE_URL, REQUEST_RETRIES } from "./constants";
|
||||||
|
|
||||||
const RD_BASE_URL = "https://real-debrid.com";
|
const RD_BASE_URL = "https://real-debrid.com";
|
||||||
const RD_LOGIN_URL = RD_BASE_URL;
|
const RD_LOGIN_URL = RD_BASE_URL;
|
||||||
const RD_APITOKEN_URL = `${RD_BASE_URL}/apitoken`;
|
const RD_APITOKEN_URL = `${RD_BASE_URL}/apitoken`;
|
||||||
const RD_UNRESTRICT_API = `${API_BASE_URL}/unrestrict/link`;
|
const RD_UNRESTRICT_API = `${API_BASE_URL}/unrestrict/link`;
|
||||||
const RD_PERSISTENT_PARTITION = "persist:realdebrid-web";
|
const RD_PERSISTENT_PARTITION = "persist:realdebrid-web";
|
||||||
const RD_TRANSIENT_PARTITION = "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";
|
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 =
|
type GenerateOutcome =
|
||||||
| { kind: "success"; value: UnrestrictedLink }
|
| { kind: "success"; value: UnrestrictedLink }
|
||||||
| { kind: "login_required" };
|
| { kind: "login_required" };
|
||||||
|
|
||||||
function abortError(): Error {
|
function abortError(): Error {
|
||||||
return new Error("aborted:realdebrid-web");
|
return new Error("aborted:realdebrid-web");
|
||||||
}
|
}
|
||||||
|
|
||||||
function withTimeoutSignal(signal: AbortSignal | undefined, timeoutMs: number): AbortSignal {
|
function withTimeoutSignal(signal: AbortSignal | undefined, timeoutMs: number): AbortSignal {
|
||||||
const timeoutSignal = AbortSignal.timeout(timeoutMs);
|
const timeoutSignal = AbortSignal.timeout(timeoutMs);
|
||||||
if (!signal) {
|
if (!signal) {
|
||||||
return timeoutSignal;
|
return timeoutSignal;
|
||||||
}
|
}
|
||||||
return AbortSignal.any([signal, timeoutSignal]);
|
return AbortSignal.any([signal, timeoutSignal]);
|
||||||
}
|
}
|
||||||
|
|
||||||
function throwIfAborted(signal?: AbortSignal): void {
|
function throwIfAborted(signal?: AbortSignal): void {
|
||||||
if (signal?.aborted) {
|
if (signal?.aborted) {
|
||||||
throw abortError();
|
throw abortError();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function sleepWithSignal(ms: number, signal?: AbortSignal): Promise<void> {
|
async function sleepWithSignal(ms: number, signal?: AbortSignal): Promise<void> {
|
||||||
if (!signal) {
|
if (!signal) {
|
||||||
await sleep(ms);
|
await sleep(ms);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (signal.aborted) {
|
if (signal.aborted) {
|
||||||
throw abortError();
|
throw abortError();
|
||||||
}
|
}
|
||||||
|
|
||||||
await new Promise<void>((resolve, reject) => {
|
await new Promise<void>((resolve, reject) => {
|
||||||
let timer: NodeJS.Timeout | null = setTimeout(() => {
|
let timer: NodeJS.Timeout | null = setTimeout(() => {
|
||||||
timer = null;
|
timer = null;
|
||||||
signal.removeEventListener("abort", onAbort);
|
signal.removeEventListener("abort", onAbort);
|
||||||
resolve();
|
resolve();
|
||||||
}, Math.max(0, ms));
|
}, Math.max(0, ms));
|
||||||
|
|
||||||
const onAbort = (): void => {
|
const onAbort = (): void => {
|
||||||
if (timer) {
|
if (timer) {
|
||||||
clearTimeout(timer);
|
clearTimeout(timer);
|
||||||
timer = null;
|
timer = null;
|
||||||
}
|
}
|
||||||
signal.removeEventListener("abort", onAbort);
|
signal.removeEventListener("abort", onAbort);
|
||||||
reject(abortError());
|
reject(abortError());
|
||||||
};
|
};
|
||||||
|
|
||||||
signal.addEventListener("abort", onAbort, { once: true });
|
signal.addEventListener("abort", onAbort, { once: true });
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseJson(text: string): Record<string, unknown> | null {
|
function parseJson(text: string): Record<string, unknown> | null {
|
||||||
try {
|
try {
|
||||||
const parsed = JSON.parse(text) as unknown;
|
const parsed = JSON.parse(text) as unknown;
|
||||||
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
return parsed as Record<string, unknown>;
|
return parsed as Record<string, unknown>;
|
||||||
} catch {
|
} catch {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function looksLikeHtmlResponse(text: string): boolean {
|
function looksLikeHtmlResponse(text: string): boolean {
|
||||||
const trimmed = text.trim();
|
const trimmed = text.trim();
|
||||||
return trimmed.startsWith("<!") || trimmed.startsWith("<html") || trimmed.startsWith("<HTML");
|
return trimmed.startsWith("<!") || trimmed.startsWith("<html") || trimmed.startsWith("<HTML");
|
||||||
}
|
}
|
||||||
|
|
||||||
export function extractPrivateTokenFromHtml(html: string): string | null {
|
export function extractPrivateTokenFromHtml(html: string): string | null {
|
||||||
const normalized = String(html || "");
|
const normalized = String(html || "");
|
||||||
if (!normalized.trim()) {
|
if (!normalized.trim()) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const patterns = [
|
const patterns = [
|
||||||
/private_token['"]\]\[0\]\.value\s*=\s*['"]([^'"]+)['"]/i,
|
/private_token['"]\]\[0\]\.value\s*=\s*['"]([^'"]+)['"]/i,
|
||||||
/getElementsByName\(\s*['"]private_token['"]\s*\)\s*\[\s*0\s*\]\.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,
|
/querySelector(?:All)?\(\s*['"][^'"]*private_token[^'"]*['"]\s*\)(?:\s*\[\s*0\s*\])?\.value\s*=\s*['"]([^'"]+)['"]/i,
|
||||||
/name=['"]private_token['"][^>]*value=['"]([^'"]+)['"]/i,
|
/name=['"]private_token['"][^>]*value=['"]([^'"]+)['"]/i,
|
||||||
/value=['"]([^'"]+)['"][^>]*name=['"]private_token['"]/i
|
/value=['"]([^'"]+)['"][^>]*name=['"]private_token['"]/i
|
||||||
];
|
];
|
||||||
|
|
||||||
for (const pattern of patterns) {
|
for (const pattern of patterns) {
|
||||||
const match = normalized.match(pattern);
|
const match = normalized.match(pattern);
|
||||||
const token = match?.[1]?.trim();
|
const token = match?.[1]?.trim();
|
||||||
if (token) {
|
if (token) {
|
||||||
return token;
|
return token;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class RealDebridWebFallback {
|
export class RealDebridWebFallback {
|
||||||
private queue: Promise<unknown> = Promise.resolve();
|
private queue: Promise<unknown> = Promise.resolve();
|
||||||
|
|
||||||
private loginWindow: BrowserWindow | null = null;
|
private loginWindow: BrowserWindow | null = null;
|
||||||
|
|
||||||
private loginWindowPartition = "";
|
private loginWindowPartition = "";
|
||||||
|
|
||||||
private cachedToken = "";
|
private cachedToken = "";
|
||||||
|
|
||||||
private cachedTokenAt = 0;
|
private cachedTokenAt = 0;
|
||||||
|
|
||||||
private getRememberSession: () => boolean;
|
private getRememberSession: () => boolean;
|
||||||
|
|
||||||
public constructor(getRememberSession: () => boolean) {
|
public constructor(getRememberSession: () => boolean) {
|
||||||
this.getRememberSession = getRememberSession;
|
this.getRememberSession = getRememberSession;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async unrestrict(link: string, signal?: AbortSignal): Promise<UnrestrictedLink | null> {
|
public async unrestrict(link: string, signal?: AbortSignal): Promise<UnrestrictedLink | null> {
|
||||||
const overallSignal = withTimeoutSignal(signal, 10 * 60 * 1000);
|
const overallSignal = withTimeoutSignal(signal, 10 * 60 * 1000);
|
||||||
return this.runExclusive(async () => {
|
return this.runExclusive(async () => {
|
||||||
throwIfAborted(overallSignal);
|
throwIfAborted(overallSignal);
|
||||||
if (!String(link || "").trim()) {
|
if (!String(link || "").trim()) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const initial = await this.generate(link, overallSignal);
|
const initial = await this.generate(link, overallSignal);
|
||||||
if (initial.kind === "success") {
|
if (initial.kind === "success") {
|
||||||
return initial.value;
|
return initial.value;
|
||||||
}
|
}
|
||||||
return this.waitForLoginAndGenerate(link, overallSignal);
|
return this.waitForLoginAndGenerate(link, overallSignal);
|
||||||
}, overallSignal);
|
}, overallSignal);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async openLoginWindow(): Promise<void> {
|
public async openLoginWindow(): Promise<void> {
|
||||||
const window = await this.ensureLoginWindow();
|
const window = await this.ensureLoginWindow();
|
||||||
if (window.isMinimized()) {
|
if (window.isMinimized()) {
|
||||||
window.restore();
|
window.restore();
|
||||||
}
|
}
|
||||||
window.show();
|
window.show();
|
||||||
window.focus();
|
window.focus();
|
||||||
void this.primeTokenFromWindow(window);
|
void this.primeTokenFromWindow(window);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async clearSessions(): Promise<void> {
|
public async clearSessions(): Promise<void> {
|
||||||
this.disposeLoginWindow();
|
this.disposeLoginWindow();
|
||||||
this.cachedToken = "";
|
this.cachedToken = "";
|
||||||
this.cachedTokenAt = 0;
|
this.cachedTokenAt = 0;
|
||||||
for (const partition of [RD_PERSISTENT_PARTITION, RD_TRANSIENT_PARTITION]) {
|
for (const partition of [RD_PERSISTENT_PARTITION, RD_TRANSIENT_PARTITION]) {
|
||||||
const currentSession = session.fromPartition(partition);
|
const currentSession = session.fromPartition(partition);
|
||||||
try {
|
try {
|
||||||
await currentSession.clearStorageData({
|
await currentSession.clearStorageData({
|
||||||
storages: ["cookies", "indexdb", "localstorage", "serviceworkers", "cachestorage"]
|
storages: ["cookies", "indexdb", "localstorage", "serviceworkers", "cachestorage"]
|
||||||
});
|
});
|
||||||
} catch {
|
} catch {
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
await currentSession.clearCache();
|
await currentSession.clearCache();
|
||||||
} catch {
|
} catch {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public dispose(): void {
|
public dispose(): void {
|
||||||
this.disposeLoginWindow();
|
this.disposeLoginWindow();
|
||||||
}
|
}
|
||||||
|
|
||||||
private getPartition(): string {
|
private getPartition(): string {
|
||||||
return this.getRememberSession() ? RD_PERSISTENT_PARTITION : RD_TRANSIENT_PARTITION;
|
return this.getRememberSession() ? RD_PERSISTENT_PARTITION : RD_TRANSIENT_PARTITION;
|
||||||
}
|
}
|
||||||
|
|
||||||
private disposeLoginWindow(): void {
|
private disposeLoginWindow(): void {
|
||||||
const current = this.loginWindow;
|
const current = this.loginWindow;
|
||||||
this.loginWindow = null;
|
this.loginWindow = null;
|
||||||
this.loginWindowPartition = "";
|
this.loginWindowPartition = "";
|
||||||
if (current && !current.isDestroyed()) {
|
if (current && !current.isDestroyed()) {
|
||||||
current.close();
|
current.close();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async runExclusive<T>(job: () => Promise<T>, signal?: AbortSignal): Promise<T> {
|
private async runExclusive<T>(job: () => Promise<T>, signal?: AbortSignal): Promise<T> {
|
||||||
const queuedAt = Date.now();
|
const queuedAt = Date.now();
|
||||||
const queueWaitTimeoutMs = 10 * 60 * 1000 + 30_000;
|
const queueWaitTimeoutMs = 10 * 60 * 1000 + 30_000;
|
||||||
const guardedJob = async (): Promise<T> => {
|
const guardedJob = async (): Promise<T> => {
|
||||||
throwIfAborted(signal);
|
throwIfAborted(signal);
|
||||||
const waited = Date.now() - queuedAt;
|
const waited = Date.now() - queuedAt;
|
||||||
if (waited > queueWaitTimeoutMs) {
|
if (waited > queueWaitTimeoutMs) {
|
||||||
throw new Error(`Real-Debrid-Web Queue-Timeout (${Math.floor(waited / 1000)}s gewartet)`);
|
throw new Error(`Real-Debrid-Web Queue-Timeout (${Math.floor(waited / 1000)}s gewartet)`);
|
||||||
}
|
}
|
||||||
return job();
|
return job();
|
||||||
};
|
};
|
||||||
const run = this.queue.then(guardedJob, guardedJob);
|
const run = this.queue.then(guardedJob, guardedJob);
|
||||||
this.queue = run.then(() => undefined, () => undefined);
|
this.queue = run.then(() => undefined, () => undefined);
|
||||||
return run;
|
return run;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async ensureLoginWindow(): Promise<BrowserWindow> {
|
private async ensureLoginWindow(): Promise<BrowserWindow> {
|
||||||
const partition = this.getPartition();
|
const partition = this.getPartition();
|
||||||
const existing = this.loginWindow;
|
const existing = this.loginWindow;
|
||||||
if (existing && !existing.isDestroyed() && this.loginWindowPartition === partition) {
|
if (existing && !existing.isDestroyed() && this.loginWindowPartition === partition) {
|
||||||
return existing;
|
return existing;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (existing && !existing.isDestroyed()) {
|
if (existing && !existing.isDestroyed()) {
|
||||||
existing.close();
|
existing.close();
|
||||||
}
|
}
|
||||||
|
|
||||||
const window = new BrowserWindow({
|
const window = new BrowserWindow({
|
||||||
width: 1120,
|
width: 1120,
|
||||||
height: 900,
|
height: 900,
|
||||||
minWidth: 980,
|
minWidth: 980,
|
||||||
minHeight: 760,
|
minHeight: 760,
|
||||||
autoHideMenuBar: true,
|
autoHideMenuBar: true,
|
||||||
title: "Real-Debrid Web-Login",
|
title: "Real-Debrid Web-Login",
|
||||||
webPreferences: {
|
webPreferences: {
|
||||||
partition,
|
partition,
|
||||||
contextIsolation: true,
|
contextIsolation: true,
|
||||||
nodeIntegration: false
|
nodeIntegration: false
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
window.setMenuBarVisibility(false);
|
window.setMenuBarVisibility(false);
|
||||||
window.webContents.setUserAgent(RD_USER_AGENT);
|
window.webContents.setUserAgent(RD_USER_AGENT);
|
||||||
const primeFromWindow = (): void => {
|
const primeFromWindow = (): void => {
|
||||||
void this.primeTokenFromWindow(window);
|
void this.primeTokenFromWindow(window);
|
||||||
};
|
};
|
||||||
window.webContents.on("did-finish-load", primeFromWindow);
|
window.webContents.on("did-finish-load", primeFromWindow);
|
||||||
window.webContents.on("did-navigate", primeFromWindow);
|
window.webContents.on("did-navigate", primeFromWindow);
|
||||||
window.webContents.on("did-navigate-in-page", primeFromWindow);
|
window.webContents.on("did-navigate-in-page", primeFromWindow);
|
||||||
window.on("close", () => {
|
window.on("close", () => {
|
||||||
void this.primeTokenFromWindow(window);
|
void this.primeTokenFromWindow(window);
|
||||||
});
|
});
|
||||||
window.on("closed", () => {
|
window.on("closed", () => {
|
||||||
if (this.loginWindow === window) {
|
if (this.loginWindow === window) {
|
||||||
this.loginWindow = null;
|
this.loginWindow = null;
|
||||||
this.loginWindowPartition = "";
|
this.loginWindowPartition = "";
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
this.loginWindow = window;
|
this.loginWindow = window;
|
||||||
this.loginWindowPartition = partition;
|
this.loginWindowPartition = partition;
|
||||||
await window.loadURL(RD_LOGIN_URL);
|
await window.loadURL(RD_LOGIN_URL);
|
||||||
return window;
|
return window;
|
||||||
}
|
}
|
||||||
|
|
||||||
private rememberToken(token: string): string {
|
private rememberToken(token: string): string {
|
||||||
this.cachedToken = token;
|
this.cachedToken = token;
|
||||||
this.cachedTokenAt = Date.now();
|
this.cachedTokenAt = Date.now();
|
||||||
return token;
|
return token;
|
||||||
}
|
}
|
||||||
|
|
||||||
private getActiveLoginWindow(): BrowserWindow | null {
|
private getActiveLoginWindow(): BrowserWindow | null {
|
||||||
const window = this.loginWindow;
|
const window = this.loginWindow;
|
||||||
if (!window || window.isDestroyed()) {
|
if (!window || window.isDestroyed()) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
if (this.loginWindowPartition !== this.getPartition()) {
|
if (this.loginWindowPartition !== this.getPartition()) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
return window;
|
return window;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async extractApiTokenFromWindow(window: BrowserWindow, signal?: AbortSignal): Promise<string | null> {
|
private async extractApiTokenFromWindow(window: BrowserWindow, signal?: AbortSignal): Promise<string | null> {
|
||||||
throwIfAborted(signal);
|
throwIfAborted(signal);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const rawResult = await window.webContents.executeJavaScript(`
|
const rawResult = await window.webContents.executeJavaScript(`
|
||||||
(async () => {
|
(async () => {
|
||||||
const readTokenFromHtml = (html) => {
|
const readTokenFromHtml = (html) => {
|
||||||
const text = String(html || "");
|
const text = String(html || "");
|
||||||
const patterns = [
|
const patterns = [
|
||||||
/private_token['"]\\]\\[0\\]\\.value\\s*=\\s*['"]([^'"]+)['"]/i,
|
/private_token['"]\\]\\[0\\]\\.value\\s*=\\s*['"]([^'"]+)['"]/i,
|
||||||
/getElementsByName\\(\\s*['"]private_token['"]\\s*\\)\\s*\\[\\s*0\\s*\\]\\.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,
|
/querySelector(?:All)?\\(\\s*['"][^'"]*private_token[^'"]*['"]\\s*\\)(?:\\s*\\[\\s*0\\s*\\])?\\.value\\s*=\\s*['"]([^'"]+)['"]/i,
|
||||||
/name=['"]private_token['"][^>]*value=['"]([^'"]+)['"]/i,
|
/name=['"]private_token['"][^>]*value=['"]([^'"]+)['"]/i,
|
||||||
/value=['"]([^'"]+)['"][^>]*name=['"]private_token['"]/i
|
/value=['"]([^'"]+)['"][^>]*name=['"]private_token['"]/i
|
||||||
];
|
];
|
||||||
for (const pattern of patterns) {
|
for (const pattern of patterns) {
|
||||||
const match = text.match(pattern);
|
const match = text.match(pattern);
|
||||||
if (match && match[1]) {
|
if (match && match[1]) {
|
||||||
return String(match[1]).trim();
|
return String(match[1]).trim();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return "";
|
return "";
|
||||||
};
|
};
|
||||||
|
|
||||||
const directInput = document.querySelector('input[name="private_token"]');
|
const directInput = document.querySelector('input[name="private_token"]');
|
||||||
if (directInput instanceof HTMLInputElement && directInput.value.trim()) {
|
if (directInput instanceof HTMLInputElement && directInput.value.trim()) {
|
||||||
return directInput.value.trim();
|
return directInput.value.trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
const html = document.documentElement ? document.documentElement.outerHTML : "";
|
const html = document.documentElement ? document.documentElement.outerHTML : "";
|
||||||
const directToken = readTokenFromHtml(html);
|
const directToken = readTokenFromHtml(html);
|
||||||
if (directToken) {
|
if (directToken) {
|
||||||
return directToken;
|
return directToken;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(${JSON.stringify(RD_APITOKEN_URL)}, {
|
const response = await fetch(${JSON.stringify(RD_APITOKEN_URL)}, {
|
||||||
credentials: "include",
|
credentials: "include",
|
||||||
cache: "no-store",
|
cache: "no-store",
|
||||||
headers: {
|
headers: {
|
||||||
"X-Requested-With": "XMLHttpRequest"
|
"X-Requested-With": "XMLHttpRequest"
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
const tokenHtml = await response.text();
|
const tokenHtml = await response.text();
|
||||||
return readTokenFromHtml(tokenHtml);
|
return readTokenFromHtml(tokenHtml);
|
||||||
} catch {
|
} catch {
|
||||||
return "";
|
return "";
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
`, true);
|
`, true);
|
||||||
const token = String(rawResult || "").trim();
|
const token = String(rawResult || "").trim();
|
||||||
if (token) {
|
if (token) {
|
||||||
return this.rememberToken(token);
|
return this.rememberToken(token);
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async primeTokenFromWindow(window: BrowserWindow): Promise<void> {
|
private async primeTokenFromWindow(window: BrowserWindow): Promise<void> {
|
||||||
try {
|
try {
|
||||||
await this.extractApiTokenFromWindow(window);
|
await this.extractApiTokenFromWindow(window);
|
||||||
} catch {
|
} catch {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async extractApiToken(signal?: AbortSignal): Promise<string | null> {
|
private async extractApiToken(signal?: AbortSignal): Promise<string | null> {
|
||||||
throwIfAborted(signal);
|
throwIfAborted(signal);
|
||||||
|
|
||||||
if (this.cachedToken && Date.now() - this.cachedTokenAt < 30 * 60 * 1000) {
|
if (this.cachedToken && Date.now() - this.cachedTokenAt < 30 * 60 * 1000) {
|
||||||
return this.cachedToken;
|
return this.cachedToken;
|
||||||
}
|
}
|
||||||
|
|
||||||
const activeLoginWindow = this.getActiveLoginWindow();
|
const activeLoginWindow = this.getActiveLoginWindow();
|
||||||
if (activeLoginWindow) {
|
if (activeLoginWindow) {
|
||||||
const windowToken = await this.extractApiTokenFromWindow(activeLoginWindow, signal);
|
const windowToken = await this.extractApiTokenFromWindow(activeLoginWindow, signal);
|
||||||
if (windowToken) {
|
if (windowToken) {
|
||||||
return windowToken;
|
return windowToken;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const currentSession = session.fromPartition(this.getPartition());
|
const currentSession = session.fromPartition(this.getPartition());
|
||||||
const response = await currentSession.fetch(RD_APITOKEN_URL, {
|
const response = await currentSession.fetch(RD_APITOKEN_URL, {
|
||||||
headers: {
|
headers: {
|
||||||
Accept: "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
|
Accept: "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
|
||||||
Referer: RD_BASE_URL + "/",
|
Referer: RD_BASE_URL + "/",
|
||||||
"User-Agent": RD_USER_AGENT
|
"User-Agent": RD_USER_AGENT
|
||||||
},
|
},
|
||||||
signal: withTimeoutSignal(signal, 30_000)
|
signal: withTimeoutSignal(signal, 30_000)
|
||||||
});
|
});
|
||||||
const html = await response.text();
|
const html = await response.text();
|
||||||
|
|
||||||
if (!response.ok || response.status === 403) {
|
if (!response.ok || response.status === 403) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const token = extractPrivateTokenFromHtml(html);
|
const token = extractPrivateTokenFromHtml(html);
|
||||||
if (token) {
|
if (token) {
|
||||||
return this.rememberToken(token);
|
return this.rememberToken(token);
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async generate(link: string, signal?: AbortSignal): Promise<GenerateOutcome> {
|
private async generate(link: string, signal?: AbortSignal): Promise<GenerateOutcome> {
|
||||||
throwIfAborted(signal);
|
throwIfAborted(signal);
|
||||||
|
|
||||||
const token = await this.extractApiToken(signal);
|
const token = await this.extractApiToken(signal);
|
||||||
if (!token) {
|
if (!token) {
|
||||||
return { kind: "login_required" };
|
return { kind: "login_required" };
|
||||||
}
|
}
|
||||||
|
|
||||||
for (let attempt = 1; attempt <= REQUEST_RETRIES; attempt += 1) {
|
for (let attempt = 1; attempt <= REQUEST_RETRIES; attempt += 1) {
|
||||||
throwIfAborted(signal);
|
throwIfAborted(signal);
|
||||||
try {
|
try {
|
||||||
const body = new URLSearchParams({ link });
|
const body = new URLSearchParams({ link });
|
||||||
const response = await fetch(RD_UNRESTRICT_API, {
|
const response = await fetch(RD_UNRESTRICT_API, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: {
|
headers: {
|
||||||
Authorization: `Bearer ${token}`,
|
Authorization: `Bearer ${token}`,
|
||||||
"Content-Type": "application/x-www-form-urlencoded",
|
"Content-Type": "application/x-www-form-urlencoded",
|
||||||
"User-Agent": RD_USER_AGENT
|
"User-Agent": RD_USER_AGENT
|
||||||
},
|
},
|
||||||
body,
|
body,
|
||||||
signal: withTimeoutSignal(signal, 30_000)
|
signal: withTimeoutSignal(signal, 30_000)
|
||||||
});
|
});
|
||||||
|
|
||||||
const text = await response.text();
|
const text = await response.text();
|
||||||
|
|
||||||
if (response.status === 401 || response.status === 403) {
|
if (response.status === 401 || response.status === 403) {
|
||||||
this.cachedToken = "";
|
this.cachedToken = "";
|
||||||
this.cachedTokenAt = 0;
|
this.cachedTokenAt = 0;
|
||||||
return { kind: "login_required" };
|
return { kind: "login_required" };
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
if ((response.status === 429 || response.status >= 500) && attempt < REQUEST_RETRIES) {
|
if ((response.status === 429 || response.status >= 500) && attempt < REQUEST_RETRIES) {
|
||||||
await sleepWithSignal(Math.min(5000, 400 * 2 ** attempt), signal);
|
await sleepWithSignal(Math.min(5000, 400 * 2 ** attempt), signal);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
throw new Error(`Real-Debrid Web HTTP ${response.status}: ${text.slice(0, 200)}`);
|
throw new Error(`Real-Debrid Web HTTP ${response.status}: ${text.slice(0, 200)}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (looksLikeHtmlResponse(text)) {
|
if (looksLikeHtmlResponse(text)) {
|
||||||
throw new Error("Real-Debrid Web lieferte HTML statt JSON");
|
throw new Error("Real-Debrid Web lieferte HTML statt JSON");
|
||||||
}
|
}
|
||||||
|
|
||||||
const payload = parseJson(text.trim());
|
const payload = parseJson(text.trim());
|
||||||
if (!payload) {
|
if (!payload) {
|
||||||
throw new Error("Ungültige JSON-Antwort von Real-Debrid Web");
|
throw new Error("Ungültige JSON-Antwort von Real-Debrid Web");
|
||||||
}
|
}
|
||||||
|
|
||||||
const directUrl = String(payload.download || payload.link || "").trim();
|
const directUrl = String(payload.download || payload.link || "").trim();
|
||||||
if (!directUrl) {
|
if (!directUrl) {
|
||||||
throw new Error("Real-Debrid Web: Antwort ohne Download-URL");
|
throw new Error("Real-Debrid Web: Antwort ohne Download-URL");
|
||||||
}
|
}
|
||||||
|
|
||||||
const fileName = String(payload.filename || "").trim() || filenameFromUrl(directUrl) || filenameFromUrl(link);
|
const fileName = String(payload.filename || "").trim() || filenameFromUrl(directUrl) || filenameFromUrl(link);
|
||||||
const fileSizeRaw = Number(payload.filesize ?? NaN);
|
const fileSizeRaw = Number(payload.filesize ?? NaN);
|
||||||
return {
|
return {
|
||||||
kind: "success",
|
kind: "success",
|
||||||
value: {
|
value: {
|
||||||
directUrl,
|
directUrl,
|
||||||
fileName,
|
fileName,
|
||||||
fileSize: Number.isFinite(fileSizeRaw) && fileSizeRaw > 0 ? Math.floor(fileSizeRaw) : null,
|
fileSize: Number.isFinite(fileSizeRaw) && fileSizeRaw > 0 ? Math.floor(fileSizeRaw) : null,
|
||||||
retriesUsed: attempt - 1
|
retriesUsed: attempt - 1
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (signal?.aborted) {
|
if (signal?.aborted) {
|
||||||
throw abortError();
|
throw abortError();
|
||||||
}
|
}
|
||||||
if (attempt >= REQUEST_RETRIES) {
|
if (attempt >= REQUEST_RETRIES) {
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
await sleepWithSignal(Math.min(5000, 400 * 2 ** attempt), signal);
|
await sleepWithSignal(Math.min(5000, 400 * 2 ** attempt), signal);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
throw new Error("Real-Debrid Web: Unrestrict fehlgeschlagen");
|
throw new Error("Real-Debrid Web: Unrestrict fehlgeschlagen");
|
||||||
}
|
}
|
||||||
|
|
||||||
private async waitForLoginAndGenerate(link: string, signal?: AbortSignal): Promise<UnrestrictedLink | null> {
|
private async waitForLoginAndGenerate(link: string, signal?: AbortSignal): Promise<UnrestrictedLink | null> {
|
||||||
const window = await this.ensureLoginWindow();
|
const window = await this.ensureLoginWindow();
|
||||||
if (window.isMinimized()) {
|
if (window.isMinimized()) {
|
||||||
window.restore();
|
window.restore();
|
||||||
}
|
}
|
||||||
window.show();
|
window.show();
|
||||||
window.focus();
|
window.focus();
|
||||||
|
|
||||||
const startedAt = Date.now();
|
const startedAt = Date.now();
|
||||||
while (Date.now() - startedAt < 10 * 60 * 1000) {
|
while (Date.now() - startedAt < 10 * 60 * 1000) {
|
||||||
throwIfAborted(signal);
|
throwIfAborted(signal);
|
||||||
if (window.isDestroyed()) {
|
if (window.isDestroyed()) {
|
||||||
throw new Error("Real-Debrid Web-Login abgebrochen");
|
throw new Error("Real-Debrid Web-Login abgebrochen");
|
||||||
}
|
}
|
||||||
|
|
||||||
const outcome = await this.generate(link, signal);
|
const outcome = await this.generate(link, signal);
|
||||||
if (outcome.kind === "success") {
|
if (outcome.kind === "success") {
|
||||||
if (!window.isDestroyed()) {
|
if (!window.isDestroyed()) {
|
||||||
window.close();
|
window.close();
|
||||||
}
|
}
|
||||||
return outcome.value;
|
return outcome.value;
|
||||||
}
|
}
|
||||||
|
|
||||||
await sleepWithSignal(1_500, signal);
|
await sleepWithSignal(1_500, signal);
|
||||||
}
|
}
|
||||||
|
|
||||||
throw new Error("Real-Debrid Web-Login Timeout");
|
throw new Error("Real-Debrid Web-Login Timeout");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,204 +1,204 @@
|
|||||||
import { API_BASE_URL, APP_VERSION, REQUEST_RETRIES } from "./constants";
|
import { API_BASE_URL, APP_VERSION, REQUEST_RETRIES } from "./constants";
|
||||||
import { compactErrorText, sleep } from "./utils";
|
import { compactErrorText, sleep } from "./utils";
|
||||||
|
|
||||||
const DEBRID_USER_AGENT = `RD-Node-Downloader/${APP_VERSION}`;
|
const DEBRID_USER_AGENT = `RD-Node-Downloader/${APP_VERSION}`;
|
||||||
|
|
||||||
export interface UnrestrictedLink {
|
export interface UnrestrictedLink {
|
||||||
fileName: string;
|
fileName: string;
|
||||||
directUrl: string;
|
directUrl: string;
|
||||||
fileSize: number | null;
|
fileSize: number | null;
|
||||||
retriesUsed: number;
|
retriesUsed: number;
|
||||||
skipTlsVerify?: boolean;
|
skipTlsVerify?: boolean;
|
||||||
sourceLabel?: string;
|
sourceLabel?: string;
|
||||||
sourceAccountId?: string;
|
sourceAccountId?: string;
|
||||||
sourceAccountLabel?: string;
|
sourceAccountLabel?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
function shouldRetryStatus(status: number): boolean {
|
function shouldRetryStatus(status: number): boolean {
|
||||||
return status === 429 || status >= 500;
|
return status === 429 || status >= 500;
|
||||||
}
|
}
|
||||||
|
|
||||||
function retryDelay(attempt: number): number {
|
function retryDelay(attempt: number): number {
|
||||||
return Math.min(5000, 400 * 2 ** attempt);
|
return Math.min(5000, 400 * 2 ** attempt);
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseRetryAfterMs(value: string | null): number {
|
function parseRetryAfterMs(value: string | null): number {
|
||||||
const text = String(value || "").trim();
|
const text = String(value || "").trim();
|
||||||
if (!text) {
|
if (!text) {
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
const asSeconds = Number(text);
|
const asSeconds = Number(text);
|
||||||
if (Number.isFinite(asSeconds) && asSeconds >= 0) {
|
if (Number.isFinite(asSeconds) && asSeconds >= 0) {
|
||||||
return Math.min(120000, Math.floor(asSeconds * 1000));
|
return Math.min(120000, Math.floor(asSeconds * 1000));
|
||||||
}
|
}
|
||||||
|
|
||||||
const asDate = Date.parse(text);
|
const asDate = Date.parse(text);
|
||||||
if (Number.isFinite(asDate)) {
|
if (Number.isFinite(asDate)) {
|
||||||
return Math.min(120000, Math.max(0, asDate - Date.now()));
|
return Math.min(120000, Math.max(0, asDate - Date.now()));
|
||||||
}
|
}
|
||||||
|
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
function retryDelayForResponse(response: Response, attempt: number): number {
|
function retryDelayForResponse(response: Response, attempt: number): number {
|
||||||
if (response.status !== 429) {
|
if (response.status !== 429) {
|
||||||
return retryDelay(attempt);
|
return retryDelay(attempt);
|
||||||
}
|
}
|
||||||
const fromHeader = parseRetryAfterMs(response.headers.get("retry-after"));
|
const fromHeader = parseRetryAfterMs(response.headers.get("retry-after"));
|
||||||
return fromHeader > 0 ? fromHeader : retryDelay(attempt);
|
return fromHeader > 0 ? fromHeader : retryDelay(attempt);
|
||||||
}
|
}
|
||||||
|
|
||||||
function readHttpStatusFromErrorText(text: string): number {
|
function readHttpStatusFromErrorText(text: string): number {
|
||||||
const match = String(text || "").match(/HTTP\s+(\d{3})/i);
|
const match = String(text || "").match(/HTTP\s+(\d{3})/i);
|
||||||
return match ? Number(match[1]) : 0;
|
return match ? Number(match[1]) : 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
function isRetryableErrorText(text: string): boolean {
|
function isRetryableErrorText(text: string): boolean {
|
||||||
const status = readHttpStatusFromErrorText(text);
|
const status = readHttpStatusFromErrorText(text);
|
||||||
if (status === 429 || status >= 500) {
|
if (status === 429 || status >= 500) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
const lower = String(text || "").toLowerCase();
|
const lower = String(text || "").toLowerCase();
|
||||||
return lower.includes("timeout")
|
return lower.includes("timeout")
|
||||||
|| lower.includes("network")
|
|| lower.includes("network")
|
||||||
|| lower.includes("fetch failed")
|
|| lower.includes("fetch failed")
|
||||||
|| lower.includes("aborted")
|
|| lower.includes("aborted")
|
||||||
|| lower.includes("econnreset")
|
|| lower.includes("econnreset")
|
||||||
|| lower.includes("enotfound")
|
|| lower.includes("enotfound")
|
||||||
|| lower.includes("etimedout")
|
|| lower.includes("etimedout")
|
||||||
|| lower.includes("html statt json");
|
|| lower.includes("html statt json");
|
||||||
}
|
}
|
||||||
|
|
||||||
function withTimeoutSignal(signal: AbortSignal | undefined, timeoutMs: number): AbortSignal {
|
function withTimeoutSignal(signal: AbortSignal | undefined, timeoutMs: number): AbortSignal {
|
||||||
if (!signal) {
|
if (!signal) {
|
||||||
return AbortSignal.timeout(timeoutMs);
|
return AbortSignal.timeout(timeoutMs);
|
||||||
}
|
}
|
||||||
return AbortSignal.any([signal, AbortSignal.timeout(timeoutMs)]);
|
return AbortSignal.any([signal, AbortSignal.timeout(timeoutMs)]);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function sleepWithSignal(ms: number, signal?: AbortSignal): Promise<void> {
|
async function sleepWithSignal(ms: number, signal?: AbortSignal): Promise<void> {
|
||||||
if (!signal) {
|
if (!signal) {
|
||||||
await sleep(ms);
|
await sleep(ms);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (signal.aborted) {
|
if (signal.aborted) {
|
||||||
throw new Error("aborted");
|
throw new Error("aborted");
|
||||||
}
|
}
|
||||||
await new Promise<void>((resolve, reject) => {
|
await new Promise<void>((resolve, reject) => {
|
||||||
let timer: NodeJS.Timeout | null = setTimeout(() => {
|
let timer: NodeJS.Timeout | null = setTimeout(() => {
|
||||||
timer = null;
|
timer = null;
|
||||||
signal.removeEventListener("abort", onAbort);
|
signal.removeEventListener("abort", onAbort);
|
||||||
resolve();
|
resolve();
|
||||||
}, Math.max(0, ms));
|
}, Math.max(0, ms));
|
||||||
|
|
||||||
const onAbort = (): void => {
|
const onAbort = (): void => {
|
||||||
if (timer) {
|
if (timer) {
|
||||||
clearTimeout(timer);
|
clearTimeout(timer);
|
||||||
timer = null;
|
timer = null;
|
||||||
}
|
}
|
||||||
signal.removeEventListener("abort", onAbort);
|
signal.removeEventListener("abort", onAbort);
|
||||||
reject(new Error("aborted"));
|
reject(new Error("aborted"));
|
||||||
};
|
};
|
||||||
|
|
||||||
signal.addEventListener("abort", onAbort, { once: true });
|
signal.addEventListener("abort", onAbort, { once: true });
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function looksLikeHtmlResponse(contentType: string, body: string): boolean {
|
function looksLikeHtmlResponse(contentType: string, body: string): boolean {
|
||||||
const type = String(contentType || "").toLowerCase();
|
const type = String(contentType || "").toLowerCase();
|
||||||
if (type.includes("text/html") || type.includes("application/xhtml+xml")) {
|
if (type.includes("text/html") || type.includes("application/xhtml+xml")) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
return /^\s*<(!doctype\s+html|html\b)/i.test(String(body || ""));
|
return /^\s*<(!doctype\s+html|html\b)/i.test(String(body || ""));
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseErrorBody(status: number, body: string, contentType: string): string {
|
function parseErrorBody(status: number, body: string, contentType: string): string {
|
||||||
if (looksLikeHtmlResponse(contentType, body)) {
|
if (looksLikeHtmlResponse(contentType, body)) {
|
||||||
return `Real-Debrid lieferte HTML statt JSON (HTTP ${status})`;
|
return `Real-Debrid lieferte HTML statt JSON (HTTP ${status})`;
|
||||||
}
|
}
|
||||||
const clean = compactErrorText(body);
|
const clean = compactErrorText(body);
|
||||||
return clean || `HTTP ${status}`;
|
return clean || `HTTP ${status}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class RealDebridClient {
|
export class RealDebridClient {
|
||||||
private token: string;
|
private token: string;
|
||||||
|
|
||||||
public constructor(token: string) {
|
public constructor(token: string) {
|
||||||
this.token = token;
|
this.token = token;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async unrestrictLink(link: string, signal?: AbortSignal): Promise<UnrestrictedLink> {
|
public async unrestrictLink(link: string, signal?: AbortSignal): Promise<UnrestrictedLink> {
|
||||||
let lastError = "";
|
let lastError = "";
|
||||||
for (let attempt = 1; attempt <= REQUEST_RETRIES; attempt += 1) {
|
for (let attempt = 1; attempt <= REQUEST_RETRIES; attempt += 1) {
|
||||||
try {
|
try {
|
||||||
const body = new URLSearchParams({ link });
|
const body = new URLSearchParams({ link });
|
||||||
const response = await fetch(`${API_BASE_URL}/unrestrict/link`, {
|
const response = await fetch(`${API_BASE_URL}/unrestrict/link`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: {
|
headers: {
|
||||||
Authorization: `Bearer ${this.token}`,
|
Authorization: `Bearer ${this.token}`,
|
||||||
"Content-Type": "application/x-www-form-urlencoded",
|
"Content-Type": "application/x-www-form-urlencoded",
|
||||||
"User-Agent": DEBRID_USER_AGENT
|
"User-Agent": DEBRID_USER_AGENT
|
||||||
},
|
},
|
||||||
body,
|
body,
|
||||||
signal: withTimeoutSignal(signal, 30000)
|
signal: withTimeoutSignal(signal, 30000)
|
||||||
});
|
});
|
||||||
|
|
||||||
const text = await response.text();
|
const text = await response.text();
|
||||||
const contentType = String(response.headers.get("content-type") || "");
|
const contentType = String(response.headers.get("content-type") || "");
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const parsed = parseErrorBody(response.status, text, contentType);
|
const parsed = parseErrorBody(response.status, text, contentType);
|
||||||
if (shouldRetryStatus(response.status) && attempt < REQUEST_RETRIES) {
|
if (shouldRetryStatus(response.status) && attempt < REQUEST_RETRIES) {
|
||||||
await sleepWithSignal(retryDelayForResponse(response, attempt), signal);
|
await sleepWithSignal(retryDelayForResponse(response, attempt), signal);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
throw new Error(parsed);
|
throw new Error(parsed);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (looksLikeHtmlResponse(contentType, text)) {
|
if (looksLikeHtmlResponse(contentType, text)) {
|
||||||
throw new Error("Real-Debrid lieferte HTML statt JSON");
|
throw new Error("Real-Debrid lieferte HTML statt JSON");
|
||||||
}
|
}
|
||||||
|
|
||||||
let payload: Record<string, unknown>;
|
let payload: Record<string, unknown>;
|
||||||
try {
|
try {
|
||||||
payload = JSON.parse(text) as Record<string, unknown>;
|
payload = JSON.parse(text) as Record<string, unknown>;
|
||||||
} catch {
|
} catch {
|
||||||
throw new Error("Ungültige JSON-Antwort von Real-Debrid");
|
throw new Error("Ungültige JSON-Antwort von Real-Debrid");
|
||||||
}
|
}
|
||||||
const directUrl = String(payload.download || payload.link || "").trim();
|
const directUrl = String(payload.download || payload.link || "").trim();
|
||||||
if (!directUrl) {
|
if (!directUrl) {
|
||||||
throw new Error("Unrestrict ohne Download-URL");
|
throw new Error("Unrestrict ohne Download-URL");
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const parsedUrl = new URL(directUrl);
|
const parsedUrl = new URL(directUrl);
|
||||||
if (parsedUrl.protocol !== "https:" && parsedUrl.protocol !== "http:") {
|
if (parsedUrl.protocol !== "https:" && parsedUrl.protocol !== "http:") {
|
||||||
throw new Error(`Ungültiges Download-URL-Protokoll (${parsedUrl.protocol})`);
|
throw new Error(`Ungültiges Download-URL-Protokoll (${parsedUrl.protocol})`);
|
||||||
}
|
}
|
||||||
} catch (urlError) {
|
} catch (urlError) {
|
||||||
if (urlError instanceof Error && urlError.message.includes("Protokoll")) throw urlError;
|
if (urlError instanceof Error && urlError.message.includes("Protokoll")) throw urlError;
|
||||||
throw new Error("Real-Debrid Antwort enthält keine gültige Download-URL");
|
throw new Error("Real-Debrid Antwort enthält keine gültige Download-URL");
|
||||||
}
|
}
|
||||||
|
|
||||||
const fileName = String(payload.filename || "download.bin").trim() || "download.bin";
|
const fileName = String(payload.filename || "download.bin").trim() || "download.bin";
|
||||||
const fileSizeRaw = Number(payload.filesize ?? NaN);
|
const fileSizeRaw = Number(payload.filesize ?? NaN);
|
||||||
return {
|
return {
|
||||||
fileName,
|
fileName,
|
||||||
directUrl,
|
directUrl,
|
||||||
fileSize: Number.isFinite(fileSizeRaw) && fileSizeRaw > 0 ? Math.floor(fileSizeRaw) : null,
|
fileSize: Number.isFinite(fileSizeRaw) && fileSizeRaw > 0 ? Math.floor(fileSizeRaw) : null,
|
||||||
retriesUsed: attempt - 1
|
retriesUsed: attempt - 1
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
lastError = compactErrorText(error);
|
lastError = compactErrorText(error);
|
||||||
if (signal?.aborted || (/aborted/i.test(lastError) && !/timeout/i.test(lastError))) {
|
if (signal?.aborted || (/aborted/i.test(lastError) && !/timeout/i.test(lastError))) {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
if (attempt >= REQUEST_RETRIES || !isRetryableErrorText(lastError)) {
|
if (attempt >= REQUEST_RETRIES || !isRetryableErrorText(lastError)) {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
await sleepWithSignal(retryDelay(attempt), signal);
|
await sleepWithSignal(retryDelay(attempt), signal);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
throw new Error(String(lastError || "Unrestrict fehlgeschlagen").replace(/^Error:\s*/i, ""));
|
throw new Error(String(lastError || "Unrestrict fehlgeschlagen").replace(/^Error:\s*/i, ""));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,119 +1,119 @@
|
|||||||
import fs from "node:fs";
|
import fs from "node:fs";
|
||||||
import { logTimestamp } from "./log-timestamp";
|
import { logTimestamp } from "./log-timestamp";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
|
|
||||||
type RenameLogLevel = "INFO" | "WARN" | "ERROR";
|
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_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);
|
const RENAME_LOG_RETENTION_DAYS = Number(process.env.RD_RENAME_LOG_RETENTION_DAYS || 30);
|
||||||
|
|
||||||
let renameLogPath: string | null = null;
|
let renameLogPath: string | null = null;
|
||||||
|
|
||||||
function sanitizeFieldValue(value: unknown): string {
|
function sanitizeFieldValue(value: unknown): string {
|
||||||
if (value === undefined || value === null) {
|
if (value === undefined || value === null) {
|
||||||
return "";
|
return "";
|
||||||
}
|
}
|
||||||
if (typeof value === "string") {
|
if (typeof value === "string") {
|
||||||
return value.replace(/\r?\n/g, "\\n");
|
return value.replace(/\r?\n/g, "\\n");
|
||||||
}
|
}
|
||||||
if (typeof value === "number" || typeof value === "boolean") {
|
if (typeof value === "number" || typeof value === "boolean") {
|
||||||
return String(value);
|
return String(value);
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
return JSON.stringify(value).replace(/\r?\n/g, "\\n");
|
return JSON.stringify(value).replace(/\r?\n/g, "\\n");
|
||||||
} catch {
|
} catch {
|
||||||
return String(value);
|
return String(value);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatFields(fields?: Record<string, unknown>): string {
|
function formatFields(fields?: Record<string, unknown>): string {
|
||||||
if (!fields) {
|
if (!fields) {
|
||||||
return "";
|
return "";
|
||||||
}
|
}
|
||||||
const parts = Object.entries(fields)
|
const parts = Object.entries(fields)
|
||||||
.filter(([, value]) => value !== undefined && value !== null && sanitizeFieldValue(value) !== "")
|
.filter(([, value]) => value !== undefined && value !== null && sanitizeFieldValue(value) !== "")
|
||||||
.map(([key, value]) => `${key}=${sanitizeFieldValue(value)}`);
|
.map(([key, value]) => `${key}=${sanitizeFieldValue(value)}`);
|
||||||
return parts.length > 0 ? ` | ${parts.join(" | ")}` : "";
|
return parts.length > 0 ? ` | ${parts.join(" | ")}` : "";
|
||||||
}
|
}
|
||||||
|
|
||||||
function rotateIfNeeded(filePath: string): void {
|
function rotateIfNeeded(filePath: string): void {
|
||||||
try {
|
try {
|
||||||
const stat = fs.statSync(filePath);
|
const stat = fs.statSync(filePath);
|
||||||
if (stat.size < RENAME_LOG_MAX_FILE_BYTES) {
|
if (stat.size < RENAME_LOG_MAX_FILE_BYTES) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const backup = `${filePath}.old`;
|
const backup = `${filePath}.old`;
|
||||||
try {
|
try {
|
||||||
fs.rmSync(backup, { force: true });
|
fs.rmSync(backup, { force: true });
|
||||||
} catch {
|
} catch {
|
||||||
}
|
}
|
||||||
fs.renameSync(filePath, backup);
|
fs.renameSync(filePath, backup);
|
||||||
} catch {
|
} catch {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function cleanupOldBackup(filePath: string): void {
|
function cleanupOldBackup(filePath: string): void {
|
||||||
const backup = `${filePath}.old`;
|
const backup = `${filePath}.old`;
|
||||||
try {
|
try {
|
||||||
const stat = fs.statSync(backup);
|
const stat = fs.statSync(backup);
|
||||||
const cutoff = Date.now() - RENAME_LOG_RETENTION_DAYS * 24 * 60 * 60 * 1000;
|
const cutoff = Date.now() - RENAME_LOG_RETENTION_DAYS * 24 * 60 * 60 * 1000;
|
||||||
if (stat.mtimeMs < cutoff) {
|
if (stat.mtimeMs < cutoff) {
|
||||||
fs.rmSync(backup, { force: true });
|
fs.rmSync(backup, { force: true });
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function initRenameLog(baseDir: string): void {
|
export function initRenameLog(baseDir: string): void {
|
||||||
renameLogPath = path.join(baseDir, "rename.log");
|
renameLogPath = path.join(baseDir, "rename.log");
|
||||||
try {
|
try {
|
||||||
fs.mkdirSync(path.dirname(renameLogPath), { recursive: true });
|
fs.mkdirSync(path.dirname(renameLogPath), { recursive: true });
|
||||||
cleanupOldBackup(renameLogPath);
|
cleanupOldBackup(renameLogPath);
|
||||||
if (!fs.existsSync(renameLogPath)) {
|
if (!fs.existsSync(renameLogPath)) {
|
||||||
fs.writeFileSync(renameLogPath, "", "utf8");
|
fs.writeFileSync(renameLogPath, "", "utf8");
|
||||||
}
|
}
|
||||||
rotateIfNeeded(renameLogPath);
|
rotateIfNeeded(renameLogPath);
|
||||||
if (!fs.existsSync(renameLogPath)) {
|
if (!fs.existsSync(renameLogPath)) {
|
||||||
fs.writeFileSync(renameLogPath, "", "utf8");
|
fs.writeFileSync(renameLogPath, "", "utf8");
|
||||||
}
|
}
|
||||||
fs.appendFileSync(renameLogPath, `=== Rename-Log Start: ${logTimestamp()} ===\n`, "utf8");
|
fs.appendFileSync(renameLogPath, `=== Rename-Log Start: ${logTimestamp()} ===\n`, "utf8");
|
||||||
} catch {
|
} catch {
|
||||||
renameLogPath = null;
|
renameLogPath = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function logRenameEvent(level: RenameLogLevel, message: string, fields?: Record<string, unknown>): void {
|
export function logRenameEvent(level: RenameLogLevel, message: string, fields?: Record<string, unknown>): void {
|
||||||
if (!renameLogPath) {
|
if (!renameLogPath) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
rotateIfNeeded(renameLogPath);
|
rotateIfNeeded(renameLogPath);
|
||||||
if (!fs.existsSync(renameLogPath)) {
|
if (!fs.existsSync(renameLogPath)) {
|
||||||
fs.writeFileSync(renameLogPath, "", "utf8");
|
fs.writeFileSync(renameLogPath, "", "utf8");
|
||||||
}
|
}
|
||||||
fs.appendFileSync(
|
fs.appendFileSync(
|
||||||
renameLogPath,
|
renameLogPath,
|
||||||
`${logTimestamp()} [${level}] ${message}${formatFields(fields)}\n`,
|
`${logTimestamp()} [${level}] ${message}${formatFields(fields)}\n`,
|
||||||
"utf8"
|
"utf8"
|
||||||
);
|
);
|
||||||
} catch {
|
} catch {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getRenameLogPath(): string | null {
|
export function getRenameLogPath(): string | null {
|
||||||
if (!renameLogPath) {
|
if (!renameLogPath) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
return fs.existsSync(renameLogPath) ? renameLogPath : null;
|
return fs.existsSync(renameLogPath) ? renameLogPath : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function shutdownRenameLog(): void {
|
export function shutdownRenameLog(): void {
|
||||||
if (!renameLogPath) {
|
if (!renameLogPath) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
fs.appendFileSync(renameLogPath, `=== Rename-Log Ende: ${logTimestamp()} ===\n`, "utf8");
|
fs.appendFileSync(renameLogPath, `=== Rename-Log Ende: ${logTimestamp()} ===\n`, "utf8");
|
||||||
} catch {
|
} catch {
|
||||||
}
|
}
|
||||||
renameLogPath = null;
|
renameLogPath = null;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,123 +1,123 @@
|
|||||||
import fs from "node:fs";
|
import fs from "node:fs";
|
||||||
import { logTimestamp } from "./log-timestamp";
|
import { logTimestamp } from "./log-timestamp";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { setLogListener } from "./logger";
|
import { setLogListener } from "./logger";
|
||||||
|
|
||||||
const SESSION_LOG_FLUSH_INTERVAL_MS = 200;
|
const SESSION_LOG_FLUSH_INTERVAL_MS = 200;
|
||||||
|
|
||||||
let sessionLogPath: string | null = null;
|
let sessionLogPath: string | null = null;
|
||||||
let sessionLogsDir: string | null = null;
|
let sessionLogsDir: string | null = null;
|
||||||
let pendingLines: string[] = [];
|
let pendingLines: string[] = [];
|
||||||
let flushTimer: NodeJS.Timeout | null = null;
|
let flushTimer: NodeJS.Timeout | null = null;
|
||||||
|
|
||||||
function formatTimestamp(): string {
|
function formatTimestamp(): string {
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const y = now.getFullYear();
|
const y = now.getFullYear();
|
||||||
const mo = String(now.getMonth() + 1).padStart(2, "0");
|
const mo = String(now.getMonth() + 1).padStart(2, "0");
|
||||||
const d = String(now.getDate()).padStart(2, "0");
|
const d = String(now.getDate()).padStart(2, "0");
|
||||||
const h = String(now.getHours()).padStart(2, "0");
|
const h = String(now.getHours()).padStart(2, "0");
|
||||||
const mi = String(now.getMinutes()).padStart(2, "0");
|
const mi = String(now.getMinutes()).padStart(2, "0");
|
||||||
const s = String(now.getSeconds()).padStart(2, "0");
|
const s = String(now.getSeconds()).padStart(2, "0");
|
||||||
return `${y}-${mo}-${d}_${h}-${mi}-${s}`;
|
return `${y}-${mo}-${d}_${h}-${mi}-${s}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function flushPending(): void {
|
function flushPending(): void {
|
||||||
if (pendingLines.length === 0 || !sessionLogPath) {
|
if (pendingLines.length === 0 || !sessionLogPath) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const chunk = pendingLines.join("");
|
const chunk = pendingLines.join("");
|
||||||
pendingLines = [];
|
pendingLines = [];
|
||||||
try {
|
try {
|
||||||
fs.appendFileSync(sessionLogPath, chunk, "utf8");
|
fs.appendFileSync(sessionLogPath, chunk, "utf8");
|
||||||
} catch {
|
} catch {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function scheduleFlush(): void {
|
function scheduleFlush(): void {
|
||||||
if (flushTimer) {
|
if (flushTimer) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
flushTimer = setTimeout(() => {
|
flushTimer = setTimeout(() => {
|
||||||
flushTimer = null;
|
flushTimer = null;
|
||||||
flushPending();
|
flushPending();
|
||||||
}, SESSION_LOG_FLUSH_INTERVAL_MS);
|
}, SESSION_LOG_FLUSH_INTERVAL_MS);
|
||||||
}
|
}
|
||||||
|
|
||||||
function appendToSessionLog(line: string): void {
|
function appendToSessionLog(line: string): void {
|
||||||
if (!sessionLogPath) {
|
if (!sessionLogPath) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
pendingLines.push(line);
|
pendingLines.push(line);
|
||||||
scheduleFlush();
|
scheduleFlush();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function cleanupOldSessionLogs(dir: string, maxAgeDays: number): Promise<void> {
|
async function cleanupOldSessionLogs(dir: string, maxAgeDays: number): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const files = await fs.promises.readdir(dir);
|
const files = await fs.promises.readdir(dir);
|
||||||
const cutoff = Date.now() - maxAgeDays * 24 * 60 * 60 * 1000;
|
const cutoff = Date.now() - maxAgeDays * 24 * 60 * 60 * 1000;
|
||||||
for (const file of files) {
|
for (const file of files) {
|
||||||
if (!file.startsWith("session_") || !file.endsWith(".txt")) {
|
if (!file.startsWith("session_") || !file.endsWith(".txt")) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const filePath = path.join(dir, file);
|
const filePath = path.join(dir, file);
|
||||||
try {
|
try {
|
||||||
const stat = await fs.promises.stat(filePath);
|
const stat = await fs.promises.stat(filePath);
|
||||||
if (stat.mtimeMs < cutoff) {
|
if (stat.mtimeMs < cutoff) {
|
||||||
await fs.promises.unlink(filePath);
|
await fs.promises.unlink(filePath);
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function initSessionLog(baseDir: string): void {
|
export function initSessionLog(baseDir: string): void {
|
||||||
sessionLogsDir = path.join(baseDir, "session-logs");
|
sessionLogsDir = path.join(baseDir, "session-logs");
|
||||||
try {
|
try {
|
||||||
fs.mkdirSync(sessionLogsDir, { recursive: true });
|
fs.mkdirSync(sessionLogsDir, { recursive: true });
|
||||||
} catch {
|
} catch {
|
||||||
sessionLogsDir = null;
|
sessionLogsDir = null;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const timestamp = formatTimestamp();
|
const timestamp = formatTimestamp();
|
||||||
sessionLogPath = path.join(sessionLogsDir, `session_${timestamp}.txt`);
|
sessionLogPath = path.join(sessionLogsDir, `session_${timestamp}.txt`);
|
||||||
|
|
||||||
const isoTimestamp = logTimestamp();
|
const isoTimestamp = logTimestamp();
|
||||||
try {
|
try {
|
||||||
fs.writeFileSync(sessionLogPath, `=== Session gestartet: ${isoTimestamp} ===\n`, "utf8");
|
fs.writeFileSync(sessionLogPath, `=== Session gestartet: ${isoTimestamp} ===\n`, "utf8");
|
||||||
} catch {
|
} catch {
|
||||||
sessionLogPath = null;
|
sessionLogPath = null;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setLogListener((line) => appendToSessionLog(line));
|
setLogListener((line) => appendToSessionLog(line));
|
||||||
|
|
||||||
void cleanupOldSessionLogs(sessionLogsDir, 7);
|
void cleanupOldSessionLogs(sessionLogsDir, 7);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getSessionLogPath(): string | null {
|
export function getSessionLogPath(): string | null {
|
||||||
return sessionLogPath;
|
return sessionLogPath;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function shutdownSessionLog(): void {
|
export function shutdownSessionLog(): void {
|
||||||
if (!sessionLogPath) {
|
if (!sessionLogPath) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (flushTimer) {
|
if (flushTimer) {
|
||||||
clearTimeout(flushTimer);
|
clearTimeout(flushTimer);
|
||||||
flushTimer = null;
|
flushTimer = null;
|
||||||
}
|
}
|
||||||
flushPending();
|
flushPending();
|
||||||
|
|
||||||
const isoTimestamp = logTimestamp();
|
const isoTimestamp = logTimestamp();
|
||||||
try {
|
try {
|
||||||
fs.appendFileSync(sessionLogPath, `=== Session beendet: ${isoTimestamp} ===\n`, "utf8");
|
fs.appendFileSync(sessionLogPath, `=== Session beendet: ${isoTimestamp} ===\n`, "utf8");
|
||||||
} catch {
|
} catch {
|
||||||
}
|
}
|
||||||
|
|
||||||
setLogListener(null);
|
setLogListener(null);
|
||||||
sessionLogPath = null;
|
sessionLogPath = null;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,195 +1,195 @@
|
|||||||
import fs from "node:fs";
|
import fs from "node:fs";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { AppSettings } from "../shared/types";
|
import { AppSettings } from "../shared/types";
|
||||||
import { parseDebridLinkApiKeys } from "../shared/debrid-link-keys";
|
import { parseDebridLinkApiKeys } from "../shared/debrid-link-keys";
|
||||||
import { parseMegaDebridAccounts } from "../shared/mega-debrid-accounts";
|
import { parseMegaDebridAccounts } from "../shared/mega-debrid-accounts";
|
||||||
import { StoragePaths } from "./storage";
|
import { StoragePaths } from "./storage";
|
||||||
|
|
||||||
export type HealthCheckSeverity = "INFO" | "WARN" | "ERROR";
|
export type HealthCheckSeverity = "INFO" | "WARN" | "ERROR";
|
||||||
|
|
||||||
export interface HealthCheckFinding {
|
export interface HealthCheckFinding {
|
||||||
severity: HealthCheckSeverity;
|
severity: HealthCheckSeverity;
|
||||||
code: string;
|
code: string;
|
||||||
message: string;
|
message: string;
|
||||||
hint?: string;
|
hint?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface HealthCheckReport {
|
export interface HealthCheckReport {
|
||||||
findings: HealthCheckFinding[];
|
findings: HealthCheckFinding[];
|
||||||
errorCount: number;
|
errorCount: number;
|
||||||
warnCount: number;
|
warnCount: number;
|
||||||
infoCount: number;
|
infoCount: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
const LOW_DISK_SPACE_BYTES = 5 * 1024 * 1024 * 1024;
|
const LOW_DISK_SPACE_BYTES = 5 * 1024 * 1024 * 1024;
|
||||||
const LARGE_STATE_FILE_BYTES = 50 * 1024 * 1024;
|
const LARGE_STATE_FILE_BYTES = 50 * 1024 * 1024;
|
||||||
|
|
||||||
function safeExists(p: string): boolean {
|
function safeExists(p: string): boolean {
|
||||||
try {
|
try {
|
||||||
return fs.existsSync(p);
|
return fs.existsSync(p);
|
||||||
} catch {
|
} catch {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function getFileSizeBytes(p: string): number {
|
function getFileSizeBytes(p: string): number {
|
||||||
try {
|
try {
|
||||||
const stat = fs.statSync(p);
|
const stat = fs.statSync(p);
|
||||||
return stat.size;
|
return stat.size;
|
||||||
} catch {
|
} catch {
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function isWritable(dir: string): boolean {
|
function isWritable(dir: string): boolean {
|
||||||
const probe = path.join(dir, `.rddl-health-probe-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`);
|
const probe = path.join(dir, `.rddl-health-probe-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`);
|
||||||
try {
|
try {
|
||||||
fs.writeFileSync(probe, "x", { encoding: "utf8" });
|
fs.writeFileSync(probe, "x", { encoding: "utf8" });
|
||||||
fs.rmSync(probe, { force: true });
|
fs.rmSync(probe, { force: true });
|
||||||
return true;
|
return true;
|
||||||
} catch {
|
} catch {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function getFreeDiskSpaceBytes(target: string): number | null {
|
function getFreeDiskSpaceBytes(target: string): number | null {
|
||||||
try {
|
try {
|
||||||
const statfs = (fs as unknown as { statfsSync?: (p: string) => { bavail: bigint; bsize: bigint } }).statfsSync;
|
const statfs = (fs as unknown as { statfsSync?: (p: string) => { bavail: bigint; bsize: bigint } }).statfsSync;
|
||||||
if (typeof statfs !== "function") {
|
if (typeof statfs !== "function") {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
const result = statfs(target);
|
const result = statfs(target);
|
||||||
const bavail = BigInt(result.bavail);
|
const bavail = BigInt(result.bavail);
|
||||||
const bsize = BigInt(result.bsize);
|
const bsize = BigInt(result.bsize);
|
||||||
const free = bavail * bsize;
|
const free = bavail * bsize;
|
||||||
if (free > BigInt(Number.MAX_SAFE_INTEGER)) {
|
if (free > BigInt(Number.MAX_SAFE_INTEGER)) {
|
||||||
return Number.MAX_SAFE_INTEGER;
|
return Number.MAX_SAFE_INTEGER;
|
||||||
}
|
}
|
||||||
return Number(free);
|
return Number(free);
|
||||||
} catch {
|
} catch {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function countConfiguredProviders(settings: AppSettings): { count: number; providers: string[] } {
|
function countConfiguredProviders(settings: AppSettings): { count: number; providers: string[] } {
|
||||||
const providers: string[] = [];
|
const providers: string[] = [];
|
||||||
if (settings.token?.trim() || settings.realDebridUseWebLogin) {
|
if (settings.token?.trim() || settings.realDebridUseWebLogin) {
|
||||||
providers.push("Real-Debrid");
|
providers.push("Real-Debrid");
|
||||||
}
|
}
|
||||||
if (settings.allDebridToken?.trim() || settings.allDebridUseWebLogin) {
|
if (settings.allDebridToken?.trim() || settings.allDebridUseWebLogin) {
|
||||||
providers.push("AllDebrid");
|
providers.push("AllDebrid");
|
||||||
}
|
}
|
||||||
if (settings.bestToken?.trim() || settings.bestDebridUseWebLogin) {
|
if (settings.bestToken?.trim() || settings.bestDebridUseWebLogin) {
|
||||||
providers.push("BestDebrid");
|
providers.push("BestDebrid");
|
||||||
}
|
}
|
||||||
if (settings.oneFichierApiKey?.trim()) {
|
if (settings.oneFichierApiKey?.trim()) {
|
||||||
providers.push("1Fichier");
|
providers.push("1Fichier");
|
||||||
}
|
}
|
||||||
if (settings.ddownloadLogin?.trim() && settings.ddownloadPassword?.trim()) {
|
if (settings.ddownloadLogin?.trim() && settings.ddownloadPassword?.trim()) {
|
||||||
providers.push("DDownload");
|
providers.push("DDownload");
|
||||||
}
|
}
|
||||||
if (settings.linkSnappyLogin?.trim() && settings.linkSnappyPassword?.trim()) {
|
if (settings.linkSnappyLogin?.trim() && settings.linkSnappyPassword?.trim()) {
|
||||||
providers.push("LinkSnappy");
|
providers.push("LinkSnappy");
|
||||||
}
|
}
|
||||||
const dlKeys = parseDebridLinkApiKeys(settings.debridLinkApiKeys || "");
|
const dlKeys = parseDebridLinkApiKeys(settings.debridLinkApiKeys || "");
|
||||||
if (dlKeys.length > 0) {
|
if (dlKeys.length > 0) {
|
||||||
providers.push(`Debrid-Link (${dlKeys.length} Key${dlKeys.length === 1 ? "" : "s"})`);
|
providers.push(`Debrid-Link (${dlKeys.length} Key${dlKeys.length === 1 ? "" : "s"})`);
|
||||||
}
|
}
|
||||||
const megaAccounts = parseMegaDebridAccounts(settings.megaCredentials || "");
|
const megaAccounts = parseMegaDebridAccounts(settings.megaCredentials || "");
|
||||||
const legacyMegaConfigured = Boolean(settings.megaLogin?.trim() && settings.megaPassword?.trim());
|
const legacyMegaConfigured = Boolean(settings.megaLogin?.trim() && settings.megaPassword?.trim());
|
||||||
if (megaAccounts.length > 0) {
|
if (megaAccounts.length > 0) {
|
||||||
providers.push(`Mega-Debrid (${megaAccounts.length} Acc)`);
|
providers.push(`Mega-Debrid (${megaAccounts.length} Acc)`);
|
||||||
} else if (legacyMegaConfigured) {
|
} else if (legacyMegaConfigured) {
|
||||||
providers.push("Mega-Debrid");
|
providers.push("Mega-Debrid");
|
||||||
}
|
}
|
||||||
return { count: providers.length, providers };
|
return { count: providers.length, providers };
|
||||||
}
|
}
|
||||||
|
|
||||||
export function runStartupHealthCheck(settings: AppSettings, storagePaths: StoragePaths): HealthCheckReport {
|
export function runStartupHealthCheck(settings: AppSettings, storagePaths: StoragePaths): HealthCheckReport {
|
||||||
const findings: HealthCheckFinding[] = [];
|
const findings: HealthCheckFinding[] = [];
|
||||||
|
|
||||||
const outputDir = String(settings.outputDir || "").trim();
|
const outputDir = String(settings.outputDir || "").trim();
|
||||||
if (!outputDir) {
|
if (!outputDir) {
|
||||||
findings.push({
|
findings.push({
|
||||||
severity: "WARN",
|
severity: "WARN",
|
||||||
code: "outputDir_missing",
|
code: "outputDir_missing",
|
||||||
message: "Kein Download-Ziel-Verzeichnis konfiguriert",
|
message: "Kein Download-Ziel-Verzeichnis konfiguriert",
|
||||||
hint: "In den Einstellungen unter 'Downloads' einen Ziel-Ordner setzen, sonst koennen keine Downloads starten."
|
hint: "In den Einstellungen unter 'Downloads' einen Ziel-Ordner setzen, sonst koennen keine Downloads starten."
|
||||||
});
|
});
|
||||||
} else if (!safeExists(outputDir)) {
|
} else if (!safeExists(outputDir)) {
|
||||||
findings.push({
|
findings.push({
|
||||||
severity: "WARN",
|
severity: "WARN",
|
||||||
code: "outputDir_not_found",
|
code: "outputDir_not_found",
|
||||||
message: `Download-Ziel-Ordner existiert nicht: ${outputDir}`,
|
message: `Download-Ziel-Ordner existiert nicht: ${outputDir}`,
|
||||||
hint: "Der Ordner wird beim ersten Download automatisch erstellt, sofern der Elternordner existiert und beschreibbar ist."
|
hint: "Der Ordner wird beim ersten Download automatisch erstellt, sofern der Elternordner existiert und beschreibbar ist."
|
||||||
});
|
});
|
||||||
} else if (!isWritable(outputDir)) {
|
} else if (!isWritable(outputDir)) {
|
||||||
findings.push({
|
findings.push({
|
||||||
severity: "ERROR",
|
severity: "ERROR",
|
||||||
code: "outputDir_not_writable",
|
code: "outputDir_not_writable",
|
||||||
message: `Download-Ziel-Ordner ist NICHT beschreibbar: ${outputDir}`,
|
message: `Download-Ziel-Ordner ist NICHT beschreibbar: ${outputDir}`,
|
||||||
hint: "Rechte pruefen oder anderen Ordner waehlen. Downloads werden sonst direkt scheitern."
|
hint: "Rechte pruefen oder anderen Ordner waehlen. Downloads werden sonst direkt scheitern."
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
const freeBytes = getFreeDiskSpaceBytes(outputDir);
|
const freeBytes = getFreeDiskSpaceBytes(outputDir);
|
||||||
if (freeBytes !== null && freeBytes < LOW_DISK_SPACE_BYTES) {
|
if (freeBytes !== null && freeBytes < LOW_DISK_SPACE_BYTES) {
|
||||||
const freeMb = Math.round(freeBytes / (1024 * 1024));
|
const freeMb = Math.round(freeBytes / (1024 * 1024));
|
||||||
findings.push({
|
findings.push({
|
||||||
severity: "WARN",
|
severity: "WARN",
|
||||||
code: "low_disk_space",
|
code: "low_disk_space",
|
||||||
message: `Wenig freier Speicher im Download-Ordner: ~${freeMb} MB verfuegbar (Schwelle ${LOW_DISK_SPACE_BYTES / (1024 * 1024 * 1024)} GB)`,
|
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."
|
hint: "Groessere Downloads koennen auf halbem Weg fehlschlagen. Vorher Platz schaffen oder anderen Ordner waehlen."
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const { count, providers } = countConfiguredProviders(settings);
|
const { count, providers } = countConfiguredProviders(settings);
|
||||||
if (count === 0) {
|
if (count === 0) {
|
||||||
findings.push({
|
findings.push({
|
||||||
severity: "WARN",
|
severity: "WARN",
|
||||||
code: "no_provider_configured",
|
code: "no_provider_configured",
|
||||||
message: "Kein Debrid-Provider konfiguriert — Downloads werden nicht funktionieren",
|
message: "Kein Debrid-Provider konfiguriert — Downloads werden nicht funktionieren",
|
||||||
hint: "In den Einstellungen mindestens einen Provider (Real-Debrid, Mega-Debrid, Debrid-Link, ...) einrichten."
|
hint: "In den Einstellungen mindestens einen Provider (Real-Debrid, Mega-Debrid, Debrid-Link, ...) einrichten."
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
findings.push({
|
findings.push({
|
||||||
severity: "INFO",
|
severity: "INFO",
|
||||||
code: "providers_configured",
|
code: "providers_configured",
|
||||||
message: `Konfigurierte Provider: ${providers.join(", ")}`
|
message: `Konfigurierte Provider: ${providers.join(", ")}`
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (safeExists(storagePaths.sessionFile)) {
|
if (safeExists(storagePaths.sessionFile)) {
|
||||||
const sizeBytes = getFileSizeBytes(storagePaths.sessionFile);
|
const sizeBytes = getFileSizeBytes(storagePaths.sessionFile);
|
||||||
if (sizeBytes > LARGE_STATE_FILE_BYTES) {
|
if (sizeBytes > LARGE_STATE_FILE_BYTES) {
|
||||||
const sizeMb = Math.round(sizeBytes / (1024 * 1024));
|
const sizeMb = Math.round(sizeBytes / (1024 * 1024));
|
||||||
findings.push({
|
findings.push({
|
||||||
severity: "WARN",
|
severity: "WARN",
|
||||||
code: "large_state_file",
|
code: "large_state_file",
|
||||||
message: `State-Datei ist sehr gross: ${sizeMb} MB (${path.basename(storagePaths.sessionFile)})`,
|
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."
|
hint: "Alte abgeschlossene Pakete aus der Queue entfernen, damit Startup + Save schneller werden."
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!safeExists(storagePaths.baseDir)) {
|
if (!safeExists(storagePaths.baseDir)) {
|
||||||
findings.push({
|
findings.push({
|
||||||
severity: "ERROR",
|
severity: "ERROR",
|
||||||
code: "baseDir_missing",
|
code: "baseDir_missing",
|
||||||
message: `Runtime-Verzeichnis existiert nicht: ${storagePaths.baseDir}`,
|
message: `Runtime-Verzeichnis existiert nicht: ${storagePaths.baseDir}`,
|
||||||
hint: "Ohne Runtime-Verzeichnis koennen weder Settings noch Session-State persistiert werden."
|
hint: "Ohne Runtime-Verzeichnis koennen weder Settings noch Session-State persistiert werden."
|
||||||
});
|
});
|
||||||
} else if (!isWritable(storagePaths.baseDir)) {
|
} else if (!isWritable(storagePaths.baseDir)) {
|
||||||
findings.push({
|
findings.push({
|
||||||
severity: "ERROR",
|
severity: "ERROR",
|
||||||
code: "baseDir_not_writable",
|
code: "baseDir_not_writable",
|
||||||
message: `Runtime-Verzeichnis ist NICHT beschreibbar: ${storagePaths.baseDir}`,
|
message: `Runtime-Verzeichnis ist NICHT beschreibbar: ${storagePaths.baseDir}`,
|
||||||
hint: "Rechte auf das Runtime-Verzeichnis pruefen (%APPDATA%/Real-Debrid-Downloader/runtime)."
|
hint: "Rechte auf das Runtime-Verzeichnis pruefen (%APPDATA%/Real-Debrid-Downloader/runtime)."
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const errorCount = findings.filter((f) => f.severity === "ERROR").length;
|
const errorCount = findings.filter((f) => f.severity === "ERROR").length;
|
||||||
const warnCount = findings.filter((f) => f.severity === "WARN").length;
|
const warnCount = findings.filter((f) => f.severity === "WARN").length;
|
||||||
const infoCount = findings.filter((f) => f.severity === "INFO").length;
|
const infoCount = findings.filter((f) => f.severity === "INFO").length;
|
||||||
return { findings, errorCount, warnCount, infoCount };
|
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 { promises as fsp } from "node:fs";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import AdmZip from "adm-zip";
|
import AdmZip from "adm-zip";
|
||||||
import { APP_VERSION } from "./constants";
|
import { APP_VERSION } from "./constants";
|
||||||
import { getAccountRotationLogPath } from "./account-rotation-log";
|
import { getAccountRotationLogPath } from "./account-rotation-log";
|
||||||
import { getConversionLogPath } from "./conversion-trace";
|
import { getConversionLogPath } from "./conversion-trace";
|
||||||
import { getAuditLogPath } from "./audit-log";
|
import { getAuditLogPath } from "./audit-log";
|
||||||
import { getDebugSetupCheck } from "./debug-setup";
|
import { getDebugSetupCheck } from "./debug-setup";
|
||||||
import { getLogFilePath } from "./logger";
|
import { getLogFilePath } from "./logger";
|
||||||
import { getRecentErrors } from "./error-ring";
|
import { getRecentErrors } from "./error-ring";
|
||||||
import { getPackageLogPath } from "./package-log";
|
import { getPackageLogPath } from "./package-log";
|
||||||
import { getRenameLogPath } from "./rename-log";
|
import { getRenameLogPath } from "./rename-log";
|
||||||
import { getDesktopRenameLogPath } from "./desktop-rename-log";
|
import { getDesktopRenameLogPath } from "./desktop-rename-log";
|
||||||
import { getSessionLogPath } from "./session-log";
|
import { getSessionLogPath } from "./session-log";
|
||||||
import { createStoragePaths, loadHistory, loadSettings } from "./storage";
|
import { createStoragePaths, loadHistory, loadSettings } from "./storage";
|
||||||
import { buildAccountSummary, buildRedactedSettingsPayload, buildStatsPayload, summarizeHistoryEntry } from "./support-data";
|
import { buildAccountSummary, buildRedactedSettingsPayload, buildStatsPayload, summarizeHistoryEntry } from "./support-data";
|
||||||
import { getTraceConfig, getTraceConfigPath, getTraceLogPath } from "./trace-log";
|
import { getTraceConfig, getTraceConfigPath, getTraceLogPath } from "./trace-log";
|
||||||
import { getCachedWindowsHostDiagnostics, getWindowsHostDiagnostics } from "./windows-host-diagnostics";
|
import { getCachedWindowsHostDiagnostics, getWindowsHostDiagnostics } from "./windows-host-diagnostics";
|
||||||
import type { DownloadManager } from "./download-manager";
|
import type { DownloadManager } from "./download-manager";
|
||||||
|
|
||||||
const SUPPORT_MANIFEST_FILE = "debug_support_manifest.json";
|
const AI_MANIFEST_FILE = "debug_ai_manifest.json";
|
||||||
|
|
||||||
async function safeReadJson(filePath: string): Promise<unknown> {
|
async function safeReadJson(filePath: string): Promise<unknown> {
|
||||||
try {
|
try {
|
||||||
return JSON.parse(await fsp.readFile(filePath, "utf8")) as unknown;
|
return JSON.parse(await fsp.readFile(filePath, "utf8")) as unknown;
|
||||||
} catch {
|
} catch {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function addJson(zip: AdmZip, zipPath: string, value: unknown): void {
|
function addJson(zip: AdmZip, zipPath: string, value: unknown): void {
|
||||||
zip.addFile(zipPath, Buffer.from(`${JSON.stringify(value, null, 2)}\n`, "utf8"));
|
zip.addFile(zipPath, Buffer.from(`${JSON.stringify(value, null, 2)}\n`, "utf8"));
|
||||||
}
|
}
|
||||||
|
|
||||||
async function addFileIfExists(zip: AdmZip, sourcePath: string | null, zipPath: string): Promise<void> {
|
async function addFileIfExists(zip: AdmZip, sourcePath: string | null, zipPath: string): Promise<void> {
|
||||||
if (!sourcePath) {
|
if (!sourcePath) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const buffer = await fsp.readFile(sourcePath);
|
const buffer = await fsp.readFile(sourcePath);
|
||||||
zip.addFile(zipPath, buffer);
|
zip.addFile(zipPath, buffer);
|
||||||
} catch {
|
} catch {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function addDirectoryIfExists(zip: AdmZip, dirPath: string, zipRoot: string): Promise<void> {
|
async function addDirectoryIfExists(zip: AdmZip, dirPath: string, zipRoot: string): Promise<void> {
|
||||||
let entries;
|
let entries;
|
||||||
try {
|
try {
|
||||||
entries = await fsp.readdir(dirPath, { withFileTypes: true });
|
entries = await fsp.readdir(dirPath, { withFileTypes: true });
|
||||||
} catch {
|
} catch {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
for (const entry of entries) {
|
for (const entry of entries) {
|
||||||
const fullPath = path.join(dirPath, entry.name);
|
const fullPath = path.join(dirPath, entry.name);
|
||||||
const zipPath = path.posix.join(zipRoot, entry.name);
|
const zipPath = path.posix.join(zipRoot, entry.name);
|
||||||
if (entry.isDirectory()) {
|
if (entry.isDirectory()) {
|
||||||
await addDirectoryIfExists(zip, fullPath, zipPath);
|
await addDirectoryIfExists(zip, fullPath, zipPath);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
await addFileIfExists(zip, fullPath, zipPath);
|
await addFileIfExists(zip, fullPath, zipPath);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function addRecentDirectoryFiles(zip: AdmZip, dirPath: string, zipRoot: string, maxAgeMs: number): Promise<number> {
|
async function addRecentDirectoryFiles(zip: AdmZip, dirPath: string, zipRoot: string, maxAgeMs: number): Promise<number> {
|
||||||
let entries;
|
let entries;
|
||||||
try {
|
try {
|
||||||
entries = await fsp.readdir(dirPath, { withFileTypes: true });
|
entries = await fsp.readdir(dirPath, { withFileTypes: true });
|
||||||
} catch {
|
} catch {
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
const cutoff = Date.now() - maxAgeMs;
|
const cutoff = Date.now() - maxAgeMs;
|
||||||
let added = 0;
|
let added = 0;
|
||||||
for (const entry of entries) {
|
for (const entry of entries) {
|
||||||
if (!entry.isFile()) continue;
|
if (!entry.isFile()) continue;
|
||||||
const fullPath = path.join(dirPath, entry.name);
|
const fullPath = path.join(dirPath, entry.name);
|
||||||
try {
|
try {
|
||||||
if ((await fsp.stat(fullPath)).mtimeMs >= cutoff) {
|
if ((await fsp.stat(fullPath)).mtimeMs >= cutoff) {
|
||||||
await addFileIfExists(zip, fullPath, path.posix.join(zipRoot, entry.name));
|
await addFileIfExists(zip, fullPath, path.posix.join(zipRoot, entry.name));
|
||||||
added += 1;
|
added += 1;
|
||||||
}
|
}
|
||||||
} catch { }
|
} catch { }
|
||||||
}
|
}
|
||||||
return added;
|
return added;
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatTimestampForFileName(date: Date): string {
|
function formatTimestampForFileName(date: Date): string {
|
||||||
const y = date.getFullYear();
|
const y = date.getFullYear();
|
||||||
const mo = String(date.getMonth() + 1).padStart(2, "0");
|
const mo = String(date.getMonth() + 1).padStart(2, "0");
|
||||||
const d = String(date.getDate()).padStart(2, "0");
|
const d = String(date.getDate()).padStart(2, "0");
|
||||||
const h = String(date.getHours()).padStart(2, "0");
|
const h = String(date.getHours()).padStart(2, "0");
|
||||||
const mi = String(date.getMinutes()).padStart(2, "0");
|
const mi = String(date.getMinutes()).padStart(2, "0");
|
||||||
const s = String(date.getSeconds()).padStart(2, "0");
|
const s = String(date.getSeconds()).padStart(2, "0");
|
||||||
return `${y}-${mo}-${d}_${h}-${mi}-${s}`;
|
return `${y}-${mo}-${d}_${h}-${mi}-${s}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getSupportBundleDefaultFileName(): string {
|
export function getSupportBundleDefaultFileName(): string {
|
||||||
return `rd-support-bundle-${formatTimestampForFileName(new Date())}.zip`;
|
return `rd-support-bundle-${formatTimestampForFileName(new Date())}.zip`;
|
||||||
}
|
}
|
||||||
|
|
||||||
type HostDiagnosticsMode = "full" | "cached" | "none";
|
type HostDiagnosticsMode = "full" | "cached" | "none";
|
||||||
|
|
||||||
interface BuildSupportBundleOptions {
|
interface BuildSupportBundleOptions {
|
||||||
hostDiagnosticsMode?: HostDiagnosticsMode;
|
hostDiagnosticsMode?: HostDiagnosticsMode;
|
||||||
}
|
}
|
||||||
|
|
||||||
function createDeferredHostDiagnostics(reason: string): unknown {
|
function createDeferredHostDiagnostics(reason: string): unknown {
|
||||||
return {
|
return {
|
||||||
collectedAt: new Date().toISOString(),
|
collectedAt: new Date().toISOString(),
|
||||||
supported: process.platform === "win32",
|
supported: process.platform === "win32",
|
||||||
platform: process.platform,
|
platform: process.platform,
|
||||||
crashControl: null,
|
crashControl: null,
|
||||||
recentKernelPower: [],
|
recentKernelPower: [],
|
||||||
recentWerKernel: [],
|
recentWerKernel: [],
|
||||||
recentKernelDump: [],
|
recentKernelDump: [],
|
||||||
recentAppCrashes: [],
|
recentAppCrashes: [],
|
||||||
recentMinidumps: [],
|
recentMinidumps: [],
|
||||||
assessmentHints: [
|
assessmentHints: [
|
||||||
reason
|
reason
|
||||||
],
|
],
|
||||||
errors: []
|
errors: []
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function resolveHostDiagnostics(mode: HostDiagnosticsMode): unknown {
|
function resolveHostDiagnostics(mode: HostDiagnosticsMode): unknown {
|
||||||
if (mode === "none") {
|
if (mode === "none") {
|
||||||
return createDeferredHostDiagnostics("Host-Diagnose wurde fuer diesen Bundle-Export deaktiviert.");
|
return createDeferredHostDiagnostics("Host-Diagnose wurde fuer diesen Bundle-Export deaktiviert.");
|
||||||
}
|
}
|
||||||
if (mode === "cached") {
|
if (mode === "cached") {
|
||||||
const cached = getCachedWindowsHostDiagnostics();
|
const cached = getCachedWindowsHostDiagnostics();
|
||||||
if (cached) {
|
if (cached) {
|
||||||
return cached;
|
return cached;
|
||||||
}
|
}
|
||||||
return createDeferredHostDiagnostics("Host-Diagnose wurde uebersprungen, um den Export nicht zu blockieren. Fuer eine Voll-Diagnose /host/diagnostics nutzen.");
|
return createDeferredHostDiagnostics("Host-Diagnose wurde uebersprungen, um den Export nicht zu blockieren. Fuer eine Voll-Diagnose /host/diagnostics nutzen.");
|
||||||
}
|
}
|
||||||
return getWindowsHostDiagnostics();
|
return getWindowsHostDiagnostics();
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function buildSupportBundle(manager: DownloadManager, baseDir: string, options: BuildSupportBundleOptions = {}): Promise<Buffer> {
|
export async function buildSupportBundle(manager: DownloadManager, baseDir: string, options: BuildSupportBundleOptions = {}): Promise<Buffer> {
|
||||||
const zip = new AdmZip();
|
const zip = new AdmZip();
|
||||||
const hostDiagnosticsMode = options.hostDiagnosticsMode || "full";
|
const hostDiagnosticsMode = options.hostDiagnosticsMode || "full";
|
||||||
const storagePaths = createStoragePaths(baseDir);
|
const storagePaths = createStoragePaths(baseDir);
|
||||||
const settings = loadSettings(storagePaths);
|
const settings = loadSettings(storagePaths);
|
||||||
const history = loadHistory(storagePaths);
|
const history = loadHistory(storagePaths);
|
||||||
const snapshot = manager.getSnapshot();
|
const snapshot = manager.getSnapshot();
|
||||||
const packageIds = Object.keys(snapshot.session.packages);
|
const packageIds = Object.keys(snapshot.session.packages);
|
||||||
const itemIds = Object.keys(snapshot.session.items);
|
const itemIds = Object.keys(snapshot.session.items);
|
||||||
const debugSetup = getDebugSetupCheck(baseDir);
|
const debugSetup = getDebugSetupCheck(baseDir);
|
||||||
|
|
||||||
addJson(zip, "overview/meta.json", {
|
addJson(zip, "overview/meta.json", {
|
||||||
appVersion: APP_VERSION,
|
appVersion: APP_VERSION,
|
||||||
generatedAt: new Date().toISOString(),
|
generatedAt: new Date().toISOString(),
|
||||||
runtimeBaseDir: baseDir,
|
runtimeBaseDir: baseDir,
|
||||||
packageCount: packageIds.length,
|
packageCount: packageIds.length,
|
||||||
itemCount: itemIds.length
|
itemCount: itemIds.length
|
||||||
});
|
});
|
||||||
addJson(zip, "overview/status.json", snapshot.session);
|
addJson(zip, "overview/status.json", snapshot.session);
|
||||||
addJson(zip, "overview/settings.json", buildRedactedSettingsPayload(settings));
|
addJson(zip, "overview/settings.json", buildRedactedSettingsPayload(settings));
|
||||||
addJson(zip, "overview/accounts.json", buildAccountSummary(settings));
|
addJson(zip, "overview/accounts.json", buildAccountSummary(settings));
|
||||||
addJson(zip, "overview/stats.json", {
|
addJson(zip, "overview/stats.json", {
|
||||||
...buildStatsPayload(snapshot),
|
...buildStatsPayload(snapshot),
|
||||||
allTime: {
|
allTime: {
|
||||||
totalDownloadedAllTime: settings.totalDownloadedAllTime,
|
totalDownloadedAllTime: settings.totalDownloadedAllTime,
|
||||||
totalCompletedFilesAllTime: settings.totalCompletedFilesAllTime,
|
totalCompletedFilesAllTime: settings.totalCompletedFilesAllTime,
|
||||||
totalRuntimeAllTimeMs: settings.totalRuntimeAllTimeMs
|
totalRuntimeAllTimeMs: settings.totalRuntimeAllTimeMs
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
addJson(zip, "overview/debug-setup.json", debugSetup);
|
addJson(zip, "overview/debug-setup.json", debugSetup);
|
||||||
addJson(zip, "overview/self-check.json", debugSetup);
|
addJson(zip, "overview/self-check.json", debugSetup);
|
||||||
addJson(zip, "overview/history.json", {
|
addJson(zip, "overview/history.json", {
|
||||||
total: history.length,
|
total: history.length,
|
||||||
entries: history.map((entry) => summarizeHistoryEntry(entry))
|
entries: history.map((entry) => summarizeHistoryEntry(entry))
|
||||||
});
|
});
|
||||||
addJson(zip, "overview/packages.json", {
|
addJson(zip, "overview/packages.json", {
|
||||||
count: packageIds.length,
|
count: packageIds.length,
|
||||||
packages: packageIds.map((packageId) => snapshot.session.packages[packageId]).filter(Boolean)
|
packages: packageIds.map((packageId) => snapshot.session.packages[packageId]).filter(Boolean)
|
||||||
});
|
});
|
||||||
addJson(zip, "overview/items.json", {
|
addJson(zip, "overview/items.json", {
|
||||||
count: itemIds.length,
|
count: itemIds.length,
|
||||||
items: itemIds.map((itemId) => snapshot.session.items[itemId]).filter(Boolean)
|
items: itemIds.map((itemId) => snapshot.session.items[itemId]).filter(Boolean)
|
||||||
});
|
});
|
||||||
addJson(zip, "overview/host-diagnostics.json", resolveHostDiagnostics(hostDiagnosticsMode));
|
addJson(zip, "overview/host-diagnostics.json", resolveHostDiagnostics(hostDiagnosticsMode));
|
||||||
addJson(zip, "overview/trace-config.json", getTraceConfig());
|
addJson(zip, "overview/trace-config.json", getTraceConfig());
|
||||||
const recentErrors = getRecentErrors();
|
const recentErrors = getRecentErrors();
|
||||||
addJson(zip, "overview/recent-errors.json", { count: recentErrors.length, entries: recentErrors });
|
addJson(zip, "overview/recent-errors.json", { count: recentErrors.length, entries: recentErrors });
|
||||||
|
|
||||||
await addFileIfExists(zip, path.join(baseDir, SUPPORT_MANIFEST_FILE), `runtime/${SUPPORT_MANIFEST_FILE}`);
|
await addFileIfExists(zip, path.join(baseDir, AI_MANIFEST_FILE), `runtime/${AI_MANIFEST_FILE}`);
|
||||||
await addFileIfExists(zip, path.join(baseDir, "debug_host.txt"), "runtime/debug_host.txt");
|
await addFileIfExists(zip, path.join(baseDir, "debug_host.txt"), "runtime/debug_host.txt");
|
||||||
await addFileIfExists(zip, path.join(baseDir, "debug_port.txt"), "runtime/debug_port.txt");
|
await addFileIfExists(zip, path.join(baseDir, "debug_port.txt"), "runtime/debug_port.txt");
|
||||||
await addFileIfExists(zip, getTraceConfigPath(), "runtime/trace_config.json");
|
await addFileIfExists(zip, getTraceConfigPath(), "runtime/trace_config.json");
|
||||||
|
|
||||||
await addFileIfExists(zip, getLogFilePath(), "logs/rd_downloader.log");
|
await addFileIfExists(zip, getLogFilePath(), "logs/rd_downloader.log");
|
||||||
await addFileIfExists(zip, `${getLogFilePath()}.old`, "logs/rd_downloader.log.old");
|
await addFileIfExists(zip, `${getLogFilePath()}.old`, "logs/rd_downloader.log.old");
|
||||||
await addFileIfExists(zip, getAuditLogPath(), "logs/audit.log");
|
await addFileIfExists(zip, getAuditLogPath(), "logs/audit.log");
|
||||||
await addFileIfExists(zip, getAuditLogPath() ? `${getAuditLogPath()}.old` : null, "logs/audit.log.old");
|
await addFileIfExists(zip, getAuditLogPath() ? `${getAuditLogPath()}.old` : null, "logs/audit.log.old");
|
||||||
await addFileIfExists(zip, getRenameLogPath(), "logs/rename.log");
|
await addFileIfExists(zip, getRenameLogPath(), "logs/rename.log");
|
||||||
await addFileIfExists(zip, getRenameLogPath() ? `${getRenameLogPath()}.old` : null, "logs/rename.log.old");
|
await addFileIfExists(zip, getRenameLogPath() ? `${getRenameLogPath()}.old` : null, "logs/rename.log.old");
|
||||||
await addFileIfExists(zip, getDesktopRenameLogPath(), "logs/rename-session-desktop.txt");
|
await addFileIfExists(zip, getDesktopRenameLogPath(), "logs/rename-session-desktop.txt");
|
||||||
await addFileIfExists(zip, getSessionLogPath(), "logs/session.log");
|
await addFileIfExists(zip, getSessionLogPath(), "logs/session.log");
|
||||||
await addFileIfExists(zip, getTraceLogPath(), "logs/trace.log");
|
await addFileIfExists(zip, getTraceLogPath(), "logs/trace.log");
|
||||||
await addFileIfExists(zip, getTraceLogPath() ? `${getTraceLogPath()}.old` : null, "logs/trace.log.old");
|
await addFileIfExists(zip, getTraceLogPath() ? `${getTraceLogPath()}.old` : null, "logs/trace.log.old");
|
||||||
await addFileIfExists(zip, getAccountRotationLogPath(), "logs/account-rotation.log");
|
await addFileIfExists(zip, getAccountRotationLogPath(), "logs/account-rotation.log");
|
||||||
await addFileIfExists(zip, getAccountRotationLogPath() ? `${getAccountRotationLogPath()}.old` : null, "logs/account-rotation.log.old");
|
await addFileIfExists(zip, getAccountRotationLogPath() ? `${getAccountRotationLogPath()}.old` : null, "logs/account-rotation.log.old");
|
||||||
await addFileIfExists(zip, getConversionLogPath(), "logs/conversion.log");
|
await addFileIfExists(zip, getConversionLogPath(), "logs/conversion.log");
|
||||||
await addFileIfExists(zip, getConversionLogPath() ? `${getConversionLogPath()}.old` : null, "logs/conversion.log.old");
|
await addFileIfExists(zip, getConversionLogPath() ? `${getConversionLogPath()}.old` : null, "logs/conversion.log.old");
|
||||||
|
|
||||||
const SUPPORT_BUNDLE_LOG_WINDOW_MS = 8 * 60 * 60 * 1000;
|
const SUPPORT_BUNDLE_LOG_WINDOW_MS = 8 * 60 * 60 * 1000;
|
||||||
await addDirectoryIfExists(zip, path.join(baseDir, "session-logs"), "logs/session-logs");
|
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, "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);
|
await addRecentDirectoryFiles(zip, path.join(baseDir, "item-logs"), "logs/item-logs", SUPPORT_BUNDLE_LOG_WINDOW_MS);
|
||||||
|
|
||||||
for (const packageId of packageIds) {
|
for (const packageId of packageIds) {
|
||||||
await addFileIfExists(zip, manager.getPackageLogPath(packageId) || getPackageLogPath(packageId), `logs/live/package-${packageId}.txt`);
|
await addFileIfExists(zip, manager.getPackageLogPath(packageId) || getPackageLogPath(packageId), `logs/live/package-${packageId}.txt`);
|
||||||
}
|
}
|
||||||
for (const itemId of itemIds) {
|
for (const itemId of itemIds) {
|
||||||
await addFileIfExists(zip, manager.getItemLogPath(itemId), `logs/live/item-${itemId}.txt`);
|
await addFileIfExists(zip, manager.getItemLogPath(itemId), `logs/live/item-${itemId}.txt`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const supportManifest = await safeReadJson(path.join(baseDir, SUPPORT_MANIFEST_FILE));
|
const aiManifest = await safeReadJson(path.join(baseDir, AI_MANIFEST_FILE));
|
||||||
if (supportManifest) {
|
if (aiManifest) {
|
||||||
addJson(zip, "overview/support-manifest.json", supportManifest);
|
addJson(zip, "overview/ai-manifest.json", aiManifest);
|
||||||
}
|
}
|
||||||
|
|
||||||
return zip.toBuffer();
|
return zip.toBuffer();
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,188 +1,188 @@
|
|||||||
import { getDebridLinkApiKeyIds } from "../shared/debrid-link-keys";
|
import { getDebridLinkApiKeyIds } from "../shared/debrid-link-keys";
|
||||||
import { isNotifyUrlValid } from "./notify";
|
import { isNotifyUrlValid } from "./notify";
|
||||||
import type { AppSettings, HistoryEntry, UiSnapshot } from "../shared/types";
|
import type { AppSettings, HistoryEntry, UiSnapshot } from "../shared/types";
|
||||||
|
|
||||||
function hasText(value: unknown): boolean {
|
function hasText(value: unknown): boolean {
|
||||||
return String(value || "").trim().length > 0;
|
return String(value || "").trim().length > 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function buildAccountSummary(settings: AppSettings): Record<string, unknown> {
|
export function buildAccountSummary(settings: AppSettings): Record<string, unknown> {
|
||||||
const debridLinkKeyIds = getDebridLinkApiKeyIds(settings.debridLinkApiKeys);
|
const debridLinkKeyIds = getDebridLinkApiKeyIds(settings.debridLinkApiKeys);
|
||||||
const disabledDebridLinkIds = new Set(settings.debridLinkDisabledKeyIds || []);
|
const disabledDebridLinkIds = new Set(settings.debridLinkDisabledKeyIds || []);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
realDebrid: {
|
realDebrid: {
|
||||||
configured: hasText(settings.token) || settings.realDebridUseWebLogin,
|
configured: hasText(settings.token) || settings.realDebridUseWebLogin,
|
||||||
tokenConfigured: hasText(settings.token),
|
tokenConfigured: hasText(settings.token),
|
||||||
webLoginEnabled: settings.realDebridUseWebLogin,
|
webLoginEnabled: settings.realDebridUseWebLogin,
|
||||||
rememberToken: settings.rememberToken
|
rememberToken: settings.rememberToken
|
||||||
},
|
},
|
||||||
megaDebrid: {
|
megaDebrid: {
|
||||||
configured: (hasText(settings.megaLogin) && hasText(settings.megaPassword))
|
configured: (hasText(settings.megaLogin) && hasText(settings.megaPassword))
|
||||||
|| settings.megaDebridApiEnabled
|
|| settings.megaDebridApiEnabled
|
||||||
|| settings.megaDebridWebEnabled,
|
|| settings.megaDebridWebEnabled,
|
||||||
loginConfigured: hasText(settings.megaLogin) && hasText(settings.megaPassword),
|
loginConfigured: hasText(settings.megaLogin) && hasText(settings.megaPassword),
|
||||||
apiEnabled: settings.megaDebridApiEnabled,
|
apiEnabled: settings.megaDebridApiEnabled,
|
||||||
webEnabled: settings.megaDebridWebEnabled,
|
webEnabled: settings.megaDebridWebEnabled,
|
||||||
preferApi: settings.megaDebridPreferApi
|
preferApi: settings.megaDebridPreferApi
|
||||||
},
|
},
|
||||||
bestDebrid: {
|
bestDebrid: {
|
||||||
configured: hasText(settings.bestToken) || settings.bestDebridUseWebLogin,
|
configured: hasText(settings.bestToken) || settings.bestDebridUseWebLogin,
|
||||||
tokenConfigured: hasText(settings.bestToken),
|
tokenConfigured: hasText(settings.bestToken),
|
||||||
webLoginEnabled: settings.bestDebridUseWebLogin
|
webLoginEnabled: settings.bestDebridUseWebLogin
|
||||||
},
|
},
|
||||||
allDebrid: {
|
allDebrid: {
|
||||||
configured: hasText(settings.allDebridToken) || settings.allDebridUseWebLogin,
|
configured: hasText(settings.allDebridToken) || settings.allDebridUseWebLogin,
|
||||||
tokenConfigured: hasText(settings.allDebridToken),
|
tokenConfigured: hasText(settings.allDebridToken),
|
||||||
webLoginEnabled: settings.allDebridUseWebLogin
|
webLoginEnabled: settings.allDebridUseWebLogin
|
||||||
},
|
},
|
||||||
ddownload: {
|
ddownload: {
|
||||||
configured: hasText(settings.ddownloadLogin) && hasText(settings.ddownloadPassword)
|
configured: hasText(settings.ddownloadLogin) && hasText(settings.ddownloadPassword)
|
||||||
},
|
},
|
||||||
oneFichier: {
|
oneFichier: {
|
||||||
configured: hasText(settings.oneFichierApiKey)
|
configured: hasText(settings.oneFichierApiKey)
|
||||||
},
|
},
|
||||||
debridLink: {
|
debridLink: {
|
||||||
configured: debridLinkKeyIds.length > 0,
|
configured: debridLinkKeyIds.length > 0,
|
||||||
keyCount: debridLinkKeyIds.length,
|
keyCount: debridLinkKeyIds.length,
|
||||||
enabledKeyCount: debridLinkKeyIds.filter((id) => !disabledDebridLinkIds.has(id)).length,
|
enabledKeyCount: debridLinkKeyIds.filter((id) => !disabledDebridLinkIds.has(id)).length,
|
||||||
disabledKeyCount: debridLinkKeyIds.filter((id) => disabledDebridLinkIds.has(id)).length
|
disabledKeyCount: debridLinkKeyIds.filter((id) => disabledDebridLinkIds.has(id)).length
|
||||||
},
|
},
|
||||||
linkSnappy: {
|
linkSnappy: {
|
||||||
configured: hasText(settings.linkSnappyLogin) && hasText(settings.linkSnappyPassword)
|
configured: hasText(settings.linkSnappyLogin) && hasText(settings.linkSnappyPassword)
|
||||||
},
|
},
|
||||||
disabledProviders: [...(settings.disabledProviders || [])]
|
disabledProviders: [...(settings.disabledProviders || [])]
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function diffAccountSummary(previous: AppSettings, next: AppSettings): Record<string, unknown> {
|
export function diffAccountSummary(previous: AppSettings, next: AppSettings): Record<string, unknown> {
|
||||||
const before = buildAccountSummary(previous);
|
const before = buildAccountSummary(previous);
|
||||||
const after = buildAccountSummary(next);
|
const after = buildAccountSummary(next);
|
||||||
const changes: Record<string, unknown> = {};
|
const changes: Record<string, unknown> = {};
|
||||||
for (const key of Object.keys(after)) {
|
for (const key of Object.keys(after)) {
|
||||||
const beforeJson = JSON.stringify(before[key]);
|
const beforeJson = JSON.stringify(before[key]);
|
||||||
const afterJson = JSON.stringify(after[key]);
|
const afterJson = JSON.stringify(after[key]);
|
||||||
if (beforeJson !== afterJson) {
|
if (beforeJson !== afterJson) {
|
||||||
changes[key] = after[key];
|
changes[key] = after[key];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return changes;
|
return changes;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function buildRedactedSettingsPayload(settings: AppSettings): Record<string, unknown> {
|
export function buildRedactedSettingsPayload(settings: AppSettings): Record<string, unknown> {
|
||||||
return {
|
return {
|
||||||
paths: {
|
paths: {
|
||||||
outputDir: settings.outputDir,
|
outputDir: settings.outputDir,
|
||||||
extractDir: settings.extractDir,
|
extractDir: settings.extractDir,
|
||||||
mkvLibraryDir: settings.mkvLibraryDir
|
mkvLibraryDir: settings.mkvLibraryDir
|
||||||
},
|
},
|
||||||
providers: {
|
providers: {
|
||||||
providerOrder: settings.providerOrder,
|
providerOrder: settings.providerOrder,
|
||||||
providerPrimary: settings.providerPrimary,
|
providerPrimary: settings.providerPrimary,
|
||||||
providerSecondary: settings.providerSecondary,
|
providerSecondary: settings.providerSecondary,
|
||||||
providerTertiary: settings.providerTertiary,
|
providerTertiary: settings.providerTertiary,
|
||||||
autoProviderFallback: settings.autoProviderFallback,
|
autoProviderFallback: settings.autoProviderFallback,
|
||||||
disabledProviders: settings.disabledProviders,
|
disabledProviders: settings.disabledProviders,
|
||||||
hosterRouting: settings.hosterRouting
|
hosterRouting: settings.hosterRouting
|
||||||
},
|
},
|
||||||
extraction: {
|
extraction: {
|
||||||
autoExtract: settings.autoExtract,
|
autoExtract: settings.autoExtract,
|
||||||
autoExtractWhenStopped: settings.autoExtractWhenStopped,
|
autoExtractWhenStopped: settings.autoExtractWhenStopped,
|
||||||
hybridExtract: settings.hybridExtract,
|
hybridExtract: settings.hybridExtract,
|
||||||
createExtractSubfolder: settings.createExtractSubfolder,
|
createExtractSubfolder: settings.createExtractSubfolder,
|
||||||
cleanupMode: settings.cleanupMode,
|
cleanupMode: settings.cleanupMode,
|
||||||
extractConflictMode: settings.extractConflictMode,
|
extractConflictMode: settings.extractConflictMode,
|
||||||
removeLinkFilesAfterExtract: settings.removeLinkFilesAfterExtract,
|
removeLinkFilesAfterExtract: settings.removeLinkFilesAfterExtract,
|
||||||
removeSamplesAfterExtract: settings.removeSamplesAfterExtract,
|
removeSamplesAfterExtract: settings.removeSamplesAfterExtract,
|
||||||
enableIntegrityCheck: settings.enableIntegrityCheck,
|
enableIntegrityCheck: settings.enableIntegrityCheck,
|
||||||
archivePasswordCount: String(settings.archivePasswordList || "")
|
archivePasswordCount: String(settings.archivePasswordList || "")
|
||||||
.split(/\r?\n/)
|
.split(/\r?\n/)
|
||||||
.map((line) => line.trim())
|
.map((line) => line.trim())
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.length,
|
.length,
|
||||||
extractCpuPriority: settings.extractCpuPriority,
|
extractCpuPriority: settings.extractCpuPriority,
|
||||||
maxParallelExtract: settings.maxParallelExtract
|
maxParallelExtract: settings.maxParallelExtract
|
||||||
},
|
},
|
||||||
downloads: {
|
downloads: {
|
||||||
maxParallel: settings.maxParallel,
|
maxParallel: settings.maxParallel,
|
||||||
retryLimit: settings.retryLimit,
|
retryLimit: settings.retryLimit,
|
||||||
autoResumeOnStart: settings.autoResumeOnStart,
|
autoResumeOnStart: settings.autoResumeOnStart,
|
||||||
autoReconnect: settings.autoReconnect,
|
autoReconnect: settings.autoReconnect,
|
||||||
reconnectWaitSeconds: settings.reconnectWaitSeconds,
|
reconnectWaitSeconds: settings.reconnectWaitSeconds,
|
||||||
autoSkipExtracted: settings.autoSkipExtracted,
|
autoSkipExtracted: settings.autoSkipExtracted,
|
||||||
completedCleanupPolicy: settings.completedCleanupPolicy
|
completedCleanupPolicy: settings.completedCleanupPolicy
|
||||||
},
|
},
|
||||||
ui: {
|
ui: {
|
||||||
packageName: settings.packageName,
|
packageName: settings.packageName,
|
||||||
theme: settings.theme,
|
theme: settings.theme,
|
||||||
collapseNewPackages: settings.collapseNewPackages,
|
collapseNewPackages: settings.collapseNewPackages,
|
||||||
hideExtractedItems: settings.hideExtractedItems,
|
hideExtractedItems: settings.hideExtractedItems,
|
||||||
confirmDeleteSelection: settings.confirmDeleteSelection,
|
confirmDeleteSelection: settings.confirmDeleteSelection,
|
||||||
clipboardWatch: settings.clipboardWatch,
|
clipboardWatch: settings.clipboardWatch,
|
||||||
minimizeToTray: settings.minimizeToTray,
|
minimizeToTray: settings.minimizeToTray,
|
||||||
columnOrder: settings.columnOrder
|
columnOrder: settings.columnOrder
|
||||||
},
|
},
|
||||||
bandwidth: {
|
bandwidth: {
|
||||||
speedLimitEnabled: settings.speedLimitEnabled,
|
speedLimitEnabled: settings.speedLimitEnabled,
|
||||||
speedLimitKbps: settings.speedLimitKbps,
|
speedLimitKbps: settings.speedLimitKbps,
|
||||||
speedLimitMode: settings.speedLimitMode,
|
speedLimitMode: settings.speedLimitMode,
|
||||||
bandwidthSchedules: settings.bandwidthSchedules
|
bandwidthSchedules: settings.bandwidthSchedules
|
||||||
},
|
},
|
||||||
updates: {
|
updates: {
|
||||||
updateRepo: settings.updateRepo,
|
updateRepo: settings.updateRepo,
|
||||||
autoUpdateCheck: settings.autoUpdateCheck
|
autoUpdateCheck: settings.autoUpdateCheck
|
||||||
},
|
},
|
||||||
notifications: {
|
notifications: {
|
||||||
notifyUrlConfigured: Boolean(String(settings.notifyUrl || "").trim()),
|
notifyUrlConfigured: Boolean(String(settings.notifyUrl || "").trim()),
|
||||||
notifyUrlLooksValid: isNotifyUrlValid(settings.notifyUrl),
|
notifyUrlLooksValid: isNotifyUrlValid(settings.notifyUrl),
|
||||||
notifyMentionConfigured: Boolean(String(settings.notifyMention || "").trim()),
|
notifyMentionConfigured: Boolean(String(settings.notifyMention || "").trim()),
|
||||||
notifyOnPackageCompleted: settings.notifyOnPackageCompleted,
|
notifyOnPackageCompleted: settings.notifyOnPackageCompleted,
|
||||||
notifyOnPackageFailed: settings.notifyOnPackageFailed,
|
notifyOnPackageFailed: settings.notifyOnPackageFailed,
|
||||||
notifyOnRunFinished: settings.notifyOnRunFinished
|
notifyOnRunFinished: settings.notifyOnRunFinished
|
||||||
},
|
},
|
||||||
statistics: {
|
statistics: {
|
||||||
totalDownloadedAllTime: settings.totalDownloadedAllTime,
|
totalDownloadedAllTime: settings.totalDownloadedAllTime,
|
||||||
totalCompletedFilesAllTime: settings.totalCompletedFilesAllTime,
|
totalCompletedFilesAllTime: settings.totalCompletedFilesAllTime,
|
||||||
totalRuntimeAllTimeMs: settings.totalRuntimeAllTimeMs,
|
totalRuntimeAllTimeMs: settings.totalRuntimeAllTimeMs,
|
||||||
providerDailyLimitBytes: settings.providerDailyLimitBytes,
|
providerDailyLimitBytes: settings.providerDailyLimitBytes,
|
||||||
providerDailyUsageBytes: settings.providerDailyUsageBytes,
|
providerDailyUsageBytes: settings.providerDailyUsageBytes,
|
||||||
providerTotalUsageBytes: settings.providerTotalUsageBytes,
|
providerTotalUsageBytes: settings.providerTotalUsageBytes,
|
||||||
debridLinkApiKeyDailyLimitBytes: settings.debridLinkApiKeyDailyLimitBytes,
|
debridLinkApiKeyDailyLimitBytes: settings.debridLinkApiKeyDailyLimitBytes,
|
||||||
debridLinkApiKeyDailyUsageBytes: settings.debridLinkApiKeyDailyUsageBytes,
|
debridLinkApiKeyDailyUsageBytes: settings.debridLinkApiKeyDailyUsageBytes,
|
||||||
debridLinkApiKeyTotalUsageBytes: settings.debridLinkApiKeyTotalUsageBytes,
|
debridLinkApiKeyTotalUsageBytes: settings.debridLinkApiKeyTotalUsageBytes,
|
||||||
providerDailyUsageDay: settings.providerDailyUsageDay
|
providerDailyUsageDay: settings.providerDailyUsageDay
|
||||||
},
|
},
|
||||||
accounts: buildAccountSummary(settings)
|
accounts: buildAccountSummary(settings)
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function buildStatsPayload(snapshot: UiSnapshot): Record<string, unknown> {
|
export function buildStatsPayload(snapshot: UiSnapshot): Record<string, unknown> {
|
||||||
return {
|
return {
|
||||||
session: snapshot.stats,
|
session: snapshot.stats,
|
||||||
totals: {
|
totals: {
|
||||||
totalPackages: Object.keys(snapshot.session.packages).length,
|
totalPackages: Object.keys(snapshot.session.packages).length,
|
||||||
totalItems: Object.keys(snapshot.session.items).length,
|
totalItems: Object.keys(snapshot.session.items).length,
|
||||||
speedText: snapshot.speedText,
|
speedText: snapshot.speedText,
|
||||||
etaText: snapshot.etaText,
|
etaText: snapshot.etaText,
|
||||||
canStart: snapshot.canStart,
|
canStart: snapshot.canStart,
|
||||||
canStop: snapshot.canStop,
|
canStop: snapshot.canStop,
|
||||||
canPause: snapshot.canPause
|
canPause: snapshot.canPause
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function summarizeHistoryEntry(entry: HistoryEntry): Record<string, unknown> {
|
export function summarizeHistoryEntry(entry: HistoryEntry): Record<string, unknown> {
|
||||||
return {
|
return {
|
||||||
id: entry.id,
|
id: entry.id,
|
||||||
name: entry.name,
|
name: entry.name,
|
||||||
status: entry.status,
|
status: entry.status,
|
||||||
provider: entry.provider,
|
provider: entry.provider,
|
||||||
fileCount: entry.fileCount,
|
fileCount: entry.fileCount,
|
||||||
totalBytes: entry.totalBytes,
|
totalBytes: entry.totalBytes,
|
||||||
downloadedBytes: entry.downloadedBytes,
|
downloadedBytes: entry.downloadedBytes,
|
||||||
durationSeconds: entry.durationSeconds,
|
durationSeconds: entry.durationSeconds,
|
||||||
completedAt: entry.completedAt,
|
completedAt: entry.completedAt,
|
||||||
outputDir: entry.outputDir,
|
outputDir: entry.outputDir,
|
||||||
urlCount: Array.isArray(entry.urls) ? entry.urls.length : 0
|
urlCount: Array.isArray(entry.urls) ? entry.urls.length : 0
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,312 +1,312 @@
|
|||||||
import fs from "node:fs";
|
import fs from "node:fs";
|
||||||
import { logTimestamp } from "./log-timestamp";
|
import { logTimestamp } from "./log-timestamp";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { addLogListener, removeLogListener } from "./logger";
|
import { addLogListener, removeLogListener } from "./logger";
|
||||||
import type { SupportTraceConfig } from "../shared/types";
|
import type { SupportTraceConfig } from "../shared/types";
|
||||||
|
|
||||||
type TraceLevel = "INFO" | "WARN" | "ERROR";
|
type TraceLevel = "INFO" | "WARN" | "ERROR";
|
||||||
|
|
||||||
const TRACE_LOG_FLUSH_INTERVAL_MS = 200;
|
const TRACE_LOG_FLUSH_INTERVAL_MS = 200;
|
||||||
const TRACE_CONFIG_FILE = "trace_config.json";
|
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_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_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 TRACE_DEFAULT_AUTO_DISABLE_MS = Number(process.env.RD_TRACE_AUTO_DISABLE_MS || 2 * 60 * 60 * 1000);
|
||||||
|
|
||||||
const DEFAULT_TRACE_CONFIG: SupportTraceConfig = {
|
const DEFAULT_TRACE_CONFIG: SupportTraceConfig = {
|
||||||
enabled: false,
|
enabled: false,
|
||||||
includeMainLog: true,
|
includeMainLog: true,
|
||||||
includeAudit: true,
|
includeAudit: true,
|
||||||
logDebugRequests: true,
|
logDebugRequests: true,
|
||||||
autoDisableAt: null,
|
autoDisableAt: null,
|
||||||
updatedAt: new Date(0).toISOString()
|
updatedAt: new Date(0).toISOString()
|
||||||
};
|
};
|
||||||
|
|
||||||
let traceLogPath: string | null = null;
|
let traceLogPath: string | null = null;
|
||||||
let traceConfigPath: string | null = null;
|
let traceConfigPath: string | null = null;
|
||||||
let traceConfig: SupportTraceConfig = { ...DEFAULT_TRACE_CONFIG };
|
let traceConfig: SupportTraceConfig = { ...DEFAULT_TRACE_CONFIG };
|
||||||
let pendingLines: string[] = [];
|
let pendingLines: string[] = [];
|
||||||
let flushTimer: NodeJS.Timeout | null = null;
|
let flushTimer: NodeJS.Timeout | null = null;
|
||||||
let autoDisableTimer: NodeJS.Timeout | null = null;
|
let autoDisableTimer: NodeJS.Timeout | null = null;
|
||||||
|
|
||||||
function sanitizeFieldValue(value: unknown): string {
|
function sanitizeFieldValue(value: unknown): string {
|
||||||
if (value === undefined || value === null) {
|
if (value === undefined || value === null) {
|
||||||
return "";
|
return "";
|
||||||
}
|
}
|
||||||
if (typeof value === "string") {
|
if (typeof value === "string") {
|
||||||
return value.replace(/\r?\n/g, "\\n");
|
return value.replace(/\r?\n/g, "\\n");
|
||||||
}
|
}
|
||||||
if (typeof value === "number" || typeof value === "boolean") {
|
if (typeof value === "number" || typeof value === "boolean") {
|
||||||
return String(value);
|
return String(value);
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
return JSON.stringify(value).replace(/\r?\n/g, "\\n");
|
return JSON.stringify(value).replace(/\r?\n/g, "\\n");
|
||||||
} catch {
|
} catch {
|
||||||
return String(value);
|
return String(value);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatFields(fields?: Record<string, unknown>): string {
|
function formatFields(fields?: Record<string, unknown>): string {
|
||||||
if (!fields) {
|
if (!fields) {
|
||||||
return "";
|
return "";
|
||||||
}
|
}
|
||||||
const parts = Object.entries(fields)
|
const parts = Object.entries(fields)
|
||||||
.filter(([, value]) => value !== undefined && value !== null && sanitizeFieldValue(value) !== "")
|
.filter(([, value]) => value !== undefined && value !== null && sanitizeFieldValue(value) !== "")
|
||||||
.map(([key, value]) => `${key}=${sanitizeFieldValue(value)}`);
|
.map(([key, value]) => `${key}=${sanitizeFieldValue(value)}`);
|
||||||
return parts.length > 0 ? ` | ${parts.join(" | ")}` : "";
|
return parts.length > 0 ? ` | ${parts.join(" | ")}` : "";
|
||||||
}
|
}
|
||||||
|
|
||||||
function flushPending(): void {
|
function flushPending(): void {
|
||||||
if (!traceLogPath || pendingLines.length === 0) {
|
if (!traceLogPath || pendingLines.length === 0) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const chunk = pendingLines.join("");
|
const chunk = pendingLines.join("");
|
||||||
pendingLines = [];
|
pendingLines = [];
|
||||||
try {
|
try {
|
||||||
fs.appendFileSync(traceLogPath, chunk, "utf8");
|
fs.appendFileSync(traceLogPath, chunk, "utf8");
|
||||||
} catch {
|
} catch {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function rotateIfNeeded(filePath: string): void {
|
function rotateIfNeeded(filePath: string): void {
|
||||||
try {
|
try {
|
||||||
const stat = fs.statSync(filePath);
|
const stat = fs.statSync(filePath);
|
||||||
if (stat.size < TRACE_LOG_MAX_FILE_BYTES) {
|
if (stat.size < TRACE_LOG_MAX_FILE_BYTES) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const backup = `${filePath}.old`;
|
const backup = `${filePath}.old`;
|
||||||
try {
|
try {
|
||||||
fs.rmSync(backup, { force: true });
|
fs.rmSync(backup, { force: true });
|
||||||
} catch {
|
} catch {
|
||||||
}
|
}
|
||||||
fs.renameSync(filePath, backup);
|
fs.renameSync(filePath, backup);
|
||||||
} catch {
|
} catch {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function cleanupOldBackup(filePath: string): void {
|
function cleanupOldBackup(filePath: string): void {
|
||||||
const backup = `${filePath}.old`;
|
const backup = `${filePath}.old`;
|
||||||
try {
|
try {
|
||||||
const stat = fs.statSync(backup);
|
const stat = fs.statSync(backup);
|
||||||
const cutoff = Date.now() - TRACE_LOG_RETENTION_DAYS * 24 * 60 * 60 * 1000;
|
const cutoff = Date.now() - TRACE_LOG_RETENTION_DAYS * 24 * 60 * 60 * 1000;
|
||||||
if (stat.mtimeMs < cutoff) {
|
if (stat.mtimeMs < cutoff) {
|
||||||
fs.rmSync(backup, { force: true });
|
fs.rmSync(backup, { force: true });
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function scheduleFlush(): void {
|
function scheduleFlush(): void {
|
||||||
if (flushTimer) {
|
if (flushTimer) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
flushTimer = setTimeout(() => {
|
flushTimer = setTimeout(() => {
|
||||||
flushTimer = null;
|
flushTimer = null;
|
||||||
flushPending();
|
flushPending();
|
||||||
}, TRACE_LOG_FLUSH_INTERVAL_MS);
|
}, TRACE_LOG_FLUSH_INTERVAL_MS);
|
||||||
}
|
}
|
||||||
|
|
||||||
function appendTraceLine(line: string): void {
|
function appendTraceLine(line: string): void {
|
||||||
if (!traceLogPath) {
|
if (!traceLogPath) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
rotateIfNeeded(traceLogPath);
|
rotateIfNeeded(traceLogPath);
|
||||||
if (!fs.existsSync(traceLogPath)) {
|
if (!fs.existsSync(traceLogPath)) {
|
||||||
try {
|
try {
|
||||||
fs.writeFileSync(traceLogPath, "", "utf8");
|
fs.writeFileSync(traceLogPath, "", "utf8");
|
||||||
} catch {
|
} catch {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
pendingLines.push(line);
|
pendingLines.push(line);
|
||||||
scheduleFlush();
|
scheduleFlush();
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeTraceConfig(raw: unknown): SupportTraceConfig {
|
function normalizeTraceConfig(raw: unknown): SupportTraceConfig {
|
||||||
if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
|
if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
|
||||||
return { ...DEFAULT_TRACE_CONFIG };
|
return { ...DEFAULT_TRACE_CONFIG };
|
||||||
}
|
}
|
||||||
const value = raw as Partial<SupportTraceConfig>;
|
const value = raw as Partial<SupportTraceConfig>;
|
||||||
return {
|
return {
|
||||||
enabled: Boolean(value.enabled),
|
enabled: Boolean(value.enabled),
|
||||||
includeMainLog: value.includeMainLog === undefined ? DEFAULT_TRACE_CONFIG.includeMainLog : Boolean(value.includeMainLog),
|
includeMainLog: value.includeMainLog === undefined ? DEFAULT_TRACE_CONFIG.includeMainLog : Boolean(value.includeMainLog),
|
||||||
includeAudit: value.includeAudit === undefined ? DEFAULT_TRACE_CONFIG.includeAudit : Boolean(value.includeAudit),
|
includeAudit: value.includeAudit === undefined ? DEFAULT_TRACE_CONFIG.includeAudit : Boolean(value.includeAudit),
|
||||||
logDebugRequests: value.logDebugRequests === undefined ? DEFAULT_TRACE_CONFIG.logDebugRequests : Boolean(value.logDebugRequests),
|
logDebugRequests: value.logDebugRequests === undefined ? DEFAULT_TRACE_CONFIG.logDebugRequests : Boolean(value.logDebugRequests),
|
||||||
autoDisableAt: typeof value.autoDisableAt === "string" && value.autoDisableAt.trim()
|
autoDisableAt: typeof value.autoDisableAt === "string" && value.autoDisableAt.trim()
|
||||||
? value.autoDisableAt
|
? value.autoDisableAt
|
||||||
: null,
|
: null,
|
||||||
updatedAt: typeof value.updatedAt === "string" && value.updatedAt.trim()
|
updatedAt: typeof value.updatedAt === "string" && value.updatedAt.trim()
|
||||||
? value.updatedAt
|
? value.updatedAt
|
||||||
: DEFAULT_TRACE_CONFIG.updatedAt
|
: DEFAULT_TRACE_CONFIG.updatedAt
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function loadTraceConfig(): SupportTraceConfig {
|
function loadTraceConfig(): SupportTraceConfig {
|
||||||
if (!traceConfigPath) {
|
if (!traceConfigPath) {
|
||||||
return { ...DEFAULT_TRACE_CONFIG };
|
return { ...DEFAULT_TRACE_CONFIG };
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const parsed = JSON.parse(fs.readFileSync(traceConfigPath, "utf8")) as unknown;
|
const parsed = JSON.parse(fs.readFileSync(traceConfigPath, "utf8")) as unknown;
|
||||||
return normalizeTraceConfig(parsed);
|
return normalizeTraceConfig(parsed);
|
||||||
} catch {
|
} catch {
|
||||||
return { ...DEFAULT_TRACE_CONFIG };
|
return { ...DEFAULT_TRACE_CONFIG };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function persistTraceConfig(): void {
|
function persistTraceConfig(): void {
|
||||||
if (!traceConfigPath) {
|
if (!traceConfigPath) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
fs.writeFileSync(traceConfigPath, `${JSON.stringify(traceConfig, null, 2)}\n`, "utf8");
|
fs.writeFileSync(traceConfigPath, `${JSON.stringify(traceConfig, null, 2)}\n`, "utf8");
|
||||||
} catch {
|
} catch {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const mainLogListener = (line: string): void => {
|
const mainLogListener = (line: string): void => {
|
||||||
if (!traceConfig.enabled || !traceConfig.includeMainLog) {
|
if (!traceConfig.enabled || !traceConfig.includeMainLog) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
appendTraceLine(line);
|
appendTraceLine(line);
|
||||||
};
|
};
|
||||||
|
|
||||||
function clearAutoDisableTimer(): void {
|
function clearAutoDisableTimer(): void {
|
||||||
if (autoDisableTimer) {
|
if (autoDisableTimer) {
|
||||||
clearTimeout(autoDisableTimer);
|
clearTimeout(autoDisableTimer);
|
||||||
autoDisableTimer = null;
|
autoDisableTimer = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function disableTraceDueToExpiry(): void {
|
function disableTraceDueToExpiry(): void {
|
||||||
clearAutoDisableTimer();
|
clearAutoDisableTimer();
|
||||||
if (!traceConfig.enabled) {
|
if (!traceConfig.enabled) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
traceConfig = normalizeTraceConfig({
|
traceConfig = normalizeTraceConfig({
|
||||||
...traceConfig,
|
...traceConfig,
|
||||||
enabled: false,
|
enabled: false,
|
||||||
autoDisableAt: null,
|
autoDisableAt: null,
|
||||||
updatedAt: logTimestamp()
|
updatedAt: logTimestamp()
|
||||||
});
|
});
|
||||||
persistTraceConfig();
|
persistTraceConfig();
|
||||||
appendTraceLine(`${logTimestamp()} [INFO] [trace] Support-Trace automatisch deaktiviert | reason=expired\n`);
|
appendTraceLine(`${logTimestamp()} [INFO] [trace] Support-Trace automatisch deaktiviert | reason=expired\n`);
|
||||||
}
|
}
|
||||||
|
|
||||||
function scheduleAutoDisable(): void {
|
function scheduleAutoDisable(): void {
|
||||||
clearAutoDisableTimer();
|
clearAutoDisableTimer();
|
||||||
if (!traceConfig.enabled || !traceConfig.autoDisableAt) {
|
if (!traceConfig.enabled || !traceConfig.autoDisableAt) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const until = Date.parse(traceConfig.autoDisableAt);
|
const until = Date.parse(traceConfig.autoDisableAt);
|
||||||
if (!Number.isFinite(until)) {
|
if (!Number.isFinite(until)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const remainingMs = until - Date.now();
|
const remainingMs = until - Date.now();
|
||||||
if (remainingMs <= 0) {
|
if (remainingMs <= 0) {
|
||||||
disableTraceDueToExpiry();
|
disableTraceDueToExpiry();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
autoDisableTimer = setTimeout(() => {
|
autoDisableTimer = setTimeout(() => {
|
||||||
autoDisableTimer = null;
|
autoDisableTimer = null;
|
||||||
disableTraceDueToExpiry();
|
disableTraceDueToExpiry();
|
||||||
}, Math.min(remainingMs, 2_147_483_647));
|
}, Math.min(remainingMs, 2_147_483_647));
|
||||||
}
|
}
|
||||||
|
|
||||||
export function initTraceLog(baseDir: string): void {
|
export function initTraceLog(baseDir: string): void {
|
||||||
traceLogPath = path.join(baseDir, "trace.log");
|
traceLogPath = path.join(baseDir, "trace.log");
|
||||||
traceConfigPath = path.join(baseDir, TRACE_CONFIG_FILE);
|
traceConfigPath = path.join(baseDir, TRACE_CONFIG_FILE);
|
||||||
try {
|
try {
|
||||||
fs.mkdirSync(baseDir, { recursive: true });
|
fs.mkdirSync(baseDir, { recursive: true });
|
||||||
cleanupOldBackup(traceLogPath);
|
cleanupOldBackup(traceLogPath);
|
||||||
if (!fs.existsSync(traceLogPath)) {
|
if (!fs.existsSync(traceLogPath)) {
|
||||||
fs.writeFileSync(traceLogPath, "", "utf8");
|
fs.writeFileSync(traceLogPath, "", "utf8");
|
||||||
}
|
}
|
||||||
rotateIfNeeded(traceLogPath);
|
rotateIfNeeded(traceLogPath);
|
||||||
if (!fs.existsSync(traceLogPath)) {
|
if (!fs.existsSync(traceLogPath)) {
|
||||||
fs.writeFileSync(traceLogPath, "", "utf8");
|
fs.writeFileSync(traceLogPath, "", "utf8");
|
||||||
}
|
}
|
||||||
traceConfig = loadTraceConfig();
|
traceConfig = loadTraceConfig();
|
||||||
persistTraceConfig();
|
persistTraceConfig();
|
||||||
fs.appendFileSync(traceLogPath, `=== Trace-Log Start: ${logTimestamp()} ===\n`, "utf8");
|
fs.appendFileSync(traceLogPath, `=== Trace-Log Start: ${logTimestamp()} ===\n`, "utf8");
|
||||||
} catch {
|
} catch {
|
||||||
traceLogPath = null;
|
traceLogPath = null;
|
||||||
traceConfigPath = null;
|
traceConfigPath = null;
|
||||||
traceConfig = { ...DEFAULT_TRACE_CONFIG };
|
traceConfig = { ...DEFAULT_TRACE_CONFIG };
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
addLogListener(mainLogListener);
|
addLogListener(mainLogListener);
|
||||||
scheduleAutoDisable();
|
scheduleAutoDisable();
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getTraceLogPath(): string | null {
|
export function getTraceLogPath(): string | null {
|
||||||
if (!traceLogPath) {
|
if (!traceLogPath) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
return fs.existsSync(traceLogPath) ? traceLogPath : null;
|
return fs.existsSync(traceLogPath) ? traceLogPath : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getTraceConfigPath(): string | null {
|
export function getTraceConfigPath(): string | null {
|
||||||
if (!traceConfigPath) {
|
if (!traceConfigPath) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
return fs.existsSync(traceConfigPath) ? traceConfigPath : null;
|
return fs.existsSync(traceConfigPath) ? traceConfigPath : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getTraceConfig(): SupportTraceConfig {
|
export function getTraceConfig(): SupportTraceConfig {
|
||||||
return { ...traceConfig };
|
return { ...traceConfig };
|
||||||
}
|
}
|
||||||
|
|
||||||
export function updateTraceConfig(patch: Partial<SupportTraceConfig>): SupportTraceConfig {
|
export function updateTraceConfig(patch: Partial<SupportTraceConfig>): SupportTraceConfig {
|
||||||
traceConfig = normalizeTraceConfig({
|
traceConfig = normalizeTraceConfig({
|
||||||
...traceConfig,
|
...traceConfig,
|
||||||
...patch,
|
...patch,
|
||||||
updatedAt: logTimestamp()
|
updatedAt: logTimestamp()
|
||||||
});
|
});
|
||||||
persistTraceConfig();
|
persistTraceConfig();
|
||||||
scheduleAutoDisable();
|
scheduleAutoDisable();
|
||||||
appendTraceLine(`${logTimestamp()} [INFO] [trace] Konfiguration aktualisiert${formatFields(traceConfig as unknown as Record<string, unknown>)}\n`);
|
appendTraceLine(`${logTimestamp()} [INFO] [trace] Konfiguration aktualisiert${formatFields(traceConfig as unknown as Record<string, unknown>)}\n`);
|
||||||
return getTraceConfig();
|
return getTraceConfig();
|
||||||
}
|
}
|
||||||
|
|
||||||
export function setTraceEnabled(enabled: boolean, note = "", durationMs: number = TRACE_DEFAULT_AUTO_DISABLE_MS): SupportTraceConfig {
|
export function setTraceEnabled(enabled: boolean, note = "", durationMs: number = TRACE_DEFAULT_AUTO_DISABLE_MS): SupportTraceConfig {
|
||||||
const autoDisableAt = enabled && durationMs > 0
|
const autoDisableAt = enabled && durationMs > 0
|
||||||
? new Date(Date.now() + durationMs).toISOString()
|
? new Date(Date.now() + durationMs).toISOString()
|
||||||
: null;
|
: null;
|
||||||
const next = updateTraceConfig({ enabled, autoDisableAt });
|
const next = updateTraceConfig({ enabled, autoDisableAt });
|
||||||
appendTraceLine(`${logTimestamp()} [INFO] [trace] Support-Trace ${enabled ? "aktiviert" : "deaktiviert"}${formatFields({ note, autoDisableAt })}\n`);
|
appendTraceLine(`${logTimestamp()} [INFO] [trace] Support-Trace ${enabled ? "aktiviert" : "deaktiviert"}${formatFields({ note, autoDisableAt })}\n`);
|
||||||
return next;
|
return next;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function logTraceEvent(
|
export function logTraceEvent(
|
||||||
level: TraceLevel,
|
level: TraceLevel,
|
||||||
category: string,
|
category: string,
|
||||||
message: string,
|
message: string,
|
||||||
fields?: Record<string, unknown>
|
fields?: Record<string, unknown>
|
||||||
): void {
|
): void {
|
||||||
if (!traceConfig.enabled) {
|
if (!traceConfig.enabled) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (category === "audit" && !traceConfig.includeAudit) {
|
if (category === "audit" && !traceConfig.includeAudit) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
appendTraceLine(`${logTimestamp()} [${level}] [${category}] ${message}${formatFields(fields)}\n`);
|
appendTraceLine(`${logTimestamp()} [${level}] [${category}] ${message}${formatFields(fields)}\n`);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function shutdownTraceLog(): void {
|
export function shutdownTraceLog(): void {
|
||||||
removeLogListener(mainLogListener);
|
removeLogListener(mainLogListener);
|
||||||
clearAutoDisableTimer();
|
clearAutoDisableTimer();
|
||||||
if (!traceLogPath) {
|
if (!traceLogPath) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (flushTimer) {
|
if (flushTimer) {
|
||||||
clearTimeout(flushTimer);
|
clearTimeout(flushTimer);
|
||||||
flushTimer = null;
|
flushTimer = null;
|
||||||
}
|
}
|
||||||
flushPending();
|
flushPending();
|
||||||
try {
|
try {
|
||||||
fs.appendFileSync(traceLogPath, `=== Trace-Log Ende: ${logTimestamp()} ===\n`, "utf8");
|
fs.appendFileSync(traceLogPath, `=== Trace-Log Ende: ${logTimestamp()} ===\n`, "utf8");
|
||||||
} catch {
|
} catch {
|
||||||
}
|
}
|
||||||
traceLogPath = null;
|
traceLogPath = null;
|
||||||
traceConfigPath = null;
|
traceConfigPath = null;
|
||||||
traceConfig = { ...DEFAULT_TRACE_CONFIG };
|
traceConfig = { ...DEFAULT_TRACE_CONFIG };
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,34 +1,34 @@
|
|||||||
export interface InstallResumeManager {
|
export interface InstallResumeManager {
|
||||||
isSessionRunning(): boolean;
|
isSessionRunning(): boolean;
|
||||||
stop(options: { parkForRestart: boolean }): void;
|
stop(options: { parkForRestart: boolean }): void;
|
||||||
persistNowSync(): void;
|
persistNowSync(): void;
|
||||||
start(): Promise<void> | void;
|
start(): Promise<void> | void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function runInstallWithResume<T extends { started: boolean }>(
|
export async function runInstallWithResume<T extends { started: boolean }>(
|
||||||
manager: InstallResumeManager,
|
manager: InstallResumeManager,
|
||||||
doInstall: () => Promise<T>
|
doInstall: () => Promise<T>
|
||||||
): Promise<T> {
|
): Promise<T> {
|
||||||
const wasRunning = manager.isSessionRunning();
|
const wasRunning = manager.isSessionRunning();
|
||||||
if (wasRunning) {
|
if (wasRunning) {
|
||||||
manager.stop({ parkForRestart: true });
|
manager.stop({ parkForRestart: true });
|
||||||
}
|
}
|
||||||
manager.persistNowSync();
|
manager.persistNowSync();
|
||||||
|
|
||||||
const resumeIfParked = async (): Promise<void> => {
|
const resumeIfParked = async (): Promise<void> => {
|
||||||
if (wasRunning && !manager.isSessionRunning()) {
|
if (wasRunning && !manager.isSessionRunning()) {
|
||||||
await manager.start();
|
await manager.start();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await doInstall();
|
const result = await doInstall();
|
||||||
if (!result.started) {
|
if (!result.started) {
|
||||||
await resumeIfParked();
|
await resumeIfParked();
|
||||||
}
|
}
|
||||||
return result;
|
return result;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
await resumeIfParked();
|
await resumeIfParked();
|
||||||
throw error;
|
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 path from "node:path";
|
||||||
import { ParsedPackageInput } from "../shared/types";
|
import { ParsedPackageInput } from "../shared/types";
|
||||||
|
|
||||||
function safeDecodeURIComponent(value: string): string {
|
function safeDecodeURIComponent(value: string): string {
|
||||||
try {
|
try {
|
||||||
return decodeURIComponent(value);
|
return decodeURIComponent(value);
|
||||||
} catch {
|
} catch {
|
||||||
return value;
|
return value;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const WINDOWS_RESERVED_BASENAMES = new Set([
|
const WINDOWS_RESERVED_BASENAMES = new Set([
|
||||||
"con", "prn", "aux", "nul",
|
"con", "prn", "aux", "nul",
|
||||||
"com1", "com2", "com3", "com4", "com5", "com6", "com7", "com8", "com9",
|
"com1", "com2", "com3", "com4", "com5", "com6", "com7", "com8", "com9",
|
||||||
"lpt1", "lpt2", "lpt3", "lpt4", "lpt5", "lpt6", "lpt7", "lpt8", "lpt9"
|
"lpt1", "lpt2", "lpt3", "lpt4", "lpt5", "lpt6", "lpt7", "lpt8", "lpt9"
|
||||||
]);
|
]);
|
||||||
|
|
||||||
export function compactErrorText(message: unknown, maxLen = 220): string {
|
export function compactErrorText(message: unknown, maxLen = 220): string {
|
||||||
const raw = String(message ?? "").replace(/<[^>]+>/g, " ").replace(/\s+/g, " ").trim();
|
const raw = String(message ?? "").replace(/<[^>]+>/g, " ").replace(/\s+/g, " ").trim();
|
||||||
if (!raw) {
|
if (!raw) {
|
||||||
return "Unbekannter Fehler";
|
return "Unbekannter Fehler";
|
||||||
}
|
}
|
||||||
const safeMaxLen = Number.isFinite(maxLen) ? Math.max(4, Math.floor(maxLen)) : 220;
|
const safeMaxLen = Number.isFinite(maxLen) ? Math.max(4, Math.floor(maxLen)) : 220;
|
||||||
if (raw.length <= safeMaxLen) {
|
if (raw.length <= safeMaxLen) {
|
||||||
return raw;
|
return raw;
|
||||||
}
|
}
|
||||||
return `${raw.slice(0, safeMaxLen - 3)}...`;
|
return `${raw.slice(0, safeMaxLen - 3)}...`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function sanitizeFilename(name: string): string {
|
export function sanitizeFilename(name: string): string {
|
||||||
const cleaned = String(name || "")
|
const cleaned = String(name || "")
|
||||||
.replace(/\0/g, "")
|
.replace(/\0/g, "")
|
||||||
.replace(/[\\/:*?"<>|]/g, " ")
|
.replace(/[\\/:*?"<>|]/g, " ")
|
||||||
.replace(/\s+/g, " ")
|
.replace(/\s+/g, " ")
|
||||||
.trim();
|
.trim();
|
||||||
|
|
||||||
let normalized = cleaned
|
let normalized = cleaned
|
||||||
.replace(/^[.\s]+/g, "")
|
.replace(/^[.\s]+/g, "")
|
||||||
.replace(/[.\s]+$/g, "")
|
.replace(/[.\s]+$/g, "")
|
||||||
.trim();
|
.trim();
|
||||||
|
|
||||||
if (!normalized || normalized === "." || normalized === ".." || /^\.+$/.test(normalized)) {
|
if (!normalized || normalized === "." || normalized === ".." || /^\.+$/.test(normalized)) {
|
||||||
return "Paket";
|
return "Paket";
|
||||||
}
|
}
|
||||||
|
|
||||||
const parsed = path.parse(normalized);
|
const parsed = path.parse(normalized);
|
||||||
const reservedBase = (parsed.name.split(".")[0] || parsed.name).toLowerCase();
|
const reservedBase = (parsed.name.split(".")[0] || parsed.name).toLowerCase();
|
||||||
if (WINDOWS_RESERVED_BASENAMES.has(reservedBase)) {
|
if (WINDOWS_RESERVED_BASENAMES.has(reservedBase)) {
|
||||||
normalized = `${parsed.name.replace(/^([^.]*)/, "$1_")}${parsed.ext}`;
|
normalized = `${parsed.name.replace(/^([^.]*)/, "$1_")}${parsed.ext}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
return normalized || "Paket";
|
return normalized || "Paket";
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isHttpLink(value: string): boolean {
|
export function isHttpLink(value: string): boolean {
|
||||||
const text = String(value || "").trim();
|
const text = String(value || "").trim();
|
||||||
if (!text) {
|
if (!text) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const url = new URL(text);
|
const url = new URL(text);
|
||||||
return (url.protocol === "http:" || url.protocol === "https:") && !!url.hostname;
|
return (url.protocol === "http:" || url.protocol === "https:") && !!url.hostname;
|
||||||
} catch {
|
} catch {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function extractHttpLinksFromText(text: string): string[] {
|
export function extractHttpLinksFromText(text: string): string[] {
|
||||||
const matches = String(text || "").match(/https?:\/\/[^\s<>"']+/gi) ?? [];
|
const matches = String(text || "").match(/https?:\/\/[^\s<>"']+/gi) ?? [];
|
||||||
const seen = new Set<string>();
|
const seen = new Set<string>();
|
||||||
const links: string[] = [];
|
const links: string[] = [];
|
||||||
|
|
||||||
for (const match of matches) {
|
for (const match of matches) {
|
||||||
let candidate = String(match || "").trim();
|
let candidate = String(match || "").trim();
|
||||||
let openParen = 0;
|
let openParen = 0;
|
||||||
let closeParen = 0;
|
let closeParen = 0;
|
||||||
let openBracket = 0;
|
let openBracket = 0;
|
||||||
let closeBracket = 0;
|
let closeBracket = 0;
|
||||||
for (const char of candidate) {
|
for (const char of candidate) {
|
||||||
if (char === "(") {
|
if (char === "(") {
|
||||||
openParen += 1;
|
openParen += 1;
|
||||||
} else if (char === ")") {
|
} else if (char === ")") {
|
||||||
closeParen += 1;
|
closeParen += 1;
|
||||||
} else if (char === "[") {
|
} else if (char === "[") {
|
||||||
openBracket += 1;
|
openBracket += 1;
|
||||||
} else if (char === "]") {
|
} else if (char === "]") {
|
||||||
closeBracket += 1;
|
closeBracket += 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
while (candidate.length > 0) {
|
while (candidate.length > 0) {
|
||||||
const lastChar = candidate[candidate.length - 1];
|
const lastChar = candidate[candidate.length - 1];
|
||||||
if (![")", "]", ",", ".", "!", "?", ";", ":"].includes(lastChar)) {
|
if (![")", "]", ",", ".", "!", "?", ";", ":"].includes(lastChar)) {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
if (lastChar === ")") {
|
if (lastChar === ")") {
|
||||||
if (closeParen <= openParen) {
|
if (closeParen <= openParen) {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (lastChar === "]") {
|
if (lastChar === "]") {
|
||||||
if (closeBracket <= openBracket) {
|
if (closeBracket <= openBracket) {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (lastChar === ")") {
|
if (lastChar === ")") {
|
||||||
closeParen = Math.max(0, closeParen - 1);
|
closeParen = Math.max(0, closeParen - 1);
|
||||||
} else if (lastChar === "]") {
|
} else if (lastChar === "]") {
|
||||||
closeBracket = Math.max(0, closeBracket - 1);
|
closeBracket = Math.max(0, closeBracket - 1);
|
||||||
}
|
}
|
||||||
candidate = candidate.slice(0, -1);
|
candidate = candidate.slice(0, -1);
|
||||||
}
|
}
|
||||||
if (!candidate || !isHttpLink(candidate) || seen.has(candidate)) {
|
if (!candidate || !isHttpLink(candidate) || seen.has(candidate)) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
seen.add(candidate);
|
seen.add(candidate);
|
||||||
links.push(candidate);
|
links.push(candidate);
|
||||||
}
|
}
|
||||||
|
|
||||||
return links;
|
return links;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function humanSize(bytes: number): string {
|
export function humanSize(bytes: number): string {
|
||||||
const value = Number(bytes);
|
const value = Number(bytes);
|
||||||
if (!Number.isFinite(value) || value < 0) {
|
if (!Number.isFinite(value) || value < 0) {
|
||||||
return "0 B";
|
return "0 B";
|
||||||
}
|
}
|
||||||
if (value < 1024) {
|
if (value < 1024) {
|
||||||
return `${Math.round(value)} B`;
|
return `${Math.round(value)} B`;
|
||||||
}
|
}
|
||||||
const units = ["KB", "MB", "GB", "TB"];
|
const units = ["KB", "MB", "GB", "TB"];
|
||||||
let size = value / 1024;
|
let size = value / 1024;
|
||||||
let unit = 0;
|
let unit = 0;
|
||||||
while (size >= 1024 && unit < units.length - 1) {
|
while (size >= 1024 && unit < units.length - 1) {
|
||||||
size /= 1024;
|
size /= 1024;
|
||||||
unit += 1;
|
unit += 1;
|
||||||
}
|
}
|
||||||
return `${size.toFixed(size < 10 ? 1 : 0)} ${units[unit]}`;
|
return `${size.toFixed(size < 10 ? 1 : 0)} ${units[unit]}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function filenameFromUrl(url: string): string {
|
export function filenameFromUrl(url: string): string {
|
||||||
try {
|
try {
|
||||||
const parsed = new URL(url);
|
const parsed = new URL(url);
|
||||||
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
|
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
|
||||||
return "download.bin";
|
return "download.bin";
|
||||||
}
|
}
|
||||||
const queryName = parsed.searchParams.get("filename")
|
const queryName = parsed.searchParams.get("filename")
|
||||||
|| parsed.searchParams.get("file")
|
|| parsed.searchParams.get("file")
|
||||||
|| parsed.searchParams.get("name")
|
|| parsed.searchParams.get("name")
|
||||||
|| parsed.searchParams.get("download")
|
|| parsed.searchParams.get("download")
|
||||||
|| parsed.searchParams.get("title")
|
|| parsed.searchParams.get("title")
|
||||||
|| "";
|
|| "";
|
||||||
const rawName = queryName || path.basename(parsed.pathname || "");
|
const rawName = queryName || path.basename(parsed.pathname || "");
|
||||||
const decoded = safeDecodeURIComponent(rawName || "").trim();
|
const decoded = safeDecodeURIComponent(rawName || "").trim();
|
||||||
const normalized = decoded
|
const normalized = decoded
|
||||||
.replace(/\.(rar|zip|7z|tar|gz|bz2|xz|iso|part\d+\.rar|r\d{2,3})\.html$/i, ".$1")
|
.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");
|
.replace(/\.(mp4|mkv|avi|mp3|flac|srt)\.html$/i, ".$1");
|
||||||
return sanitizeFilename(normalized || "download.bin");
|
return sanitizeFilename(normalized || "download.bin");
|
||||||
} catch {
|
} catch {
|
||||||
return "download.bin";
|
return "download.bin";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function looksLikeOpaqueFilename(name: string): boolean {
|
export function looksLikeOpaqueFilename(name: string): boolean {
|
||||||
const cleaned = sanitizeFilename(name || "").toLowerCase();
|
const cleaned = sanitizeFilename(name || "").toLowerCase();
|
||||||
if (!cleaned || cleaned === "download.bin") {
|
if (!cleaned || cleaned === "download.bin") {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
const parsed = path.parse(cleaned);
|
const parsed = path.parse(cleaned);
|
||||||
return /^[a-f0-9]{24,}$/i.test(parsed.name || cleaned);
|
return /^[a-f0-9]{24,}$/i.test(parsed.name || cleaned);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function inferPackageNameFromLinks(links: string[]): string {
|
export function inferPackageNameFromLinks(links: string[]): string {
|
||||||
if (links.length === 0) {
|
if (links.length === 0) {
|
||||||
return "Paket";
|
return "Paket";
|
||||||
}
|
}
|
||||||
const names = links.map((link) => filenameFromUrl(link).toLowerCase());
|
const names = links.map((link) => filenameFromUrl(link).toLowerCase());
|
||||||
const first = names[0];
|
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);
|
const match = first.match(/^([a-z0-9._\- ]{3,80}?)(?:\.|-|_)(?:part\d+|r\d{2}|s\d{2}e\d{2})/i);
|
||||||
if (match) {
|
if (match) {
|
||||||
return sanitizeFilename(match[1]);
|
return sanitizeFilename(match[1]);
|
||||||
}
|
}
|
||||||
return sanitizeFilename(path.parse(first).name || "Paket");
|
return sanitizeFilename(path.parse(first).name || "Paket");
|
||||||
}
|
}
|
||||||
|
|
||||||
export function uniquePreserveOrder(items: string[]): string[] {
|
export function uniquePreserveOrder(items: string[]): string[] {
|
||||||
const seen = new Set<string>();
|
const seen = new Set<string>();
|
||||||
const out: string[] = [];
|
const out: string[] = [];
|
||||||
for (const item of items) {
|
for (const item of items) {
|
||||||
const trimmed = item.trim();
|
const trimmed = item.trim();
|
||||||
if (!trimmed || seen.has(trimmed)) {
|
if (!trimmed || seen.has(trimmed)) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
seen.add(trimmed);
|
seen.add(trimmed);
|
||||||
out.push(trimmed);
|
out.push(trimmed);
|
||||||
}
|
}
|
||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function parsePackagesFromLinksText(rawText: string, defaultPackageName: string): ParsedPackageInput[] {
|
export function parsePackagesFromLinksText(rawText: string, defaultPackageName: string): ParsedPackageInput[] {
|
||||||
const lines = String(rawText || "").split(/\r?\n/);
|
const lines = String(rawText || "").split(/\r?\n/);
|
||||||
const packages: ParsedPackageInput[] = [];
|
const packages: ParsedPackageInput[] = [];
|
||||||
let currentName = String(defaultPackageName || "").trim();
|
let currentName = String(defaultPackageName || "").trim();
|
||||||
let currentLinks: string[] = [];
|
let currentLinks: string[] = [];
|
||||||
let currentFileNames: string[] = [];
|
let currentFileNames: string[] = [];
|
||||||
let pendingFileName = "";
|
let pendingFileName = "";
|
||||||
|
|
||||||
const flush = (): void => {
|
const flush = (): void => {
|
||||||
const links = uniquePreserveOrder(currentLinks.filter((line) => isHttpLink(line)));
|
const links = uniquePreserveOrder(currentLinks.filter((line) => isHttpLink(line)));
|
||||||
if (links.length > 0) {
|
if (links.length > 0) {
|
||||||
const normalizedCurrentName = String(currentName || "").trim();
|
const normalizedCurrentName = String(currentName || "").trim();
|
||||||
const fileNames = links.map((link) => {
|
const fileNames = links.map((link) => {
|
||||||
const firstIndex = currentLinks.findIndex((currentLink) => currentLink === link);
|
const firstIndex = currentLinks.findIndex((currentLink) => currentLink === link);
|
||||||
return firstIndex >= 0 ? currentFileNames[firstIndex] || "" : "";
|
return firstIndex >= 0 ? currentFileNames[firstIndex] || "" : "";
|
||||||
});
|
});
|
||||||
const nextPackage: ParsedPackageInput = {
|
const nextPackage: ParsedPackageInput = {
|
||||||
name: normalizedCurrentName
|
name: normalizedCurrentName
|
||||||
? sanitizeFilename(normalizedCurrentName)
|
? sanitizeFilename(normalizedCurrentName)
|
||||||
: inferPackageNameFromLinks(links),
|
: inferPackageNameFromLinks(links),
|
||||||
links
|
links
|
||||||
};
|
};
|
||||||
if (fileNames.some((fileName) => fileName.trim().length > 0)) {
|
if (fileNames.some((fileName) => fileName.trim().length > 0)) {
|
||||||
nextPackage.fileNames = fileNames;
|
nextPackage.fileNames = fileNames;
|
||||||
}
|
}
|
||||||
packages.push(nextPackage);
|
packages.push(nextPackage);
|
||||||
}
|
}
|
||||||
currentLinks = [];
|
currentLinks = [];
|
||||||
currentFileNames = [];
|
currentFileNames = [];
|
||||||
pendingFileName = "";
|
pendingFileName = "";
|
||||||
};
|
};
|
||||||
|
|
||||||
for (const line of lines) {
|
for (const line of lines) {
|
||||||
const text = line.trim();
|
const text = line.trim();
|
||||||
if (!text) {
|
if (!text) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const marker = text.match(/^#\s*package\s*:\s*(.+)$/i);
|
const marker = text.match(/^#\s*package\s*:\s*(.+)$/i);
|
||||||
if (marker) {
|
if (marker) {
|
||||||
flush();
|
flush();
|
||||||
currentName = String(marker[1] || "").trim();
|
currentName = String(marker[1] || "").trim();
|
||||||
pendingFileName = "";
|
pendingFileName = "";
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const fileMarker = text.match(/^#\s*file\s*:\s*(.+)$/i);
|
const fileMarker = text.match(/^#\s*file\s*:\s*(.+)$/i);
|
||||||
if (fileMarker) {
|
if (fileMarker) {
|
||||||
pendingFileName = sanitizeFilename(String(fileMarker[1] || "").trim());
|
pendingFileName = sanitizeFilename(String(fileMarker[1] || "").trim());
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (!isHttpLink(text)) {
|
if (!isHttpLink(text)) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
currentLinks.push(text);
|
currentLinks.push(text);
|
||||||
currentFileNames.push(pendingFileName);
|
currentFileNames.push(pendingFileName);
|
||||||
pendingFileName = "";
|
pendingFileName = "";
|
||||||
}
|
}
|
||||||
|
|
||||||
flush();
|
flush();
|
||||||
if (packages.length === 0) {
|
if (packages.length === 0) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
return packages;
|
return packages;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ensureDirPath(baseDir: string, packageName: string): string {
|
export function ensureDirPath(baseDir: string, packageName: string): string {
|
||||||
if (!path.isAbsolute(baseDir)) {
|
if (!path.isAbsolute(baseDir)) {
|
||||||
throw new Error("baseDir muss ein absoluter Pfad sein");
|
throw new Error("baseDir muss ein absoluter Pfad sein");
|
||||||
}
|
}
|
||||||
return path.join(baseDir, sanitizeFilename(packageName));
|
return path.join(baseDir, sanitizeFilename(packageName));
|
||||||
}
|
}
|
||||||
|
|
||||||
export function nowMs(): number {
|
export function nowMs(): number {
|
||||||
return Date.now();
|
return Date.now();
|
||||||
}
|
}
|
||||||
|
|
||||||
export function sleep(ms: number, signal?: AbortSignal): Promise<void> {
|
export function sleep(ms: number, signal?: AbortSignal): Promise<void> {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
if (signal?.aborted) {
|
if (signal?.aborted) {
|
||||||
reject(new Error(String(signal.reason || "aborted")));
|
reject(new Error(String(signal.reason || "aborted")));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const timer = setTimeout(() => {
|
const timer = setTimeout(() => {
|
||||||
cleanup();
|
cleanup();
|
||||||
resolve();
|
resolve();
|
||||||
}, ms);
|
}, ms);
|
||||||
const onAbort = (): void => {
|
const onAbort = (): void => {
|
||||||
clearTimeout(timer);
|
clearTimeout(timer);
|
||||||
cleanup();
|
cleanup();
|
||||||
reject(new Error(String(signal?.reason || "aborted")));
|
reject(new Error(String(signal?.reason || "aborted")));
|
||||||
};
|
};
|
||||||
const cleanup = (): void => {
|
const cleanup = (): void => {
|
||||||
signal?.removeEventListener("abort", onAbort);
|
signal?.removeEventListener("abort", onAbort);
|
||||||
};
|
};
|
||||||
signal?.addEventListener("abort", onAbort, { once: true });
|
signal?.addEventListener("abort", onAbort, { once: true });
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function formatEta(seconds: number): string {
|
export function formatEta(seconds: number): string {
|
||||||
if (!Number.isFinite(seconds) || seconds < 0) {
|
if (!Number.isFinite(seconds) || seconds < 0) {
|
||||||
return "--";
|
return "--";
|
||||||
}
|
}
|
||||||
const s = Math.floor(seconds);
|
const s = Math.floor(seconds);
|
||||||
const sec = s % 60;
|
const sec = s % 60;
|
||||||
const minTotal = Math.floor(s / 60);
|
const minTotal = Math.floor(s / 60);
|
||||||
const min = minTotal % 60;
|
const min = minTotal % 60;
|
||||||
const hr = Math.floor(minTotal / 60);
|
const hr = Math.floor(minTotal / 60);
|
||||||
if (hr > 0) {
|
if (hr > 0) {
|
||||||
return `${String(hr).padStart(2, "0")}:${String(min).padStart(2, "0")}:${String(sec).padStart(2, "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")}`;
|
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 fs from "node:fs";
|
||||||
import { spawnSync } from "node:child_process";
|
import { spawnSync } from "node:child_process";
|
||||||
|
|
||||||
export interface WindowsHostEvent {
|
export interface WindowsHostEvent {
|
||||||
timeCreated: string;
|
timeCreated: string;
|
||||||
id: number;
|
id: number;
|
||||||
providerName: string;
|
providerName: string;
|
||||||
levelDisplayName: string;
|
levelDisplayName: string;
|
||||||
message: string;
|
message: string;
|
||||||
bugcheckCode?: string;
|
bugcheckCode?: string;
|
||||||
bugcheckCodeHex?: string;
|
bugcheckCodeHex?: string;
|
||||||
reportId?: string;
|
reportId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface WindowsHostDumpFile {
|
export interface WindowsHostDumpFile {
|
||||||
name: string;
|
name: string;
|
||||||
fullName: string;
|
fullName: string;
|
||||||
length: number;
|
length: number;
|
||||||
lastWriteTime: string;
|
lastWriteTime: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface WindowsCrashControlInfo {
|
export interface WindowsCrashControlInfo {
|
||||||
crashDumpEnabled: number | null;
|
crashDumpEnabled: number | null;
|
||||||
minidumpDir: string;
|
minidumpDir: string;
|
||||||
dumpFile: string;
|
dumpFile: string;
|
||||||
overwrite: number | null;
|
overwrite: number | null;
|
||||||
logEvent: number | null;
|
logEvent: number | null;
|
||||||
autoReboot: number | null;
|
autoReboot: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface WindowsHostDiagnostics {
|
export interface WindowsHostDiagnostics {
|
||||||
collectedAt: string;
|
collectedAt: string;
|
||||||
supported: boolean;
|
supported: boolean;
|
||||||
platform: string;
|
platform: string;
|
||||||
crashControl: WindowsCrashControlInfo | null;
|
crashControl: WindowsCrashControlInfo | null;
|
||||||
recentKernelPower: WindowsHostEvent[];
|
recentKernelPower: WindowsHostEvent[];
|
||||||
recentWerKernel: WindowsHostEvent[];
|
recentWerKernel: WindowsHostEvent[];
|
||||||
recentKernelDump: WindowsHostEvent[];
|
recentKernelDump: WindowsHostEvent[];
|
||||||
recentAppCrashes: WindowsHostEvent[];
|
recentAppCrashes: WindowsHostEvent[];
|
||||||
recentMinidumps: WindowsHostDumpFile[];
|
recentMinidumps: WindowsHostDumpFile[];
|
||||||
assessmentHints: string[];
|
assessmentHints: string[];
|
||||||
errors: string[];
|
errors: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
const CACHE_TTL_MS = 15_000;
|
const CACHE_TTL_MS = 15_000;
|
||||||
|
|
||||||
let cachedAt = 0;
|
let cachedAt = 0;
|
||||||
let cachedValue: WindowsHostDiagnostics | null = null;
|
let cachedValue: WindowsHostDiagnostics | null = null;
|
||||||
|
|
||||||
function createEmptyDiagnostics(): WindowsHostDiagnostics {
|
function createEmptyDiagnostics(): WindowsHostDiagnostics {
|
||||||
return {
|
return {
|
||||||
collectedAt: new Date().toISOString(),
|
collectedAt: new Date().toISOString(),
|
||||||
supported: process.platform === "win32",
|
supported: process.platform === "win32",
|
||||||
platform: process.platform,
|
platform: process.platform,
|
||||||
crashControl: null,
|
crashControl: null,
|
||||||
recentKernelPower: [],
|
recentKernelPower: [],
|
||||||
recentWerKernel: [],
|
recentWerKernel: [],
|
||||||
recentKernelDump: [],
|
recentKernelDump: [],
|
||||||
recentAppCrashes: [],
|
recentAppCrashes: [],
|
||||||
recentMinidumps: [],
|
recentMinidumps: [],
|
||||||
assessmentHints: [],
|
assessmentHints: [],
|
||||||
errors: []
|
errors: []
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function runPowerShellJson(script: string): unknown {
|
function runPowerShellJson(script: string): unknown {
|
||||||
const result = spawnSync(
|
const result = spawnSync(
|
||||||
process.env.ComSpec && process.env.ComSpec.toLowerCase().includes("pwsh") ? process.env.ComSpec : "powershell.exe",
|
process.env.ComSpec && process.env.ComSpec.toLowerCase().includes("pwsh") ? process.env.ComSpec : "powershell.exe",
|
||||||
["-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-Command", script],
|
["-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-Command", script],
|
||||||
{
|
{
|
||||||
encoding: "utf8",
|
encoding: "utf8",
|
||||||
timeout: 20_000,
|
timeout: 20_000,
|
||||||
windowsHide: true,
|
windowsHide: true,
|
||||||
stdio: ["ignore", "pipe", "pipe"]
|
stdio: ["ignore", "pipe", "pipe"]
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
if (result.status !== 0) {
|
if (result.status !== 0) {
|
||||||
const errorText = String(result.stderr || result.stdout || "").trim() || `PowerShell exited with code ${result.status}`;
|
const errorText = String(result.stderr || result.stdout || "").trim() || `PowerShell exited with code ${result.status}`;
|
||||||
throw new Error(errorText);
|
throw new Error(errorText);
|
||||||
}
|
}
|
||||||
|
|
||||||
const text = String(result.stdout || "").trim();
|
const text = String(result.stdout || "").trim();
|
||||||
if (!text) {
|
if (!text) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
return JSON.parse(text) as unknown;
|
return JSON.parse(text) as unknown;
|
||||||
}
|
}
|
||||||
|
|
||||||
function asRecord(value: unknown): Record<string, unknown> | null {
|
function asRecord(value: unknown): Record<string, unknown> | null {
|
||||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
return value as Record<string, unknown>;
|
return value as Record<string, unknown>;
|
||||||
}
|
}
|
||||||
|
|
||||||
function asString(value: unknown): string {
|
function asString(value: unknown): string {
|
||||||
return typeof value === "string" ? value : value === undefined || value === null ? "" : String(value);
|
return typeof value === "string" ? value : value === undefined || value === null ? "" : String(value);
|
||||||
}
|
}
|
||||||
|
|
||||||
function asNumber(value: unknown): number | null {
|
function asNumber(value: unknown): number | null {
|
||||||
const parsed = Number(value);
|
const parsed = Number(value);
|
||||||
return Number.isFinite(parsed) ? parsed : null;
|
return Number.isFinite(parsed) ? parsed : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeEvent(value: unknown): WindowsHostEvent | null {
|
function normalizeEvent(value: unknown): WindowsHostEvent | null {
|
||||||
const record = asRecord(value);
|
const record = asRecord(value);
|
||||||
if (!record) {
|
if (!record) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
timeCreated: asString(record.TimeCreated),
|
timeCreated: asString(record.TimeCreated),
|
||||||
id: asNumber(record.Id) || 0,
|
id: asNumber(record.Id) || 0,
|
||||||
providerName: asString(record.ProviderName),
|
providerName: asString(record.ProviderName),
|
||||||
levelDisplayName: asString(record.LevelDisplayName),
|
levelDisplayName: asString(record.LevelDisplayName),
|
||||||
message: asString(record.Message),
|
message: asString(record.Message),
|
||||||
bugcheckCode: asString(record.BugcheckCode),
|
bugcheckCode: asString(record.BugcheckCode),
|
||||||
bugcheckCodeHex: asString(record.BugcheckCodeHex),
|
bugcheckCodeHex: asString(record.BugcheckCodeHex),
|
||||||
reportId: asString(record.ReportId)
|
reportId: asString(record.ReportId)
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeDumpFile(value: unknown): WindowsHostDumpFile | null {
|
function normalizeDumpFile(value: unknown): WindowsHostDumpFile | null {
|
||||||
const record = asRecord(value);
|
const record = asRecord(value);
|
||||||
if (!record) {
|
if (!record) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
name: asString(record.Name),
|
name: asString(record.Name),
|
||||||
fullName: asString(record.FullName),
|
fullName: asString(record.FullName),
|
||||||
length: asNumber(record.Length) || 0,
|
length: asNumber(record.Length) || 0,
|
||||||
lastWriteTime: asString(record.LastWriteTime)
|
lastWriteTime: asString(record.LastWriteTime)
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeCrashControl(value: unknown): WindowsCrashControlInfo | null {
|
function normalizeCrashControl(value: unknown): WindowsCrashControlInfo | null {
|
||||||
const record = asRecord(value);
|
const record = asRecord(value);
|
||||||
if (!record) {
|
if (!record) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
crashDumpEnabled: asNumber(record.CrashDumpEnabled),
|
crashDumpEnabled: asNumber(record.CrashDumpEnabled),
|
||||||
minidumpDir: asString(record.MinidumpDir),
|
minidumpDir: asString(record.MinidumpDir),
|
||||||
dumpFile: asString(record.DumpFile),
|
dumpFile: asString(record.DumpFile),
|
||||||
overwrite: asNumber(record.Overwrite),
|
overwrite: asNumber(record.Overwrite),
|
||||||
logEvent: asNumber(record.LogEvent),
|
logEvent: asNumber(record.LogEvent),
|
||||||
autoReboot: asNumber(record.AutoReboot)
|
autoReboot: asNumber(record.AutoReboot)
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function pushHints(diagnostics: WindowsHostDiagnostics): void {
|
function pushHints(diagnostics: WindowsHostDiagnostics): void {
|
||||||
if (diagnostics.recentKernelPower.some((entry) => String(entry.bugcheckCode || "").trim() === "0")) {
|
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.");
|
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))) {
|
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.");
|
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) {
|
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.");
|
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) {
|
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.");
|
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 {
|
function loadFromPowerShell(): WindowsHostDiagnostics {
|
||||||
const script = String.raw`
|
const script = String.raw`
|
||||||
$ErrorActionPreference = "SilentlyContinue"
|
$ErrorActionPreference = "SilentlyContinue"
|
||||||
|
|
||||||
function Convert-EventRecord($eventRecord) {
|
function Convert-EventRecord($eventRecord) {
|
||||||
$map = @{}
|
$map = @{}
|
||||||
try {
|
try {
|
||||||
[xml]$xml = $eventRecord.ToXml()
|
[xml]$xml = $eventRecord.ToXml()
|
||||||
foreach ($node in $xml.Event.EventData.Data) {
|
foreach ($node in $xml.Event.EventData.Data) {
|
||||||
if ($node.Name) {
|
if ($node.Name) {
|
||||||
$map[$node.Name] = [string]$node.'#text'
|
$map[$node.Name] = [string]$node.'#text'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
}
|
}
|
||||||
|
|
||||||
$reportId = ""
|
$reportId = ""
|
||||||
if ([string]$eventRecord.Message -match "ReportId\s+([^,\r\n]+)") {
|
if ([string]$eventRecord.Message -match "ReportId\s+([^,\r\n]+)") {
|
||||||
$reportId = $Matches[1]
|
$reportId = $Matches[1]
|
||||||
}
|
}
|
||||||
|
|
||||||
[PSCustomObject]@{
|
[PSCustomObject]@{
|
||||||
TimeCreated = if ($eventRecord.TimeCreated) { $eventRecord.TimeCreated.ToUniversalTime().ToString("o") } else { "" }
|
TimeCreated = if ($eventRecord.TimeCreated) { $eventRecord.TimeCreated.ToUniversalTime().ToString("o") } else { "" }
|
||||||
Id = [int]$eventRecord.Id
|
Id = [int]$eventRecord.Id
|
||||||
ProviderName = [string]$eventRecord.ProviderName
|
ProviderName = [string]$eventRecord.ProviderName
|
||||||
LevelDisplayName = [string]$eventRecord.LevelDisplayName
|
LevelDisplayName = [string]$eventRecord.LevelDisplayName
|
||||||
Message = [string]$eventRecord.Message
|
Message = [string]$eventRecord.Message
|
||||||
BugcheckCode = if ($map.ContainsKey("BugcheckCode")) { [string]$map["BugcheckCode"] } else { "" }
|
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 { "" }
|
BugcheckCodeHex = if ($map.ContainsKey("BugcheckCode") -and [int64]$map["BugcheckCode"] -gt 0) { ("0x{0:X}" -f [int64]$map["BugcheckCode"]) } else { "" }
|
||||||
ReportId = $reportId
|
ReportId = $reportId
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$startTime = (Get-Date).AddDays(-7)
|
$startTime = (Get-Date).AddDays(-7)
|
||||||
$crashControl = Get-ItemProperty "HKLM:\SYSTEM\CurrentControlSet\Control\CrashControl"
|
$crashControl = Get-ItemProperty "HKLM:\SYSTEM\CurrentControlSet\Control\CrashControl"
|
||||||
|
|
||||||
$kernelPower = @(
|
$kernelPower = @(
|
||||||
Get-WinEvent -FilterHashtable @{ LogName = "System"; Id = 41; StartTime = $startTime } -MaxEvents 5 |
|
Get-WinEvent -FilterHashtable @{ LogName = "System"; Id = 41; StartTime = $startTime } -MaxEvents 5 |
|
||||||
ForEach-Object { Convert-EventRecord $_ }
|
ForEach-Object { Convert-EventRecord $_ }
|
||||||
)
|
)
|
||||||
|
|
||||||
$werKernel = @(
|
$werKernel = @(
|
||||||
Get-WinEvent -FilterHashtable @{ LogName = "Microsoft-Windows-WerKernel/Operational"; StartTime = $startTime } -MaxEvents 30 |
|
Get-WinEvent -FilterHashtable @{ LogName = "Microsoft-Windows-WerKernel/Operational"; StartTime = $startTime } -MaxEvents 30 |
|
||||||
Where-Object { $_.Message -match "WATCHDOG|dump|bugcheck|blue|memory" } |
|
Where-Object { $_.Message -match "WATCHDOG|dump|bugcheck|blue|memory" } |
|
||||||
Select-Object -First 10 |
|
Select-Object -First 10 |
|
||||||
ForEach-Object { Convert-EventRecord $_ }
|
ForEach-Object { Convert-EventRecord $_ }
|
||||||
)
|
)
|
||||||
|
|
||||||
$kernelDump = @(
|
$kernelDump = @(
|
||||||
Get-WinEvent -FilterHashtable @{ LogName = "Microsoft-Windows-Kernel-Dump/Operational"; StartTime = $startTime } -MaxEvents 20 |
|
Get-WinEvent -FilterHashtable @{ LogName = "Microsoft-Windows-Kernel-Dump/Operational"; StartTime = $startTime } -MaxEvents 20 |
|
||||||
Select-Object -First 10 |
|
Select-Object -First 10 |
|
||||||
ForEach-Object { Convert-EventRecord $_ }
|
ForEach-Object { Convert-EventRecord $_ }
|
||||||
)
|
)
|
||||||
|
|
||||||
$appCrashes = @(
|
$appCrashes = @(
|
||||||
Get-WinEvent -FilterHashtable @{ LogName = "Application"; StartTime = $startTime } -MaxEvents 100 |
|
Get-WinEvent -FilterHashtable @{ LogName = "Application"; StartTime = $startTime } -MaxEvents 100 |
|
||||||
Where-Object {
|
Where-Object {
|
||||||
($_.ProviderName -eq "Application Error" -or $_.ProviderName -eq "Windows Error Reporting") -and
|
($_.ProviderName -eq "Application Error" -or $_.ProviderName -eq "Windows Error Reporting") -and
|
||||||
($_.Message -match "Real-Debrid-Downloader|electron|node\.exe|main\.js")
|
($_.Message -match "Real-Debrid-Downloader|electron|node\.exe|main\.js")
|
||||||
} |
|
} |
|
||||||
Select-Object -First 10 |
|
Select-Object -First 10 |
|
||||||
ForEach-Object { Convert-EventRecord $_ }
|
ForEach-Object { Convert-EventRecord $_ }
|
||||||
)
|
)
|
||||||
|
|
||||||
$dumpFiles = @()
|
$dumpFiles = @()
|
||||||
foreach ($dir in @("C:\Windows\Minidump", "C:\Windows\Minidumps")) {
|
foreach ($dir in @("C:\Windows\Minidump", "C:\Windows\Minidumps")) {
|
||||||
if (Test-Path $dir) {
|
if (Test-Path $dir) {
|
||||||
$dumpFiles += Get-ChildItem -Path $dir -File |
|
$dumpFiles += Get-ChildItem -Path $dir -File |
|
||||||
Sort-Object LastWriteTime -Descending |
|
Sort-Object LastWriteTime -Descending |
|
||||||
Select-Object -First 10 |
|
Select-Object -First 10 |
|
||||||
ForEach-Object {
|
ForEach-Object {
|
||||||
[PSCustomObject]@{
|
[PSCustomObject]@{
|
||||||
Name = $_.Name
|
Name = $_.Name
|
||||||
FullName = $_.FullName
|
FullName = $_.FullName
|
||||||
Length = [int64]$_.Length
|
Length = [int64]$_.Length
|
||||||
LastWriteTime = $_.LastWriteTimeUtc.ToString("o")
|
LastWriteTime = $_.LastWriteTimeUtc.ToString("o")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
[PSCustomObject]@{
|
[PSCustomObject]@{
|
||||||
CrashControl = [PSCustomObject]@{
|
CrashControl = [PSCustomObject]@{
|
||||||
CrashDumpEnabled = if ($null -ne $crashControl.CrashDumpEnabled) { [int]$crashControl.CrashDumpEnabled } else { $null }
|
CrashDumpEnabled = if ($null -ne $crashControl.CrashDumpEnabled) { [int]$crashControl.CrashDumpEnabled } else { $null }
|
||||||
MinidumpDir = [string]$crashControl.MinidumpDir
|
MinidumpDir = [string]$crashControl.MinidumpDir
|
||||||
DumpFile = [string]$crashControl.DumpFile
|
DumpFile = [string]$crashControl.DumpFile
|
||||||
Overwrite = if ($null -ne $crashControl.Overwrite) { [int]$crashControl.Overwrite } else { $null }
|
Overwrite = if ($null -ne $crashControl.Overwrite) { [int]$crashControl.Overwrite } else { $null }
|
||||||
LogEvent = if ($null -ne $crashControl.LogEvent) { [int]$crashControl.LogEvent } else { $null }
|
LogEvent = if ($null -ne $crashControl.LogEvent) { [int]$crashControl.LogEvent } else { $null }
|
||||||
AutoReboot = if ($null -ne $crashControl.AutoReboot) { [int]$crashControl.AutoReboot } else { $null }
|
AutoReboot = if ($null -ne $crashControl.AutoReboot) { [int]$crashControl.AutoReboot } else { $null }
|
||||||
}
|
}
|
||||||
RecentKernelPower = @($kernelPower)
|
RecentKernelPower = @($kernelPower)
|
||||||
RecentWerKernel = @($werKernel)
|
RecentWerKernel = @($werKernel)
|
||||||
RecentKernelDump = @($kernelDump)
|
RecentKernelDump = @($kernelDump)
|
||||||
RecentAppCrashes = @($appCrashes)
|
RecentAppCrashes = @($appCrashes)
|
||||||
RecentMinidumps = @($dumpFiles)
|
RecentMinidumps = @($dumpFiles)
|
||||||
} | ConvertTo-Json -Depth 6 -Compress
|
} | ConvertTo-Json -Depth 6 -Compress
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const raw = runPowerShellJson(script);
|
const raw = runPowerShellJson(script);
|
||||||
const parsed = asRecord(raw);
|
const parsed = asRecord(raw);
|
||||||
const diagnostics = createEmptyDiagnostics();
|
const diagnostics = createEmptyDiagnostics();
|
||||||
diagnostics.crashControl = normalizeCrashControl(parsed?.CrashControl ?? null);
|
diagnostics.crashControl = normalizeCrashControl(parsed?.CrashControl ?? null);
|
||||||
diagnostics.recentKernelPower = Array.isArray(parsed?.RecentKernelPower) ? parsed!.RecentKernelPower.map(normalizeEvent).filter(Boolean) as WindowsHostEvent[] : [];
|
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.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.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.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.recentMinidumps = Array.isArray(parsed?.RecentMinidumps) ? parsed!.RecentMinidumps.map(normalizeDumpFile).filter(Boolean) as WindowsHostDumpFile[] : [];
|
||||||
diagnostics.collectedAt = new Date().toISOString();
|
diagnostics.collectedAt = new Date().toISOString();
|
||||||
pushHints(diagnostics);
|
pushHints(diagnostics);
|
||||||
return diagnostics;
|
return diagnostics;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getWindowsHostDiagnostics(forceRefresh = false): WindowsHostDiagnostics {
|
export function getWindowsHostDiagnostics(forceRefresh = false): WindowsHostDiagnostics {
|
||||||
if (!forceRefresh && cachedValue && Date.now() - cachedAt < CACHE_TTL_MS) {
|
if (!forceRefresh && cachedValue && Date.now() - cachedAt < CACHE_TTL_MS) {
|
||||||
return cachedValue;
|
return cachedValue;
|
||||||
}
|
}
|
||||||
|
|
||||||
const diagnostics = createEmptyDiagnostics();
|
const diagnostics = createEmptyDiagnostics();
|
||||||
if (process.platform !== "win32") {
|
if (process.platform !== "win32") {
|
||||||
diagnostics.assessmentHints.push("Windows-Host-Diagnose ist nur unter Windows verfuegbar.");
|
diagnostics.assessmentHints.push("Windows-Host-Diagnose ist nur unter Windows verfuegbar.");
|
||||||
cachedAt = Date.now();
|
cachedAt = Date.now();
|
||||||
cachedValue = diagnostics;
|
cachedValue = diagnostics;
|
||||||
return diagnostics;
|
return diagnostics;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const loaded = loadFromPowerShell();
|
const loaded = loadFromPowerShell();
|
||||||
cachedAt = Date.now();
|
cachedAt = Date.now();
|
||||||
cachedValue = loaded;
|
cachedValue = loaded;
|
||||||
return loaded;
|
return loaded;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
diagnostics.errors.push(String(error instanceof Error ? error.message : error));
|
diagnostics.errors.push(String(error instanceof Error ? error.message : error));
|
||||||
diagnostics.assessmentHints.push("Host-Diagnose konnte nicht vollstaendig geladen werden.");
|
diagnostics.assessmentHints.push("Host-Diagnose konnte nicht vollstaendig geladen werden.");
|
||||||
cachedAt = Date.now();
|
cachedAt = Date.now();
|
||||||
cachedValue = diagnostics;
|
cachedValue = diagnostics;
|
||||||
return diagnostics;
|
return diagnostics;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getCachedWindowsHostDiagnostics(): WindowsHostDiagnostics | null {
|
export function getCachedWindowsHostDiagnostics(): WindowsHostDiagnostics | null {
|
||||||
return cachedValue;
|
return cachedValue;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function resetWindowsHostDiagnosticsCache(): void {
|
export function resetWindowsHostDiagnosticsCache(): void {
|
||||||
cachedAt = 0;
|
cachedAt = 0;
|
||||||
cachedValue = null;
|
cachedValue = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function hasRecentWindowsMinidumps(): boolean {
|
export function hasRecentWindowsMinidumps(): boolean {
|
||||||
for (const dir of ["C:\\Windows\\Minidump", "C:\\Windows\\Minidumps"]) {
|
for (const dir of ["C:\\Windows\\Minidump", "C:\\Windows\\Minidumps"]) {
|
||||||
try {
|
try {
|
||||||
const entries = fs.readdirSync(dir, { withFileTypes: true });
|
const entries = fs.readdirSync(dir, { withFileTypes: true });
|
||||||
if (entries.some((entry) => entry.isFile())) {
|
if (entries.some((entry) => entry.isFile())) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,124 +1,124 @@
|
|||||||
import { contextBridge, ipcRenderer } from "electron";
|
import { contextBridge, ipcRenderer } from "electron";
|
||||||
import {
|
import {
|
||||||
AddLinksPayload,
|
AddLinksPayload,
|
||||||
AllDebridHostInfo,
|
AllDebridHostInfo,
|
||||||
AppSettings,
|
AppSettings,
|
||||||
DebridAccountStatus,
|
DebridAccountStatus,
|
||||||
DebridLinkHostLimitInfo,
|
DebridLinkHostLimitInfo,
|
||||||
DebridProvider,
|
DebridProvider,
|
||||||
DuplicatePolicy,
|
DuplicatePolicy,
|
||||||
EnableRemoteDiagnosticsInput,
|
EnableRemoteDiagnosticsInput,
|
||||||
HistoryEntry,
|
HistoryEntry,
|
||||||
PackagePriority,
|
PackagePriority,
|
||||||
RemoteDiagnosticsInfo,
|
RemoteDiagnosticsInfo,
|
||||||
RendererErrorReport,
|
RendererErrorReport,
|
||||||
SessionStats,
|
SessionStats,
|
||||||
StartConflictEntry,
|
StartConflictEntry,
|
||||||
StartConflictResolutionResult,
|
StartConflictResolutionResult,
|
||||||
UiSnapshot,
|
UiSnapshot,
|
||||||
UpdateCheckResult,
|
UpdateCheckResult,
|
||||||
UpdateInstallProgress
|
UpdateInstallProgress
|
||||||
} from "../shared/types";
|
} from "../shared/types";
|
||||||
import { IPC_CHANNELS } from "../shared/ipc";
|
import { IPC_CHANNELS } from "../shared/ipc";
|
||||||
import { ElectronApi } from "../shared/preload-api";
|
import { ElectronApi } from "../shared/preload-api";
|
||||||
|
|
||||||
const api: ElectronApi = {
|
const api: ElectronApi = {
|
||||||
getSnapshot: (): Promise<UiSnapshot> => ipcRenderer.invoke(IPC_CHANNELS.GET_SNAPSHOT),
|
getSnapshot: (): Promise<UiSnapshot> => ipcRenderer.invoke(IPC_CHANNELS.GET_SNAPSHOT),
|
||||||
getVersion: (): Promise<string> => ipcRenderer.invoke(IPC_CHANNELS.GET_VERSION),
|
getVersion: (): Promise<string> => ipcRenderer.invoke(IPC_CHANNELS.GET_VERSION),
|
||||||
checkUpdates: (): Promise<UpdateCheckResult> => ipcRenderer.invoke(IPC_CHANNELS.CHECK_UPDATES),
|
checkUpdates: (): Promise<UpdateCheckResult> => ipcRenderer.invoke(IPC_CHANNELS.CHECK_UPDATES),
|
||||||
installUpdate: () => ipcRenderer.invoke(IPC_CHANNELS.INSTALL_UPDATE),
|
installUpdate: () => ipcRenderer.invoke(IPC_CHANNELS.INSTALL_UPDATE),
|
||||||
openExternal: (url: string): Promise<boolean> => ipcRenderer.invoke(IPC_CHANNELS.OPEN_EXTERNAL, url),
|
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),
|
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),
|
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),
|
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 }> =>
|
addLinks: (payload: AddLinksPayload): Promise<{ addedPackages: number; addedLinks: number; invalidCount: number }> =>
|
||||||
ipcRenderer.invoke(IPC_CHANNELS.ADD_LINKS, payload),
|
ipcRenderer.invoke(IPC_CHANNELS.ADD_LINKS, payload),
|
||||||
addContainers: (filePaths: string[]): Promise<{ addedPackages: number; addedLinks: number }> =>
|
addContainers: (filePaths: string[]): Promise<{ addedPackages: number; addedLinks: number }> =>
|
||||||
ipcRenderer.invoke(IPC_CHANNELS.ADD_CONTAINERS, filePaths),
|
ipcRenderer.invoke(IPC_CHANNELS.ADD_CONTAINERS, filePaths),
|
||||||
getStartConflicts: (): Promise<StartConflictEntry[]> => ipcRenderer.invoke(IPC_CHANNELS.GET_START_CONFLICTS),
|
getStartConflicts: (): Promise<StartConflictEntry[]> => ipcRenderer.invoke(IPC_CHANNELS.GET_START_CONFLICTS),
|
||||||
resolveStartConflict: (packageId: string, policy: DuplicatePolicy): Promise<StartConflictResolutionResult> =>
|
resolveStartConflict: (packageId: string, policy: DuplicatePolicy): Promise<StartConflictResolutionResult> =>
|
||||||
ipcRenderer.invoke(IPC_CHANNELS.RESOLVE_START_CONFLICT, packageId, policy),
|
ipcRenderer.invoke(IPC_CHANNELS.RESOLVE_START_CONFLICT, packageId, policy),
|
||||||
clearAll: (): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.CLEAR_ALL),
|
clearAll: (): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.CLEAR_ALL),
|
||||||
start: (): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.START),
|
start: (): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.START),
|
||||||
startPackages: (packageIds: string[]): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.START_PACKAGES, packageIds),
|
startPackages: (packageIds: string[]): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.START_PACKAGES, packageIds),
|
||||||
stop: (): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.STOP),
|
stop: (): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.STOP),
|
||||||
togglePause: (): Promise<boolean> => ipcRenderer.invoke(IPC_CHANNELS.TOGGLE_PAUSE),
|
togglePause: (): Promise<boolean> => ipcRenderer.invoke(IPC_CHANNELS.TOGGLE_PAUSE),
|
||||||
cancelPackage: (packageId: string): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.CANCEL_PACKAGE, packageId),
|
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),
|
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),
|
reorderPackages: (packageIds: string[]): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.REORDER_PACKAGES, packageIds),
|
||||||
removeItem: (itemId: string): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.REMOVE_ITEM, itemId),
|
removeItem: (itemId: string): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.REMOVE_ITEM, itemId),
|
||||||
togglePackage: (packageId: string): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.TOGGLE_PACKAGE, packageId),
|
togglePackage: (packageId: string): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.TOGGLE_PACKAGE, packageId),
|
||||||
exportPackageSelection: (packageIds: string[]) => ipcRenderer.invoke(IPC_CHANNELS.EXPORT_PACKAGE_SELECTION, packageIds),
|
exportPackageSelection: (packageIds: string[]) => ipcRenderer.invoke(IPC_CHANNELS.EXPORT_PACKAGE_SELECTION, packageIds),
|
||||||
exportItemSelection: (itemIds: string[]) => ipcRenderer.invoke(IPC_CHANNELS.EXPORT_ITEM_SELECTION, itemIds),
|
exportItemSelection: (itemIds: string[]) => ipcRenderer.invoke(IPC_CHANNELS.EXPORT_ITEM_SELECTION, itemIds),
|
||||||
exportQueue: (): Promise<{ saved: boolean }> => ipcRenderer.invoke(IPC_CHANNELS.EXPORT_QUEUE),
|
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),
|
importQueue: (json: string): Promise<{ addedPackages: number; addedLinks: number }> => ipcRenderer.invoke(IPC_CHANNELS.IMPORT_QUEUE, json),
|
||||||
toggleClipboard: (): Promise<boolean> => ipcRenderer.invoke(IPC_CHANNELS.TOGGLE_CLIPBOARD),
|
toggleClipboard: (): Promise<boolean> => ipcRenderer.invoke(IPC_CHANNELS.TOGGLE_CLIPBOARD),
|
||||||
pickFolder: (): Promise<string | null> => ipcRenderer.invoke(IPC_CHANNELS.PICK_FOLDER),
|
pickFolder: (): Promise<string | null> => ipcRenderer.invoke(IPC_CHANNELS.PICK_FOLDER),
|
||||||
pickContainers: (): Promise<string[]> => ipcRenderer.invoke(IPC_CHANNELS.PICK_CONTAINERS),
|
pickContainers: (): Promise<string[]> => ipcRenderer.invoke(IPC_CHANNELS.PICK_CONTAINERS),
|
||||||
getSessionStats: (): Promise<SessionStats> => ipcRenderer.invoke(IPC_CHANNELS.GET_SESSION_STATS),
|
getSessionStats: (): Promise<SessionStats> => ipcRenderer.invoke(IPC_CHANNELS.GET_SESSION_STATS),
|
||||||
resetSessionStats: (): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.RESET_SESSION_STATS),
|
resetSessionStats: (): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.RESET_SESSION_STATS),
|
||||||
resetDownloadStats: (): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.RESET_DOWNLOAD_STATS),
|
resetDownloadStats: (): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.RESET_DOWNLOAD_STATS),
|
||||||
restart: (): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.RESTART),
|
restart: (): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.RESTART),
|
||||||
quit: (): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.QUIT),
|
quit: (): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.QUIT),
|
||||||
exportBackup: (): Promise<{ saved: boolean }> => ipcRenderer.invoke(IPC_CHANNELS.EXPORT_BACKUP),
|
exportBackup: (): Promise<{ saved: boolean }> => ipcRenderer.invoke(IPC_CHANNELS.EXPORT_BACKUP),
|
||||||
importBackup: (): Promise<{ restored: boolean; relaunch: boolean; message: string }> => ipcRenderer.invoke(IPC_CHANNELS.IMPORT_BACKUP),
|
importBackup: (): Promise<{ restored: boolean; relaunch: boolean; message: string }> => ipcRenderer.invoke(IPC_CHANNELS.IMPORT_BACKUP),
|
||||||
exportSupportBundle: (): Promise<{ saved: boolean; filePath?: string }> => ipcRenderer.invoke(IPC_CHANNELS.EXPORT_SUPPORT_BUNDLE),
|
exportSupportBundle: (): Promise<{ saved: boolean; filePath?: string }> => ipcRenderer.invoke(IPC_CHANNELS.EXPORT_SUPPORT_BUNDLE),
|
||||||
openLog: (): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.OPEN_LOG),
|
openLog: (): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.OPEN_LOG),
|
||||||
openAuditLog: (): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.OPEN_AUDIT_LOG),
|
openAuditLog: (): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.OPEN_AUDIT_LOG),
|
||||||
openRenameLog: (): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.OPEN_RENAME_LOG),
|
openRenameLog: (): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.OPEN_RENAME_LOG),
|
||||||
openSessionLog: (): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.OPEN_SESSION_LOG),
|
openSessionLog: (): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.OPEN_SESSION_LOG),
|
||||||
openTraceLog: (): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.OPEN_TRACE_LOG),
|
openTraceLog: (): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.OPEN_TRACE_LOG),
|
||||||
openPackageLog: (packageId: string): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.OPEN_PACKAGE_LOG, packageId),
|
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),
|
openItemLog: (itemId: string): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.OPEN_ITEM_LOG, itemId),
|
||||||
getDebugSetupCheck: () => ipcRenderer.invoke(IPC_CHANNELS.GET_DEBUG_SETUP_CHECK),
|
getDebugSetupCheck: () => ipcRenderer.invoke(IPC_CHANNELS.GET_DEBUG_SETUP_CHECK),
|
||||||
getRecentErrors: () => ipcRenderer.invoke(IPC_CHANNELS.GET_RECENT_ERRORS),
|
getRecentErrors: () => ipcRenderer.invoke(IPC_CHANNELS.GET_RECENT_ERRORS),
|
||||||
testNotification: (url: string, mention: string) => ipcRenderer.invoke(IPC_CHANNELS.TEST_NOTIFY, url, mention),
|
testNotification: (url: string, mention: string) => ipcRenderer.invoke(IPC_CHANNELS.TEST_NOTIFY, url, mention),
|
||||||
getTraceConfig: () => ipcRenderer.invoke(IPC_CHANNELS.GET_TRACE_CONFIG),
|
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),
|
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),
|
rotateDebugToken: (): Promise<{ path: string }> => ipcRenderer.invoke(IPC_CHANNELS.ROTATE_DEBUG_TOKEN),
|
||||||
getRemoteDiagnostics: (): Promise<RemoteDiagnosticsInfo> => ipcRenderer.invoke(IPC_CHANNELS.GET_REMOTE_DIAGNOSTICS),
|
getRemoteDiagnostics: (): Promise<RemoteDiagnosticsInfo> => ipcRenderer.invoke(IPC_CHANNELS.GET_REMOTE_DIAGNOSTICS),
|
||||||
enableRemoteDiagnostics: (input: EnableRemoteDiagnosticsInput): Promise<RemoteDiagnosticsInfo> => ipcRenderer.invoke(IPC_CHANNELS.ENABLE_REMOTE_DIAGNOSTICS, input),
|
enableRemoteDiagnostics: (input: EnableRemoteDiagnosticsInput): Promise<RemoteDiagnosticsInfo> => ipcRenderer.invoke(IPC_CHANNELS.ENABLE_REMOTE_DIAGNOSTICS, input),
|
||||||
disableRemoteDiagnostics: (): Promise<RemoteDiagnosticsInfo> => ipcRenderer.invoke(IPC_CHANNELS.DISABLE_REMOTE_DIAGNOSTICS),
|
disableRemoteDiagnostics: (): Promise<RemoteDiagnosticsInfo> => ipcRenderer.invoke(IPC_CHANNELS.DISABLE_REMOTE_DIAGNOSTICS),
|
||||||
rotateRemoteDiagnosticsToken: (): Promise<RemoteDiagnosticsInfo> => ipcRenderer.invoke(IPC_CHANNELS.ROTATE_REMOTE_DIAGNOSTICS_TOKEN),
|
rotateRemoteDiagnosticsToken: (): Promise<RemoteDiagnosticsInfo> => ipcRenderer.invoke(IPC_CHANNELS.ROTATE_REMOTE_DIAGNOSTICS_TOKEN),
|
||||||
openRealDebridLogin: (): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.OPEN_REALDEBRID_LOGIN),
|
openRealDebridLogin: (): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.OPEN_REALDEBRID_LOGIN),
|
||||||
openAllDebridLogin: (): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.OPEN_ALLDEBRID_LOGIN),
|
openAllDebridLogin: (): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.OPEN_ALLDEBRID_LOGIN),
|
||||||
importBestDebridCookies: (): Promise<number> => ipcRenderer.invoke(IPC_CHANNELS.IMPORT_BESTDEBRID_COOKIES),
|
importBestDebridCookies: (): Promise<number> => ipcRenderer.invoke(IPC_CHANNELS.IMPORT_BESTDEBRID_COOKIES),
|
||||||
getAllDebridHostInfo: (): Promise<AllDebridHostInfo> => ipcRenderer.invoke(IPC_CHANNELS.GET_ALLDEBRID_HOST_INFO),
|
getAllDebridHostInfo: (): Promise<AllDebridHostInfo> => ipcRenderer.invoke(IPC_CHANNELS.GET_ALLDEBRID_HOST_INFO),
|
||||||
getDebridLinkHostLimits: (): Promise<DebridLinkHostLimitInfo[]> => ipcRenderer.invoke(IPC_CHANNELS.GET_DEBRIDLINK_HOST_LIMITS),
|
getDebridLinkHostLimits: (): Promise<DebridLinkHostLimitInfo[]> => ipcRenderer.invoke(IPC_CHANNELS.GET_DEBRIDLINK_HOST_LIMITS),
|
||||||
checkDebridAccounts: (): Promise<DebridAccountStatus[]> => ipcRenderer.invoke(IPC_CHANNELS.CHECK_DEBRID_ACCOUNTS),
|
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),
|
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),
|
retryExtraction: (packageId: string): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.RETRY_EXTRACTION, packageId),
|
||||||
extractNow: (packageId: string): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.EXTRACT_NOW, 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),
|
resetPackage: (packageId: string): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.RESET_PACKAGE, packageId),
|
||||||
getHistory: (): Promise<HistoryEntry[]> => ipcRenderer.invoke(IPC_CHANNELS.GET_HISTORY),
|
getHistory: (): Promise<HistoryEntry[]> => ipcRenderer.invoke(IPC_CHANNELS.GET_HISTORY),
|
||||||
clearHistory: (): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.CLEAR_HISTORY),
|
clearHistory: (): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.CLEAR_HISTORY),
|
||||||
removeHistoryEntry: (entryId: string): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.REMOVE_HISTORY_ENTRY, entryId),
|
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),
|
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),
|
skipItems: (itemIds: string[]): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.SKIP_ITEMS, itemIds),
|
||||||
resetItems: (itemIds: string[]): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.RESET_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),
|
startItems: (itemIds: string[]): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.START_ITEMS, itemIds),
|
||||||
reportRendererError: (report: RendererErrorReport): void => ipcRenderer.send(IPC_CHANNELS.LOG_RENDERER_ERROR, report),
|
reportRendererError: (report: RendererErrorReport): void => ipcRenderer.send(IPC_CHANNELS.LOG_RENDERER_ERROR, report),
|
||||||
onStateUpdate: (callback: (snapshot: UiSnapshot) => void): (() => void) => {
|
onStateUpdate: (callback: (snapshot: UiSnapshot) => void): (() => void) => {
|
||||||
const listener = (_event: unknown, snapshot: UiSnapshot): void => callback(snapshot);
|
const listener = (_event: unknown, snapshot: UiSnapshot): void => callback(snapshot);
|
||||||
ipcRenderer.on(IPC_CHANNELS.STATE_UPDATE, listener);
|
ipcRenderer.on(IPC_CHANNELS.STATE_UPDATE, listener);
|
||||||
return () => {
|
return () => {
|
||||||
ipcRenderer.removeListener(IPC_CHANNELS.STATE_UPDATE, listener);
|
ipcRenderer.removeListener(IPC_CHANNELS.STATE_UPDATE, listener);
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
onClipboardDetected: (callback: (links: string[]) => void): (() => void) => {
|
onClipboardDetected: (callback: (links: string[]) => void): (() => void) => {
|
||||||
const listener = (_event: unknown, links: string[]): void => callback(links);
|
const listener = (_event: unknown, links: string[]): void => callback(links);
|
||||||
ipcRenderer.on(IPC_CHANNELS.CLIPBOARD_DETECTED, listener);
|
ipcRenderer.on(IPC_CHANNELS.CLIPBOARD_DETECTED, listener);
|
||||||
return () => {
|
return () => {
|
||||||
ipcRenderer.removeListener(IPC_CHANNELS.CLIPBOARD_DETECTED, listener);
|
ipcRenderer.removeListener(IPC_CHANNELS.CLIPBOARD_DETECTED, listener);
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
onUpdateInstallProgress: (callback: (progress: UpdateInstallProgress) => void): (() => void) => {
|
onUpdateInstallProgress: (callback: (progress: UpdateInstallProgress) => void): (() => void) => {
|
||||||
const listener = (_event: unknown, progress: UpdateInstallProgress): void => callback(progress);
|
const listener = (_event: unknown, progress: UpdateInstallProgress): void => callback(progress);
|
||||||
ipcRenderer.on(IPC_CHANNELS.UPDATE_INSTALL_PROGRESS, listener);
|
ipcRenderer.on(IPC_CHANNELS.UPDATE_INSTALL_PROGRESS, listener);
|
||||||
return () => {
|
return () => {
|
||||||
ipcRenderer.removeListener(IPC_CHANNELS.UPDATE_INSTALL_PROGRESS, listener);
|
ipcRenderer.removeListener(IPC_CHANNELS.UPDATE_INSTALL_PROGRESS, listener);
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
contextBridge.exposeInMainWorld("rd", api);
|
contextBridge.exposeInMainWorld("rd", api);
|
||||||
|
|||||||
14650
src/renderer/App.tsx
14650
src/renderer/App.tsx
File diff suppressed because it is too large
Load Diff
@ -1,94 +1,94 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
|
|
||||||
interface ErrorBoundaryProps {
|
interface ErrorBoundaryProps {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ErrorBoundaryState {
|
interface ErrorBoundaryState {
|
||||||
hasError: boolean;
|
hasError: boolean;
|
||||||
message: string;
|
message: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Catches render-time errors in the component tree so a crash shows a minimal
|
// 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
|
// 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
|
// 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.
|
// error inside the error path is how you get a second white screen or a loop.
|
||||||
export class ErrorBoundary extends React.Component<ErrorBoundaryProps, ErrorBoundaryState> {
|
export class ErrorBoundary extends React.Component<ErrorBoundaryProps, ErrorBoundaryState> {
|
||||||
constructor(props: ErrorBoundaryProps) {
|
constructor(props: ErrorBoundaryProps) {
|
||||||
super(props);
|
super(props);
|
||||||
this.state = { hasError: false, message: "" };
|
this.state = { hasError: false, message: "" };
|
||||||
}
|
}
|
||||||
|
|
||||||
static getDerivedStateFromError(error: unknown): ErrorBoundaryState {
|
static getDerivedStateFromError(error: unknown): ErrorBoundaryState {
|
||||||
return { hasError: true, message: error instanceof Error ? error.message : String(error) };
|
return { hasError: true, message: error instanceof Error ? error.message : String(error) };
|
||||||
}
|
}
|
||||||
|
|
||||||
componentDidCatch(error: unknown, info: React.ErrorInfo): void {
|
componentDidCatch(error: unknown, info: React.ErrorInfo): void {
|
||||||
try {
|
try {
|
||||||
window.rd?.reportRendererError({
|
window.rd?.reportRendererError({
|
||||||
kind: "react",
|
kind: "react",
|
||||||
message: error instanceof Error ? error.message : String(error),
|
message: error instanceof Error ? error.message : String(error),
|
||||||
stack: error instanceof Error ? error.stack : undefined,
|
stack: error instanceof Error ? error.stack : undefined,
|
||||||
componentStack: info?.componentStack || undefined
|
componentStack: info?.componentStack || undefined
|
||||||
});
|
});
|
||||||
} catch {
|
} catch {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private handleReload = (): void => {
|
private handleReload = (): void => {
|
||||||
window.location.reload();
|
window.location.reload();
|
||||||
};
|
};
|
||||||
|
|
||||||
render(): React.ReactNode {
|
render(): React.ReactNode {
|
||||||
if (!this.state.hasError) {
|
if (!this.state.hasError) {
|
||||||
return this.props.children;
|
return this.props.children;
|
||||||
}
|
}
|
||||||
const overlay: React.CSSProperties = {
|
const overlay: React.CSSProperties = {
|
||||||
position: "fixed",
|
position: "fixed",
|
||||||
inset: 0,
|
inset: 0,
|
||||||
display: "flex",
|
display: "flex",
|
||||||
flexDirection: "column",
|
flexDirection: "column",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
justifyContent: "center",
|
justifyContent: "center",
|
||||||
gap: 16,
|
gap: 16,
|
||||||
padding: 32,
|
padding: 32,
|
||||||
background: "#070b14",
|
background: "#070b14",
|
||||||
color: "#e6edf6",
|
color: "#e6edf6",
|
||||||
fontFamily: "Segoe UI, system-ui, sans-serif",
|
fontFamily: "Segoe UI, system-ui, sans-serif",
|
||||||
textAlign: "center"
|
textAlign: "center"
|
||||||
};
|
};
|
||||||
const pre: React.CSSProperties = {
|
const pre: React.CSSProperties = {
|
||||||
maxWidth: 640,
|
maxWidth: 640,
|
||||||
maxHeight: 200,
|
maxHeight: 200,
|
||||||
overflow: "auto",
|
overflow: "auto",
|
||||||
padding: 12,
|
padding: 12,
|
||||||
background: "#0d1422",
|
background: "#0d1422",
|
||||||
border: "1px solid #243049",
|
border: "1px solid #243049",
|
||||||
borderRadius: 6,
|
borderRadius: 6,
|
||||||
color: "#ff9a8c",
|
color: "#ff9a8c",
|
||||||
fontSize: 12,
|
fontSize: 12,
|
||||||
whiteSpace: "pre-wrap",
|
whiteSpace: "pre-wrap",
|
||||||
textAlign: "left"
|
textAlign: "left"
|
||||||
};
|
};
|
||||||
const button: React.CSSProperties = {
|
const button: React.CSSProperties = {
|
||||||
padding: "8px 20px",
|
padding: "8px 20px",
|
||||||
background: "#2d5cff",
|
background: "#2d5cff",
|
||||||
color: "#fff",
|
color: "#fff",
|
||||||
border: "none",
|
border: "none",
|
||||||
borderRadius: 6,
|
borderRadius: 6,
|
||||||
cursor: "pointer",
|
cursor: "pointer",
|
||||||
fontSize: 14
|
fontSize: 14
|
||||||
};
|
};
|
||||||
return (
|
return (
|
||||||
<div style={overlay}>
|
<div style={overlay}>
|
||||||
<h1 style={{ margin: 0, fontSize: 20 }}>Die Oberfläche hat einen Fehler ausgelöst</h1>
|
<h1 style={{ margin: 0, fontSize: 20 }}>Die Oberfläche hat einen Fehler ausgelöst</h1>
|
||||||
<p style={{ margin: 0, maxWidth: 560, color: "#9aa7bd" }}>
|
<p style={{ margin: 0, maxWidth: 560, color: "#9aa7bd" }}>
|
||||||
Die Anzeige wurde gestoppt, um Datenverlust zu vermeiden. Die laufenden Downloads im
|
Die Anzeige wurde gestoppt, um Datenverlust zu vermeiden. Die laufenden Downloads im
|
||||||
Hintergrund sind nicht betroffen. Der Fehler wurde ins Log geschrieben.
|
Hintergrund sind nicht betroffen. Der Fehler wurde ins Log geschrieben.
|
||||||
</p>
|
</p>
|
||||||
<pre style={pre}>{this.state.message}</pre>
|
<pre style={pre}>{this.state.message}</pre>
|
||||||
<button type="button" style={button} onClick={this.handleReload}>Oberfläche neu laden</button>
|
<button type="button" style={button} onClick={this.handleReload}>Oberfläche neu laden</button>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,12 +1,12 @@
|
|||||||
<!doctype html>
|
<!doctype html>
|
||||||
<html lang="de">
|
<html lang="de">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>Multi Debrid Downloader</title>
|
<title>Multi Debrid Downloader</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
<script type="module" src="./main.tsx"></script>
|
<script type="module" src="./main.tsx"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@ -1,48 +1,48 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import { createRoot } from "react-dom/client";
|
import { createRoot } from "react-dom/client";
|
||||||
import { App } from "./App";
|
import { App } from "./App";
|
||||||
import { ErrorBoundary } from "./error-boundary";
|
import { ErrorBoundary } from "./error-boundary";
|
||||||
import "./styles.css";
|
import "./styles.css";
|
||||||
|
|
||||||
// Forward otherwise-silent renderer failures (uncaught errors, unhandled promise
|
// Forward otherwise-silent renderer failures (uncaught errors, unhandled promise
|
||||||
// rejections) to the main process log. Without this, a renderer crash leaves no
|
// rejections) to the main process log. Without this, a renderer crash leaves no
|
||||||
// trace anywhere on an unattended server.
|
// trace anywhere on an unattended server.
|
||||||
function reportRendererError(report: Parameters<typeof window.rd.reportRendererError>[0]): void {
|
function reportRendererError(report: Parameters<typeof window.rd.reportRendererError>[0]): void {
|
||||||
try {
|
try {
|
||||||
window.rd?.reportRendererError(report);
|
window.rd?.reportRendererError(report);
|
||||||
} catch {
|
} catch {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
window.addEventListener("error", (event) => {
|
window.addEventListener("error", (event) => {
|
||||||
reportRendererError({
|
reportRendererError({
|
||||||
kind: "error",
|
kind: "error",
|
||||||
message: event.message || String(event.error || "Unbekannter Fehler"),
|
message: event.message || String(event.error || "Unbekannter Fehler"),
|
||||||
stack: event.error instanceof Error ? event.error.stack : undefined,
|
stack: event.error instanceof Error ? event.error.stack : undefined,
|
||||||
source: event.filename || undefined,
|
source: event.filename || undefined,
|
||||||
line: typeof event.lineno === "number" ? event.lineno : undefined,
|
line: typeof event.lineno === "number" ? event.lineno : undefined,
|
||||||
column: typeof event.colno === "number" ? event.colno : undefined
|
column: typeof event.colno === "number" ? event.colno : undefined
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
window.addEventListener("unhandledrejection", (event) => {
|
window.addEventListener("unhandledrejection", (event) => {
|
||||||
const reason = event.reason;
|
const reason = event.reason;
|
||||||
reportRendererError({
|
reportRendererError({
|
||||||
kind: "unhandledrejection",
|
kind: "unhandledrejection",
|
||||||
message: reason instanceof Error ? reason.message : String(reason),
|
message: reason instanceof Error ? reason.message : String(reason),
|
||||||
stack: reason instanceof Error ? reason.stack : undefined
|
stack: reason instanceof Error ? reason.stack : undefined
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
const rootElement = document.getElementById("root");
|
const rootElement = document.getElementById("root");
|
||||||
if (!rootElement) {
|
if (!rootElement) {
|
||||||
throw new Error("Root element fehlt");
|
throw new Error("Root element fehlt");
|
||||||
}
|
}
|
||||||
|
|
||||||
createRoot(rootElement).render(
|
createRoot(rootElement).render(
|
||||||
<React.StrictMode>
|
<React.StrictMode>
|
||||||
<ErrorBoundary>
|
<ErrorBoundary>
|
||||||
<App />
|
<App />
|
||||||
</ErrorBoundary>
|
</ErrorBoundary>
|
||||||
</React.StrictMode>
|
</React.StrictMode>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -1,61 +1,61 @@
|
|||||||
import type { DownloadItem, DownloadStatus, PackageEntry } from "../shared/types";
|
import type { DownloadItem, DownloadStatus, PackageEntry } from "../shared/types";
|
||||||
|
|
||||||
const ACTIVE_PACKAGE_STATUSES = new Set<DownloadStatus>(["downloading", "validating", "integrity_check", "extracting"]);
|
const ACTIVE_PACKAGE_STATUSES = new Set<DownloadStatus>(["downloading", "validating", "integrity_check", "extracting"]);
|
||||||
|
|
||||||
export function reorderPackageOrderByDrop(order: string[], draggedPackageId: string, targetPackageId: string): string[] {
|
export function reorderPackageOrderByDrop(order: string[], draggedPackageId: string, targetPackageId: string): string[] {
|
||||||
const fromIndex = order.indexOf(draggedPackageId);
|
const fromIndex = order.indexOf(draggedPackageId);
|
||||||
const toIndex = order.indexOf(targetPackageId);
|
const toIndex = order.indexOf(targetPackageId);
|
||||||
if (fromIndex < 0 || toIndex < 0 || fromIndex === toIndex) {
|
if (fromIndex < 0 || toIndex < 0 || fromIndex === toIndex) {
|
||||||
return order;
|
return order;
|
||||||
}
|
}
|
||||||
const next = [...order];
|
const next = [...order];
|
||||||
const [dragged] = next.splice(fromIndex, 1);
|
const [dragged] = next.splice(fromIndex, 1);
|
||||||
const insertIndex = Math.max(0, Math.min(next.length, toIndex));
|
const insertIndex = Math.max(0, Math.min(next.length, toIndex));
|
||||||
next.splice(insertIndex, 0, dragged);
|
next.splice(insertIndex, 0, dragged);
|
||||||
return next;
|
return next;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function sortPackageOrderByName(order: string[], packages: Record<string, PackageEntry>, descending: boolean): string[] {
|
export function sortPackageOrderByName(order: string[], packages: Record<string, PackageEntry>, descending: boolean): string[] {
|
||||||
const sorted = [...order];
|
const sorted = [...order];
|
||||||
sorted.sort((a, b) => {
|
sorted.sort((a, b) => {
|
||||||
const nameA = (packages[a]?.name ?? "").toLowerCase();
|
const nameA = (packages[a]?.name ?? "").toLowerCase();
|
||||||
const nameB = (packages[b]?.name ?? "").toLowerCase();
|
const nameB = (packages[b]?.name ?? "").toLowerCase();
|
||||||
const cmp = nameA.localeCompare(nameB, undefined, { numeric: true, sensitivity: "base" });
|
const cmp = nameA.localeCompare(nameB, undefined, { numeric: true, sensitivity: "base" });
|
||||||
return descending ? -cmp : cmp;
|
return descending ? -cmp : cmp;
|
||||||
});
|
});
|
||||||
return sorted;
|
return sorted;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function sortPackagesForDisplay(
|
export function sortPackagesForDisplay(
|
||||||
packages: PackageEntry[],
|
packages: PackageEntry[],
|
||||||
itemsById: Record<string, DownloadItem>,
|
itemsById: Record<string, DownloadItem>,
|
||||||
running: boolean,
|
running: boolean,
|
||||||
autoSortPackagesByProgress: boolean
|
autoSortPackagesByProgress: boolean
|
||||||
): PackageEntry[] {
|
): PackageEntry[] {
|
||||||
if (!running || !autoSortPackagesByProgress || packages.length <= 1) {
|
if (!running || !autoSortPackagesByProgress || packages.length <= 1) {
|
||||||
return packages;
|
return packages;
|
||||||
}
|
}
|
||||||
|
|
||||||
const active: PackageEntry[] = [];
|
const active: PackageEntry[] = [];
|
||||||
const rest: PackageEntry[] = [];
|
const rest: PackageEntry[] = [];
|
||||||
|
|
||||||
// Float packages that have an active item to the top, but keep BOTH groups in
|
// 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
|
// their original (queue) order. Earlier this sorted the active group by live
|
||||||
// completedRatio/downloadedBytes — which change on every progress tick (every
|
// completedRatio/downloadedBytes — which change on every progress tick (every
|
||||||
// 150-700ms), so active packages visibly reshuffled the whole time. A package
|
// 150-700ms), so active packages visibly reshuffled the whole time. A package
|
||||||
// entering/leaving the active bucket is a real, discrete event (start/finish);
|
// entering/leaving the active bucket is a real, discrete event (start/finish);
|
||||||
// ranking *within* the bucket by live bytes was pure jitter nobody needs.
|
// ranking *within* the bucket by live bytes was pure jitter nobody needs.
|
||||||
for (const pkg of packages) {
|
for (const pkg of packages) {
|
||||||
const hasActive = pkg.itemIds.some((id) => {
|
const hasActive = pkg.itemIds.some((id) => {
|
||||||
const item = itemsById[id];
|
const item = itemsById[id];
|
||||||
return item != null && ACTIVE_PACKAGE_STATUSES.has(item.status);
|
return item != null && ACTIVE_PACKAGE_STATUSES.has(item.status);
|
||||||
});
|
});
|
||||||
(hasActive ? active : rest).push(pkg);
|
(hasActive ? active : rest).push(pkg);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (active.length === 0 || active.length === packages.length) {
|
if (active.length === 0 || active.length === packages.length) {
|
||||||
return packages;
|
return packages;
|
||||||
}
|
}
|
||||||
|
|
||||||
return [...active, ...rest];
|
return [...active, ...rest];
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,27 +1,27 @@
|
|||||||
import type { SessionState } from "../shared/types";
|
import type { SessionState } from "../shared/types";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Drop selected ids whose package OR item no longer exists in the session.
|
* 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
|
* The selection set mixes package and item ids; when entries vanish (delta
|
||||||
* removal, backup-driven session swap, completed-cleanup) a stale id would
|
* removal, backup-driven session swap, completed-cleanup) a stale id would
|
||||||
* otherwise inflate the selection count and the "(N)" action labels and keep
|
* otherwise inflate the selection count and the "(N)" action labels and keep
|
||||||
* "multi" styling alive for ghosts.
|
* "multi" styling alive for ghosts.
|
||||||
*
|
*
|
||||||
* Returns the SAME set instance when nothing changed, so callers can use it
|
* Returns the SAME set instance when nothing changed, so callers can use it
|
||||||
* directly as a React state updater without forcing a re-render.
|
* directly as a React state updater without forcing a re-render.
|
||||||
*/
|
*/
|
||||||
export function pruneSelection(
|
export function pruneSelection(
|
||||||
selected: ReadonlySet<string>,
|
selected: ReadonlySet<string>,
|
||||||
session: Pick<SessionState, "packages" | "items">
|
session: Pick<SessionState, "packages" | "items">
|
||||||
): Set<string> {
|
): Set<string> {
|
||||||
if (selected.size === 0) {
|
if (selected.size === 0) {
|
||||||
return selected as Set<string>;
|
return selected as Set<string>;
|
||||||
}
|
}
|
||||||
const next = new Set<string>();
|
const next = new Set<string>();
|
||||||
for (const id of selected) {
|
for (const id of selected) {
|
||||||
if (session.packages[id] || session.items[id]) {
|
if (session.packages[id] || session.items[id]) {
|
||||||
next.add(id);
|
next.add(id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return next.size === selected.size ? (selected as Set<string>) : next;
|
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";
|
import type { ElectronApi } from "../shared/preload-api";
|
||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
interface Window {
|
interface Window {
|
||||||
rd: ElectronApi;
|
rd: ElectronApi;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export {};
|
export {};
|
||||||
|
|||||||
@ -1,66 +1,66 @@
|
|||||||
export interface DebridLinkApiKeyEntry {
|
export interface DebridLinkApiKeyEntry {
|
||||||
id: string;
|
id: string;
|
||||||
token: string;
|
token: string;
|
||||||
index: number;
|
index: number;
|
||||||
label: string;
|
label: string;
|
||||||
masked: string;
|
masked: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const FNV64_OFFSET_BASIS = 0xcbf29ce484222325n;
|
const FNV64_OFFSET_BASIS = 0xcbf29ce484222325n;
|
||||||
const FNV64_PRIME = 0x100000001b3n;
|
const FNV64_PRIME = 0x100000001b3n;
|
||||||
const FNV64_MASK = 0xffffffffffffffffn;
|
const FNV64_MASK = 0xffffffffffffffffn;
|
||||||
|
|
||||||
function fnv1a64(text: string): string {
|
function fnv1a64(text: string): string {
|
||||||
let hash = FNV64_OFFSET_BASIS;
|
let hash = FNV64_OFFSET_BASIS;
|
||||||
for (const char of text) {
|
for (const char of text) {
|
||||||
hash ^= BigInt(char.codePointAt(0) || 0);
|
hash ^= BigInt(char.codePointAt(0) || 0);
|
||||||
hash = (hash * FNV64_PRIME) & FNV64_MASK;
|
hash = (hash * FNV64_PRIME) & FNV64_MASK;
|
||||||
}
|
}
|
||||||
return hash.toString(36);
|
return hash.toString(36);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function maskDebridLinkApiKey(token: string): string {
|
export function maskDebridLinkApiKey(token: string): string {
|
||||||
const trimmed = token.trim();
|
const trimmed = token.trim();
|
||||||
if (!trimmed) {
|
if (!trimmed) {
|
||||||
return "Nicht hinterlegt";
|
return "Nicht hinterlegt";
|
||||||
}
|
}
|
||||||
if (trimmed.length <= 6) {
|
if (trimmed.length <= 6) {
|
||||||
return "*".repeat(trimmed.length);
|
return "*".repeat(trimmed.length);
|
||||||
}
|
}
|
||||||
return `${trimmed.slice(0, 3)}${"*".repeat(Math.max(4, trimmed.length - 6))}${trimmed.slice(-3)}`;
|
return `${trimmed.slice(0, 3)}${"*".repeat(Math.max(4, trimmed.length - 6))}${trimmed.slice(-3)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getDebridLinkApiKeyId(token: string): string {
|
export function getDebridLinkApiKeyId(token: string): string {
|
||||||
return `dlk_${fnv1a64(token.trim())}`;
|
return `dlk_${fnv1a64(token.trim())}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getDebridLinkApiKeyLabel(index: number): string {
|
export function getDebridLinkApiKeyLabel(index: number): string {
|
||||||
return `Key ${index + 1}`;
|
return `Key ${index + 1}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function parseDebridLinkApiKeys(raw: string): DebridLinkApiKeyEntry[] {
|
export function parseDebridLinkApiKeys(raw: string): DebridLinkApiKeyEntry[] {
|
||||||
const seen = new Set<string>();
|
const seen = new Set<string>();
|
||||||
const tokens = String(raw || "")
|
const tokens = String(raw || "")
|
||||||
.split(/[\n,]+/)
|
.split(/[\n,]+/)
|
||||||
.map((entry) => entry.trim())
|
.map((entry) => entry.trim())
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.filter((token) => {
|
.filter((token) => {
|
||||||
if (seen.has(token)) {
|
if (seen.has(token)) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
seen.add(token);
|
seen.add(token);
|
||||||
return true;
|
return true;
|
||||||
});
|
});
|
||||||
|
|
||||||
return tokens.map((token, index) => ({
|
return tokens.map((token, index) => ({
|
||||||
id: getDebridLinkApiKeyId(token),
|
id: getDebridLinkApiKeyId(token),
|
||||||
token,
|
token,
|
||||||
index,
|
index,
|
||||||
label: getDebridLinkApiKeyLabel(index),
|
label: getDebridLinkApiKeyLabel(index),
|
||||||
masked: maskDebridLinkApiKey(token)
|
masked: maskDebridLinkApiKey(token)
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getDebridLinkApiKeyIds(raw: string): string[] {
|
export function getDebridLinkApiKeyIds(raw: string): string[] {
|
||||||
return parseDebridLinkApiKeys(raw).map((entry) => entry.id);
|
return parseDebridLinkApiKeys(raw).map((entry) => entry.id);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,77 +1,77 @@
|
|||||||
export const IPC_CHANNELS = {
|
export const IPC_CHANNELS = {
|
||||||
GET_SNAPSHOT: "app:get-snapshot",
|
GET_SNAPSHOT: "app:get-snapshot",
|
||||||
GET_VERSION: "app:get-version",
|
GET_VERSION: "app:get-version",
|
||||||
CHECK_UPDATES: "app:check-updates",
|
CHECK_UPDATES: "app:check-updates",
|
||||||
INSTALL_UPDATE: "app:install-update",
|
INSTALL_UPDATE: "app:install-update",
|
||||||
UPDATE_INSTALL_PROGRESS: "app:update-install-progress",
|
UPDATE_INSTALL_PROGRESS: "app:update-install-progress",
|
||||||
OPEN_EXTERNAL: "app:open-external",
|
OPEN_EXTERNAL: "app:open-external",
|
||||||
UPDATE_SETTINGS: "app:update-settings",
|
UPDATE_SETTINGS: "app:update-settings",
|
||||||
RESET_PROVIDER_DAILY_USAGE: "app:reset-provider-daily-usage",
|
RESET_PROVIDER_DAILY_USAGE: "app:reset-provider-daily-usage",
|
||||||
RESET_DEBRID_LINK_API_KEY_DAILY_USAGE: "app:reset-debrid-link-api-key-daily-usage",
|
RESET_DEBRID_LINK_API_KEY_DAILY_USAGE: "app:reset-debrid-link-api-key-daily-usage",
|
||||||
ADD_LINKS: "queue:add-links",
|
ADD_LINKS: "queue:add-links",
|
||||||
ADD_CONTAINERS: "queue:add-containers",
|
ADD_CONTAINERS: "queue:add-containers",
|
||||||
GET_START_CONFLICTS: "queue:get-start-conflicts",
|
GET_START_CONFLICTS: "queue:get-start-conflicts",
|
||||||
RESOLVE_START_CONFLICT: "queue:resolve-start-conflict",
|
RESOLVE_START_CONFLICT: "queue:resolve-start-conflict",
|
||||||
CLEAR_ALL: "queue:clear-all",
|
CLEAR_ALL: "queue:clear-all",
|
||||||
START: "queue:start",
|
START: "queue:start",
|
||||||
START_PACKAGES: "queue:start-packages",
|
START_PACKAGES: "queue:start-packages",
|
||||||
STOP: "queue:stop",
|
STOP: "queue:stop",
|
||||||
TOGGLE_PAUSE: "queue:toggle-pause",
|
TOGGLE_PAUSE: "queue:toggle-pause",
|
||||||
CANCEL_PACKAGE: "queue:cancel-package",
|
CANCEL_PACKAGE: "queue:cancel-package",
|
||||||
RENAME_PACKAGE: "queue:rename-package",
|
RENAME_PACKAGE: "queue:rename-package",
|
||||||
REORDER_PACKAGES: "queue:reorder-packages",
|
REORDER_PACKAGES: "queue:reorder-packages",
|
||||||
REMOVE_ITEM: "queue:remove-item",
|
REMOVE_ITEM: "queue:remove-item",
|
||||||
TOGGLE_PACKAGE: "queue:toggle-package",
|
TOGGLE_PACKAGE: "queue:toggle-package",
|
||||||
EXPORT_PACKAGE_SELECTION: "queue:export-package-selection",
|
EXPORT_PACKAGE_SELECTION: "queue:export-package-selection",
|
||||||
EXPORT_ITEM_SELECTION: "queue:export-item-selection",
|
EXPORT_ITEM_SELECTION: "queue:export-item-selection",
|
||||||
EXPORT_QUEUE: "queue:export",
|
EXPORT_QUEUE: "queue:export",
|
||||||
IMPORT_QUEUE: "queue:import",
|
IMPORT_QUEUE: "queue:import",
|
||||||
PICK_FOLDER: "dialog:pick-folder",
|
PICK_FOLDER: "dialog:pick-folder",
|
||||||
PICK_CONTAINERS: "dialog:pick-containers",
|
PICK_CONTAINERS: "dialog:pick-containers",
|
||||||
STATE_UPDATE: "state:update",
|
STATE_UPDATE: "state:update",
|
||||||
CLIPBOARD_DETECTED: "clipboard:detected",
|
CLIPBOARD_DETECTED: "clipboard:detected",
|
||||||
TOGGLE_CLIPBOARD: "clipboard:toggle",
|
TOGGLE_CLIPBOARD: "clipboard:toggle",
|
||||||
GET_SESSION_STATS: "stats:get-session-stats",
|
GET_SESSION_STATS: "stats:get-session-stats",
|
||||||
RESET_SESSION_STATS: "stats:reset-session",
|
RESET_SESSION_STATS: "stats:reset-session",
|
||||||
RESET_DOWNLOAD_STATS: "stats:reset-download",
|
RESET_DOWNLOAD_STATS: "stats:reset-download",
|
||||||
RESTART: "app:restart",
|
RESTART: "app:restart",
|
||||||
QUIT: "app:quit",
|
QUIT: "app:quit",
|
||||||
EXPORT_BACKUP: "app:export-backup",
|
EXPORT_BACKUP: "app:export-backup",
|
||||||
IMPORT_BACKUP: "app:import-backup",
|
IMPORT_BACKUP: "app:import-backup",
|
||||||
EXPORT_SUPPORT_BUNDLE: "app:export-support-bundle",
|
EXPORT_SUPPORT_BUNDLE: "app:export-support-bundle",
|
||||||
OPEN_LOG: "app:open-log",
|
OPEN_LOG: "app:open-log",
|
||||||
OPEN_AUDIT_LOG: "app:open-audit-log",
|
OPEN_AUDIT_LOG: "app:open-audit-log",
|
||||||
OPEN_RENAME_LOG: "app:open-rename-log",
|
OPEN_RENAME_LOG: "app:open-rename-log",
|
||||||
OPEN_SESSION_LOG: "app:open-session-log",
|
OPEN_SESSION_LOG: "app:open-session-log",
|
||||||
OPEN_TRACE_LOG: "app:open-trace-log",
|
OPEN_TRACE_LOG: "app:open-trace-log",
|
||||||
OPEN_PACKAGE_LOG: "app:open-package-log",
|
OPEN_PACKAGE_LOG: "app:open-package-log",
|
||||||
OPEN_ITEM_LOG: "app:open-item-log",
|
OPEN_ITEM_LOG: "app:open-item-log",
|
||||||
GET_DEBUG_SETUP_CHECK: "app:get-debug-setup-check",
|
GET_DEBUG_SETUP_CHECK: "app:get-debug-setup-check",
|
||||||
GET_RECENT_ERRORS: "app:get-recent-errors",
|
GET_RECENT_ERRORS: "app:get-recent-errors",
|
||||||
TEST_NOTIFY: "app:test-notify",
|
TEST_NOTIFY: "app:test-notify",
|
||||||
GET_TRACE_CONFIG: "app:get-trace-config",
|
GET_TRACE_CONFIG: "app:get-trace-config",
|
||||||
SET_TRACE_ENABLED: "app:set-trace-enabled",
|
SET_TRACE_ENABLED: "app:set-trace-enabled",
|
||||||
ROTATE_DEBUG_TOKEN: "app:rotate-debug-token",
|
ROTATE_DEBUG_TOKEN: "app:rotate-debug-token",
|
||||||
GET_REMOTE_DIAGNOSTICS: "app:get-remote-diagnostics",
|
GET_REMOTE_DIAGNOSTICS: "app:get-remote-diagnostics",
|
||||||
ENABLE_REMOTE_DIAGNOSTICS: "app:enable-remote-diagnostics",
|
ENABLE_REMOTE_DIAGNOSTICS: "app:enable-remote-diagnostics",
|
||||||
DISABLE_REMOTE_DIAGNOSTICS: "app:disable-remote-diagnostics",
|
DISABLE_REMOTE_DIAGNOSTICS: "app:disable-remote-diagnostics",
|
||||||
ROTATE_REMOTE_DIAGNOSTICS_TOKEN: "app:rotate-remote-diagnostics-token",
|
ROTATE_REMOTE_DIAGNOSTICS_TOKEN: "app:rotate-remote-diagnostics-token",
|
||||||
OPEN_REALDEBRID_LOGIN: "app:open-realdebrid-login",
|
OPEN_REALDEBRID_LOGIN: "app:open-realdebrid-login",
|
||||||
OPEN_ALLDEBRID_LOGIN: "app:open-alldebrid-login",
|
OPEN_ALLDEBRID_LOGIN: "app:open-alldebrid-login",
|
||||||
IMPORT_BESTDEBRID_COOKIES: "app:import-bestdebrid-cookies",
|
IMPORT_BESTDEBRID_COOKIES: "app:import-bestdebrid-cookies",
|
||||||
GET_ALLDEBRID_HOST_INFO: "app:get-alldebrid-host-info",
|
GET_ALLDEBRID_HOST_INFO: "app:get-alldebrid-host-info",
|
||||||
GET_DEBRIDLINK_HOST_LIMITS: "app:get-debridlink-host-limits",
|
GET_DEBRIDLINK_HOST_LIMITS: "app:get-debridlink-host-limits",
|
||||||
CHECK_DEBRID_ACCOUNTS: "app:check-debrid-accounts",
|
CHECK_DEBRID_ACCOUNTS: "app:check-debrid-accounts",
|
||||||
CHECK_MEGA_DEBRID_ACCOUNT: "app:check-mega-debrid-account",
|
CHECK_MEGA_DEBRID_ACCOUNT: "app:check-mega-debrid-account",
|
||||||
RETRY_EXTRACTION: "queue:retry-extraction",
|
RETRY_EXTRACTION: "queue:retry-extraction",
|
||||||
EXTRACT_NOW: "queue:extract-now",
|
EXTRACT_NOW: "queue:extract-now",
|
||||||
RESET_PACKAGE: "queue:reset-package",
|
RESET_PACKAGE: "queue:reset-package",
|
||||||
GET_HISTORY: "history:get",
|
GET_HISTORY: "history:get",
|
||||||
CLEAR_HISTORY: "history:clear",
|
CLEAR_HISTORY: "history:clear",
|
||||||
REMOVE_HISTORY_ENTRY: "history:remove-entry",
|
REMOVE_HISTORY_ENTRY: "history:remove-entry",
|
||||||
SET_PACKAGE_PRIORITY: "queue:set-package-priority",
|
SET_PACKAGE_PRIORITY: "queue:set-package-priority",
|
||||||
SKIP_ITEMS: "queue:skip-items",
|
SKIP_ITEMS: "queue:skip-items",
|
||||||
RESET_ITEMS: "queue:reset-items",
|
RESET_ITEMS: "queue:reset-items",
|
||||||
START_ITEMS: "queue:start-items",
|
START_ITEMS: "queue:start-items",
|
||||||
LOG_RENDERER_ERROR: "log:renderer-error"
|
LOG_RENDERER_ERROR: "log:renderer-error"
|
||||||
} as const;
|
} as const;
|
||||||
|
|||||||
@ -1,90 +1,90 @@
|
|||||||
export interface MegaDebridAccountEntry {
|
export interface MegaDebridAccountEntry {
|
||||||
id: string;
|
id: string;
|
||||||
login: string;
|
login: string;
|
||||||
password: string;
|
password: string;
|
||||||
index: number;
|
index: number;
|
||||||
label: string;
|
label: string;
|
||||||
maskedLogin: string;
|
maskedLogin: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const FNV64_OFFSET_BASIS = 0xcbf29ce484222325n;
|
const FNV64_OFFSET_BASIS = 0xcbf29ce484222325n;
|
||||||
const FNV64_PRIME = 0x100000001b3n;
|
const FNV64_PRIME = 0x100000001b3n;
|
||||||
const FNV64_MASK = 0xffffffffffffffffn;
|
const FNV64_MASK = 0xffffffffffffffffn;
|
||||||
|
|
||||||
function fnv1a64(text: string): string {
|
function fnv1a64(text: string): string {
|
||||||
let hash = FNV64_OFFSET_BASIS;
|
let hash = FNV64_OFFSET_BASIS;
|
||||||
for (const char of text) {
|
for (const char of text) {
|
||||||
hash ^= BigInt(char.codePointAt(0) || 0);
|
hash ^= BigInt(char.codePointAt(0) || 0);
|
||||||
hash = (hash * FNV64_PRIME) & FNV64_MASK;
|
hash = (hash * FNV64_PRIME) & FNV64_MASK;
|
||||||
}
|
}
|
||||||
return hash.toString(36);
|
return hash.toString(36);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getMegaDebridAccountId(login: string): string {
|
export function getMegaDebridAccountId(login: string): string {
|
||||||
return `mda_${fnv1a64(login.trim().toLowerCase())}`;
|
return `mda_${fnv1a64(login.trim().toLowerCase())}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function maskMegaDebridLogin(login: string): string {
|
export function maskMegaDebridLogin(login: string): string {
|
||||||
const trimmed = login.trim();
|
const trimmed = login.trim();
|
||||||
if (!trimmed) {
|
if (!trimmed) {
|
||||||
return "Nicht hinterlegt";
|
return "Nicht hinterlegt";
|
||||||
}
|
}
|
||||||
if (trimmed.length <= 4) {
|
if (trimmed.length <= 4) {
|
||||||
return `${trimmed[0]}${"*".repeat(trimmed.length - 1)}`;
|
return `${trimmed[0]}${"*".repeat(trimmed.length - 1)}`;
|
||||||
}
|
}
|
||||||
return `${trimmed.slice(0, 2)}${"*".repeat(Math.max(3, trimmed.length - 4))}${trimmed.slice(-2)}`;
|
return `${trimmed.slice(0, 2)}${"*".repeat(Math.max(3, trimmed.length - 4))}${trimmed.slice(-2)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getMegaDebridAccountLabel(index: number): string {
|
export function getMegaDebridAccountLabel(index: number): string {
|
||||||
return `Account ${index + 1}`;
|
return `Account ${index + 1}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function parseMegaDebridAccounts(raw: string, legacyPassword = ""): MegaDebridAccountEntry[] {
|
export function parseMegaDebridAccounts(raw: string, legacyPassword = ""): MegaDebridAccountEntry[] {
|
||||||
const seen = new Set<string>();
|
const seen = new Set<string>();
|
||||||
const lines = String(raw || "")
|
const lines = String(raw || "")
|
||||||
.split(/\n+/)
|
.split(/\n+/)
|
||||||
.map((line) => line.trim())
|
.map((line) => line.trim())
|
||||||
.filter(Boolean);
|
.filter(Boolean);
|
||||||
|
|
||||||
const entries: MegaDebridAccountEntry[] = [];
|
const entries: MegaDebridAccountEntry[] = [];
|
||||||
for (const line of lines) {
|
for (const line of lines) {
|
||||||
const colonIdx = line.indexOf(":");
|
const colonIdx = line.indexOf(":");
|
||||||
let login: string;
|
let login: string;
|
||||||
let password: string;
|
let password: string;
|
||||||
if (colonIdx >= 0) {
|
if (colonIdx >= 0) {
|
||||||
login = line.slice(0, colonIdx).trim();
|
login = line.slice(0, colonIdx).trim();
|
||||||
password = line.slice(colonIdx + 1).trim();
|
password = line.slice(colonIdx + 1).trim();
|
||||||
} else {
|
} else {
|
||||||
login = line;
|
login = line;
|
||||||
password = legacyPassword;
|
password = legacyPassword;
|
||||||
}
|
}
|
||||||
if (!login || !password) {
|
if (!login || !password) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const key = login.toLowerCase();
|
const key = login.toLowerCase();
|
||||||
if (seen.has(key)) {
|
if (seen.has(key)) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
seen.add(key);
|
seen.add(key);
|
||||||
entries.push({
|
entries.push({
|
||||||
id: getMegaDebridAccountId(login),
|
id: getMegaDebridAccountId(login),
|
||||||
login,
|
login,
|
||||||
password,
|
password,
|
||||||
index: entries.length,
|
index: entries.length,
|
||||||
label: getMegaDebridAccountLabel(entries.length),
|
label: getMegaDebridAccountLabel(entries.length),
|
||||||
maskedLogin: maskMegaDebridLogin(login)
|
maskedLogin: maskMegaDebridLogin(login)
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
return entries;
|
return entries;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function serializeMegaDebridAccounts(accounts: { login: string; password: string }[]): string {
|
export function serializeMegaDebridAccounts(accounts: { login: string; password: string }[]): string {
|
||||||
return accounts
|
return accounts
|
||||||
.filter((a) => a.login.trim() && a.password.trim())
|
.filter((a) => a.login.trim() && a.password.trim())
|
||||||
.map((a) => `${a.login.trim()}:${a.password.trim()}`)
|
.map((a) => `${a.login.trim()}:${a.password.trim()}`)
|
||||||
.join("\n");
|
.join("\n");
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getMegaDebridAccountIds(raw: string, legacyPassword = ""): string[] {
|
export function getMegaDebridAccountIds(raw: string, legacyPassword = ""): string[] {
|
||||||
return parseMegaDebridAccounts(raw, legacyPassword).map((entry) => entry.id);
|
return parseMegaDebridAccounts(raw, legacyPassword).map((entry) => entry.id);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,24 +1,24 @@
|
|||||||
export function isMegaDebridResolveFailure(errorText: string): boolean {
|
export function isMegaDebridResolveFailure(errorText: string): boolean {
|
||||||
const text = String(errorText || "").toLowerCase();
|
const text = String(errorText || "").toLowerCase();
|
||||||
return /supprim/.test(text)
|
return /supprim/.test(text)
|
||||||
|| text.includes("introuvable")
|
|| text.includes("introuvable")
|
||||||
|| text.includes("n'existe plus")
|
|| text.includes("n'existe plus")
|
||||||
|| text.includes("n existe plus")
|
|| text.includes("n existe plus")
|
||||||
|| text.includes("fichier inexistant");
|
|| text.includes("fichier inexistant");
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isMegaDebridTransientResolveFailure(errorText: string): boolean {
|
export function isMegaDebridTransientResolveFailure(errorText: string): boolean {
|
||||||
const text = String(errorText || "").toLowerCase();
|
const text = String(errorText || "").toLowerCase();
|
||||||
return isMegaDebridResolveFailure(text)
|
return isMegaDebridResolveFailure(text)
|
||||||
|| text.includes("datei beim hoster gerade nicht abrufbar")
|
|| text.includes("datei beim hoster gerade nicht abrufbar")
|
||||||
|| text.includes("datei beim hoster nicht gefunden");
|
|| text.includes("datei beim hoster nicht gefunden");
|
||||||
}
|
}
|
||||||
|
|
||||||
export function germanMegaDebridResolveReason(errorText: string): string {
|
export function germanMegaDebridResolveReason(errorText: string): string {
|
||||||
const text = String(errorText || "").toLowerCase();
|
const text = String(errorText || "").toLowerCase();
|
||||||
if (text.includes("datei beim hoster nicht gefunden")
|
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")) {
|
|| 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 nicht gefunden";
|
||||||
}
|
}
|
||||||
return "Datei beim Hoster gerade nicht abrufbar";
|
return "Datei beim Hoster gerade nicht abrufbar";
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,101 +1,101 @@
|
|||||||
import type {
|
import type {
|
||||||
AddLinksPayload,
|
AddLinksPayload,
|
||||||
AllDebridHostInfo,
|
AllDebridHostInfo,
|
||||||
AppSettings,
|
AppSettings,
|
||||||
DebridAccountStatus,
|
DebridAccountStatus,
|
||||||
DebugSetupCheckResult,
|
DebugSetupCheckResult,
|
||||||
DebridLinkHostLimitInfo,
|
DebridLinkHostLimitInfo,
|
||||||
DebridProvider,
|
DebridProvider,
|
||||||
DuplicatePolicy,
|
DuplicatePolicy,
|
||||||
EnableRemoteDiagnosticsInput,
|
EnableRemoteDiagnosticsInput,
|
||||||
HistoryEntry,
|
HistoryEntry,
|
||||||
PackagePriority,
|
PackagePriority,
|
||||||
RemoteDiagnosticsInfo,
|
RemoteDiagnosticsInfo,
|
||||||
RendererErrorReport,
|
RendererErrorReport,
|
||||||
SessionStats,
|
SessionStats,
|
||||||
StartConflictEntry,
|
StartConflictEntry,
|
||||||
StartConflictResolutionResult,
|
StartConflictResolutionResult,
|
||||||
SupportTraceConfig,
|
SupportTraceConfig,
|
||||||
UiSnapshot,
|
UiSnapshot,
|
||||||
UpdateCheckResult,
|
UpdateCheckResult,
|
||||||
UpdateInstallProgress,
|
UpdateInstallProgress,
|
||||||
UpdateInstallResult
|
UpdateInstallResult
|
||||||
} from "./types";
|
} from "./types";
|
||||||
|
|
||||||
export interface ElectronApi {
|
export interface ElectronApi {
|
||||||
getSnapshot: () => Promise<UiSnapshot>;
|
getSnapshot: () => Promise<UiSnapshot>;
|
||||||
getVersion: () => Promise<string>;
|
getVersion: () => Promise<string>;
|
||||||
checkUpdates: () => Promise<UpdateCheckResult>;
|
checkUpdates: () => Promise<UpdateCheckResult>;
|
||||||
installUpdate: () => Promise<UpdateInstallResult>;
|
installUpdate: () => Promise<UpdateInstallResult>;
|
||||||
openExternal: (url: string) => Promise<boolean>;
|
openExternal: (url: string) => Promise<boolean>;
|
||||||
updateSettings: (settings: Partial<AppSettings>) => Promise<AppSettings>;
|
updateSettings: (settings: Partial<AppSettings>) => Promise<AppSettings>;
|
||||||
resetProviderDailyUsage: (provider: DebridProvider) => Promise<AppSettings>;
|
resetProviderDailyUsage: (provider: DebridProvider) => Promise<AppSettings>;
|
||||||
resetDebridLinkApiKeyDailyUsage: (keyId: string) => Promise<AppSettings>;
|
resetDebridLinkApiKeyDailyUsage: (keyId: string) => Promise<AppSettings>;
|
||||||
addLinks: (payload: AddLinksPayload) => Promise<{ addedPackages: number; addedLinks: number; invalidCount: number }>;
|
addLinks: (payload: AddLinksPayload) => Promise<{ addedPackages: number; addedLinks: number; invalidCount: number }>;
|
||||||
addContainers: (filePaths: string[]) => Promise<{ addedPackages: number; addedLinks: number }>;
|
addContainers: (filePaths: string[]) => Promise<{ addedPackages: number; addedLinks: number }>;
|
||||||
getStartConflicts: () => Promise<StartConflictEntry[]>;
|
getStartConflicts: () => Promise<StartConflictEntry[]>;
|
||||||
resolveStartConflict: (packageId: string, policy: DuplicatePolicy) => Promise<StartConflictResolutionResult>;
|
resolveStartConflict: (packageId: string, policy: DuplicatePolicy) => Promise<StartConflictResolutionResult>;
|
||||||
clearAll: () => Promise<void>;
|
clearAll: () => Promise<void>;
|
||||||
start: () => Promise<void>;
|
start: () => Promise<void>;
|
||||||
startPackages: (packageIds: string[]) => Promise<void>;
|
startPackages: (packageIds: string[]) => Promise<void>;
|
||||||
stop: () => Promise<void>;
|
stop: () => Promise<void>;
|
||||||
togglePause: () => Promise<boolean>;
|
togglePause: () => Promise<boolean>;
|
||||||
cancelPackage: (packageId: string) => Promise<void>;
|
cancelPackage: (packageId: string) => Promise<void>;
|
||||||
renamePackage: (packageId: string, newName: string) => Promise<void>;
|
renamePackage: (packageId: string, newName: string) => Promise<void>;
|
||||||
reorderPackages: (packageIds: string[]) => Promise<void>;
|
reorderPackages: (packageIds: string[]) => Promise<void>;
|
||||||
removeItem: (itemId: string) => Promise<void>;
|
removeItem: (itemId: string) => Promise<void>;
|
||||||
togglePackage: (packageId: string) => Promise<void>;
|
togglePackage: (packageId: string) => Promise<void>;
|
||||||
exportPackageSelection: (packageIds: string[]) => Promise<{ saved: boolean; packageCount: number; linkCount: number; filePath?: string }>;
|
exportPackageSelection: (packageIds: string[]) => Promise<{ saved: boolean; packageCount: number; linkCount: number; filePath?: string }>;
|
||||||
exportItemSelection: (itemIds: 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 }>;
|
exportQueue: () => Promise<{ saved: boolean }>;
|
||||||
importQueue: (json: string) => Promise<{ addedPackages: number; addedLinks: number }>;
|
importQueue: (json: string) => Promise<{ addedPackages: number; addedLinks: number }>;
|
||||||
toggleClipboard: () => Promise<boolean>;
|
toggleClipboard: () => Promise<boolean>;
|
||||||
pickFolder: () => Promise<string | null>;
|
pickFolder: () => Promise<string | null>;
|
||||||
pickContainers: () => Promise<string[]>;
|
pickContainers: () => Promise<string[]>;
|
||||||
getSessionStats: () => Promise<SessionStats>;
|
getSessionStats: () => Promise<SessionStats>;
|
||||||
resetSessionStats: () => Promise<void>;
|
resetSessionStats: () => Promise<void>;
|
||||||
resetDownloadStats: () => Promise<void>;
|
resetDownloadStats: () => Promise<void>;
|
||||||
restart: () => Promise<void>;
|
restart: () => Promise<void>;
|
||||||
quit: () => Promise<void>;
|
quit: () => Promise<void>;
|
||||||
exportBackup: () => Promise<{ saved: boolean }>;
|
exportBackup: () => Promise<{ saved: boolean }>;
|
||||||
importBackup: () => Promise<{ restored: boolean; relaunch: boolean; message: string }>;
|
importBackup: () => Promise<{ restored: boolean; relaunch: boolean; message: string }>;
|
||||||
exportSupportBundle: () => Promise<{ saved: boolean; filePath?: string }>;
|
exportSupportBundle: () => Promise<{ saved: boolean; filePath?: string }>;
|
||||||
openLog: () => Promise<void>;
|
openLog: () => Promise<void>;
|
||||||
openAuditLog: () => Promise<void>;
|
openAuditLog: () => Promise<void>;
|
||||||
openRenameLog: () => Promise<void>;
|
openRenameLog: () => Promise<void>;
|
||||||
openSessionLog: () => Promise<void>;
|
openSessionLog: () => Promise<void>;
|
||||||
openTraceLog: () => Promise<void>;
|
openTraceLog: () => Promise<void>;
|
||||||
openPackageLog: (packageId: string) => Promise<void>;
|
openPackageLog: (packageId: string) => Promise<void>;
|
||||||
openItemLog: (itemId: string) => Promise<void>;
|
openItemLog: (itemId: string) => Promise<void>;
|
||||||
getDebugSetupCheck: () => Promise<DebugSetupCheckResult>;
|
getDebugSetupCheck: () => Promise<DebugSetupCheckResult>;
|
||||||
getRecentErrors: () => Promise<Array<{ ts: string; level: string; message: string }>>;
|
getRecentErrors: () => Promise<Array<{ ts: string; level: string; message: string }>>;
|
||||||
testNotification: (url: string, mention: string) => Promise<boolean>;
|
testNotification: (url: string, mention: string) => Promise<boolean>;
|
||||||
getTraceConfig: () => Promise<SupportTraceConfig>;
|
getTraceConfig: () => Promise<SupportTraceConfig>;
|
||||||
setTraceEnabled: (enabled: boolean, note?: string, durationMinutes?: number) => Promise<SupportTraceConfig>;
|
setTraceEnabled: (enabled: boolean, note?: string, durationMinutes?: number) => Promise<SupportTraceConfig>;
|
||||||
rotateDebugToken: () => Promise<{ path: string }>;
|
rotateDebugToken: () => Promise<{ path: string }>;
|
||||||
getRemoteDiagnostics: () => Promise<RemoteDiagnosticsInfo>;
|
getRemoteDiagnostics: () => Promise<RemoteDiagnosticsInfo>;
|
||||||
enableRemoteDiagnostics: (input: EnableRemoteDiagnosticsInput) => Promise<RemoteDiagnosticsInfo>;
|
enableRemoteDiagnostics: (input: EnableRemoteDiagnosticsInput) => Promise<RemoteDiagnosticsInfo>;
|
||||||
disableRemoteDiagnostics: () => Promise<RemoteDiagnosticsInfo>;
|
disableRemoteDiagnostics: () => Promise<RemoteDiagnosticsInfo>;
|
||||||
rotateRemoteDiagnosticsToken: () => Promise<RemoteDiagnosticsInfo>;
|
rotateRemoteDiagnosticsToken: () => Promise<RemoteDiagnosticsInfo>;
|
||||||
openRealDebridLogin: () => Promise<void>;
|
openRealDebridLogin: () => Promise<void>;
|
||||||
openAllDebridLogin: () => Promise<void>;
|
openAllDebridLogin: () => Promise<void>;
|
||||||
importBestDebridCookies: () => Promise<number>;
|
importBestDebridCookies: () => Promise<number>;
|
||||||
getAllDebridHostInfo: () => Promise<AllDebridHostInfo>;
|
getAllDebridHostInfo: () => Promise<AllDebridHostInfo>;
|
||||||
getDebridLinkHostLimits: () => Promise<DebridLinkHostLimitInfo[]>;
|
getDebridLinkHostLimits: () => Promise<DebridLinkHostLimitInfo[]>;
|
||||||
checkDebridAccounts: () => Promise<DebridAccountStatus[]>;
|
checkDebridAccounts: () => Promise<DebridAccountStatus[]>;
|
||||||
checkMegaDebridAccount: (login: string, password: string) => Promise<DebridAccountStatus | null>;
|
checkMegaDebridAccount: (login: string, password: string) => Promise<DebridAccountStatus | null>;
|
||||||
retryExtraction: (packageId: string) => Promise<void>;
|
retryExtraction: (packageId: string) => Promise<void>;
|
||||||
extractNow: (packageId: string) => Promise<void>;
|
extractNow: (packageId: string) => Promise<void>;
|
||||||
resetPackage: (packageId: string) => Promise<void>;
|
resetPackage: (packageId: string) => Promise<void>;
|
||||||
getHistory: () => Promise<HistoryEntry[]>;
|
getHistory: () => Promise<HistoryEntry[]>;
|
||||||
clearHistory: () => Promise<void>;
|
clearHistory: () => Promise<void>;
|
||||||
removeHistoryEntry: (entryId: string) => Promise<void>;
|
removeHistoryEntry: (entryId: string) => Promise<void>;
|
||||||
setPackagePriority: (packageId: string, priority: PackagePriority) => Promise<void>;
|
setPackagePriority: (packageId: string, priority: PackagePriority) => Promise<void>;
|
||||||
skipItems: (itemIds: string[]) => Promise<void>;
|
skipItems: (itemIds: string[]) => Promise<void>;
|
||||||
resetItems: (itemIds: string[]) => Promise<void>;
|
resetItems: (itemIds: string[]) => Promise<void>;
|
||||||
startItems: (itemIds: string[]) => Promise<void>;
|
startItems: (itemIds: string[]) => Promise<void>;
|
||||||
reportRendererError: (report: RendererErrorReport) => void;
|
reportRendererError: (report: RendererErrorReport) => void;
|
||||||
onStateUpdate: (callback: (snapshot: UiSnapshot) => void) => () => void;
|
onStateUpdate: (callback: (snapshot: UiSnapshot) => void) => () => void;
|
||||||
onClipboardDetected: (callback: (links: string[]) => void) => () => void;
|
onClipboardDetected: (callback: (links: string[]) => void) => () => void;
|
||||||
onUpdateInstallProgress: (callback: (progress: UpdateInstallProgress) => void) => () => void;
|
onUpdateInstallProgress: (callback: (progress: UpdateInstallProgress) => void) => () => void;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,329 +1,329 @@
|
|||||||
import type { AppSettings, DebridProvider } from "./types";
|
import type { AppSettings, DebridProvider } from "./types";
|
||||||
|
|
||||||
export type ProviderByteMap = Partial<Record<DebridProvider, number>>;
|
export type ProviderByteMap = Partial<Record<DebridProvider, number>>;
|
||||||
export type DebridLinkKeyByteMap = Record<string, number>;
|
export type DebridLinkKeyByteMap = Record<string, number>;
|
||||||
|
|
||||||
type ProviderDailySettings =
|
type ProviderDailySettings =
|
||||||
Pick<AppSettings, "providerDailyLimitBytes" | "providerDailyUsageBytes" | "providerDailyUsageDay">
|
Pick<AppSettings, "providerDailyLimitBytes" | "providerDailyUsageBytes" | "providerDailyUsageDay">
|
||||||
& Partial<Pick<AppSettings, "debridLinkApiKeyDailyLimitBytes" | "debridLinkApiKeyDailyUsageBytes">>
|
& Partial<Pick<AppSettings, "debridLinkApiKeyDailyLimitBytes" | "debridLinkApiKeyDailyUsageBytes">>
|
||||||
& Partial<Pick<AppSettings, "megaDebridDisabledAccountIds" | "megaDebridAccountDailyLimitBytes" | "megaDebridAccountDailyUsageBytes">>;
|
& Partial<Pick<AppSettings, "megaDebridDisabledAccountIds" | "megaDebridAccountDailyLimitBytes" | "megaDebridAccountDailyUsageBytes">>;
|
||||||
|
|
||||||
type ProviderUsageSettings =
|
type ProviderUsageSettings =
|
||||||
ProviderDailySettings
|
ProviderDailySettings
|
||||||
& Partial<Pick<AppSettings, "providerTotalUsageBytes" | "debridLinkApiKeyTotalUsageBytes">>
|
& Partial<Pick<AppSettings, "providerTotalUsageBytes" | "debridLinkApiKeyTotalUsageBytes">>
|
||||||
& Partial<Pick<AppSettings, "megaDebridAccountTotalUsageBytes">>;
|
& Partial<Pick<AppSettings, "megaDebridAccountTotalUsageBytes">>;
|
||||||
|
|
||||||
function normalizePositiveBytes(value: unknown): number {
|
function normalizePositiveBytes(value: unknown): number {
|
||||||
const numeric = Number(value);
|
const numeric = Number(value);
|
||||||
if (!Number.isFinite(numeric) || numeric <= 0) {
|
if (!Number.isFinite(numeric) || numeric <= 0) {
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
return Math.floor(numeric);
|
return Math.floor(numeric);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getProviderUsageDayKey(epochMs = Date.now()): string {
|
export function getProviderUsageDayKey(epochMs = Date.now()): string {
|
||||||
const current = new Date(epochMs);
|
const current = new Date(epochMs);
|
||||||
const year = current.getFullYear();
|
const year = current.getFullYear();
|
||||||
const month = String(current.getMonth() + 1).padStart(2, "0");
|
const month = String(current.getMonth() + 1).padStart(2, "0");
|
||||||
const day = String(current.getDate()).padStart(2, "0");
|
const day = String(current.getDate()).padStart(2, "0");
|
||||||
return `${year}-${month}-${day}`;
|
return `${year}-${month}-${day}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getProviderDailyLimitBytes(settings: ProviderDailySettings, provider: DebridProvider): number {
|
export function getProviderDailyLimitBytes(settings: ProviderDailySettings, provider: DebridProvider): number {
|
||||||
return normalizePositiveBytes(settings.providerDailyLimitBytes?.[provider]);
|
return normalizePositiveBytes(settings.providerDailyLimitBytes?.[provider]);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getProviderDailyUsageBytes(
|
export function getProviderDailyUsageBytes(
|
||||||
settings: ProviderDailySettings,
|
settings: ProviderDailySettings,
|
||||||
provider: DebridProvider,
|
provider: DebridProvider,
|
||||||
epochMs = Date.now()
|
epochMs = Date.now()
|
||||||
): number {
|
): number {
|
||||||
if (settings.providerDailyUsageDay !== getProviderUsageDayKey(epochMs)) {
|
if (settings.providerDailyUsageDay !== getProviderUsageDayKey(epochMs)) {
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
return normalizePositiveBytes(settings.providerDailyUsageBytes?.[provider]);
|
return normalizePositiveBytes(settings.providerDailyUsageBytes?.[provider]);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getProviderDailyRemainingBytes(
|
export function getProviderDailyRemainingBytes(
|
||||||
settings: ProviderDailySettings,
|
settings: ProviderDailySettings,
|
||||||
provider: DebridProvider,
|
provider: DebridProvider,
|
||||||
epochMs = Date.now()
|
epochMs = Date.now()
|
||||||
): number | null {
|
): number | null {
|
||||||
const limit = getProviderDailyLimitBytes(settings, provider);
|
const limit = getProviderDailyLimitBytes(settings, provider);
|
||||||
if (limit <= 0) {
|
if (limit <= 0) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
return Math.max(0, limit - getProviderDailyUsageBytes(settings, provider, epochMs));
|
return Math.max(0, limit - getProviderDailyUsageBytes(settings, provider, epochMs));
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isProviderDailyLimitReached(
|
export function isProviderDailyLimitReached(
|
||||||
settings: ProviderDailySettings,
|
settings: ProviderDailySettings,
|
||||||
provider: DebridProvider,
|
provider: DebridProvider,
|
||||||
epochMs = Date.now()
|
epochMs = Date.now()
|
||||||
): boolean {
|
): boolean {
|
||||||
const limit = getProviderDailyLimitBytes(settings, provider);
|
const limit = getProviderDailyLimitBytes(settings, provider);
|
||||||
return limit > 0 && getProviderDailyUsageBytes(settings, provider, epochMs) >= limit;
|
return limit > 0 && getProviderDailyUsageBytes(settings, provider, epochMs) >= limit;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getProviderTotalUsageBytes(settings: ProviderUsageSettings, provider: DebridProvider): number {
|
export function getProviderTotalUsageBytes(settings: ProviderUsageSettings, provider: DebridProvider): number {
|
||||||
return normalizePositiveBytes(settings.providerTotalUsageBytes?.[provider]);
|
return normalizePositiveBytes(settings.providerTotalUsageBytes?.[provider]);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function resetProviderDailyUsage(
|
export function resetProviderDailyUsage(
|
||||||
settings: ProviderDailySettings,
|
settings: ProviderDailySettings,
|
||||||
provider?: DebridProvider,
|
provider?: DebridProvider,
|
||||||
epochMs = Date.now()
|
epochMs = Date.now()
|
||||||
): Pick<AppSettings, "providerDailyUsageDay" | "providerDailyUsageBytes"> {
|
): Pick<AppSettings, "providerDailyUsageDay" | "providerDailyUsageBytes"> {
|
||||||
const dayKey = getProviderUsageDayKey(epochMs);
|
const dayKey = getProviderUsageDayKey(epochMs);
|
||||||
if (!provider) {
|
if (!provider) {
|
||||||
return {
|
return {
|
||||||
providerDailyUsageDay: dayKey,
|
providerDailyUsageDay: dayKey,
|
||||||
providerDailyUsageBytes: {}
|
providerDailyUsageBytes: {}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const nextUsageBytes = settings.providerDailyUsageDay === dayKey
|
const nextUsageBytes = settings.providerDailyUsageDay === dayKey
|
||||||
? { ...(settings.providerDailyUsageBytes || {}) }
|
? { ...(settings.providerDailyUsageBytes || {}) }
|
||||||
: {};
|
: {};
|
||||||
delete nextUsageBytes[provider];
|
delete nextUsageBytes[provider];
|
||||||
|
|
||||||
return {
|
return {
|
||||||
providerDailyUsageDay: dayKey,
|
providerDailyUsageDay: dayKey,
|
||||||
providerDailyUsageBytes: nextUsageBytes
|
providerDailyUsageBytes: nextUsageBytes
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function addProviderDailyUsageBytes(
|
export function addProviderDailyUsageBytes(
|
||||||
settings: ProviderDailySettings,
|
settings: ProviderDailySettings,
|
||||||
provider: DebridProvider,
|
provider: DebridProvider,
|
||||||
byteDelta: number,
|
byteDelta: number,
|
||||||
epochMs = Date.now()
|
epochMs = Date.now()
|
||||||
): Pick<AppSettings, "providerDailyUsageDay" | "providerDailyUsageBytes"> {
|
): Pick<AppSettings, "providerDailyUsageDay" | "providerDailyUsageBytes"> {
|
||||||
const increment = normalizePositiveBytes(byteDelta);
|
const increment = normalizePositiveBytes(byteDelta);
|
||||||
const dayKey = getProviderUsageDayKey(epochMs);
|
const dayKey = getProviderUsageDayKey(epochMs);
|
||||||
const currentUsageBytes = settings.providerDailyUsageDay === dayKey
|
const currentUsageBytes = settings.providerDailyUsageDay === dayKey
|
||||||
? { ...(settings.providerDailyUsageBytes || {}) }
|
? { ...(settings.providerDailyUsageBytes || {}) }
|
||||||
: {};
|
: {};
|
||||||
if (increment <= 0) {
|
if (increment <= 0) {
|
||||||
return {
|
return {
|
||||||
providerDailyUsageDay: dayKey,
|
providerDailyUsageDay: dayKey,
|
||||||
providerDailyUsageBytes: currentUsageBytes
|
providerDailyUsageBytes: currentUsageBytes
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const nextUsageBytes = currentUsageBytes;
|
const nextUsageBytes = currentUsageBytes;
|
||||||
nextUsageBytes[provider] = normalizePositiveBytes(nextUsageBytes[provider]) + increment;
|
nextUsageBytes[provider] = normalizePositiveBytes(nextUsageBytes[provider]) + increment;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
providerDailyUsageDay: dayKey,
|
providerDailyUsageDay: dayKey,
|
||||||
providerDailyUsageBytes: nextUsageBytes
|
providerDailyUsageBytes: nextUsageBytes
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function addProviderTotalUsageBytes(
|
export function addProviderTotalUsageBytes(
|
||||||
settings: ProviderUsageSettings,
|
settings: ProviderUsageSettings,
|
||||||
provider: DebridProvider,
|
provider: DebridProvider,
|
||||||
byteDelta: number
|
byteDelta: number
|
||||||
): Pick<AppSettings, "providerTotalUsageBytes"> {
|
): Pick<AppSettings, "providerTotalUsageBytes"> {
|
||||||
const increment = normalizePositiveBytes(byteDelta);
|
const increment = normalizePositiveBytes(byteDelta);
|
||||||
const currentUsageBytes = { ...(settings.providerTotalUsageBytes || {}) };
|
const currentUsageBytes = { ...(settings.providerTotalUsageBytes || {}) };
|
||||||
if (increment <= 0) {
|
if (increment <= 0) {
|
||||||
return {
|
return {
|
||||||
providerTotalUsageBytes: currentUsageBytes
|
providerTotalUsageBytes: currentUsageBytes
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
currentUsageBytes[provider] = normalizePositiveBytes(currentUsageBytes[provider]) + increment;
|
currentUsageBytes[provider] = normalizePositiveBytes(currentUsageBytes[provider]) + increment;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
providerTotalUsageBytes: currentUsageBytes
|
providerTotalUsageBytes: currentUsageBytes
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getDebridLinkApiKeyDailyLimitBytes(settings: ProviderDailySettings, keyId: string): number {
|
export function getDebridLinkApiKeyDailyLimitBytes(settings: ProviderDailySettings, keyId: string): number {
|
||||||
return normalizePositiveBytes(settings.debridLinkApiKeyDailyLimitBytes?.[keyId]);
|
return normalizePositiveBytes(settings.debridLinkApiKeyDailyLimitBytes?.[keyId]);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getDebridLinkApiKeyDailyUsageBytes(
|
export function getDebridLinkApiKeyDailyUsageBytes(
|
||||||
settings: ProviderDailySettings,
|
settings: ProviderDailySettings,
|
||||||
keyId: string,
|
keyId: string,
|
||||||
epochMs = Date.now()
|
epochMs = Date.now()
|
||||||
): number {
|
): number {
|
||||||
if (settings.providerDailyUsageDay !== getProviderUsageDayKey(epochMs)) {
|
if (settings.providerDailyUsageDay !== getProviderUsageDayKey(epochMs)) {
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
return normalizePositiveBytes(settings.debridLinkApiKeyDailyUsageBytes?.[keyId]);
|
return normalizePositiveBytes(settings.debridLinkApiKeyDailyUsageBytes?.[keyId]);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getDebridLinkApiKeyDailyRemainingBytes(
|
export function getDebridLinkApiKeyDailyRemainingBytes(
|
||||||
settings: ProviderDailySettings,
|
settings: ProviderDailySettings,
|
||||||
keyId: string,
|
keyId: string,
|
||||||
epochMs = Date.now()
|
epochMs = Date.now()
|
||||||
): number | null {
|
): number | null {
|
||||||
const limit = getDebridLinkApiKeyDailyLimitBytes(settings, keyId);
|
const limit = getDebridLinkApiKeyDailyLimitBytes(settings, keyId);
|
||||||
if (limit <= 0) {
|
if (limit <= 0) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
return Math.max(0, limit - getDebridLinkApiKeyDailyUsageBytes(settings, keyId, epochMs));
|
return Math.max(0, limit - getDebridLinkApiKeyDailyUsageBytes(settings, keyId, epochMs));
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isDebridLinkApiKeyDailyLimitReached(
|
export function isDebridLinkApiKeyDailyLimitReached(
|
||||||
settings: ProviderDailySettings,
|
settings: ProviderDailySettings,
|
||||||
keyId: string,
|
keyId: string,
|
||||||
epochMs = Date.now()
|
epochMs = Date.now()
|
||||||
): boolean {
|
): boolean {
|
||||||
const limit = getDebridLinkApiKeyDailyLimitBytes(settings, keyId);
|
const limit = getDebridLinkApiKeyDailyLimitBytes(settings, keyId);
|
||||||
return limit > 0 && getDebridLinkApiKeyDailyUsageBytes(settings, keyId, epochMs) >= limit;
|
return limit > 0 && getDebridLinkApiKeyDailyUsageBytes(settings, keyId, epochMs) >= limit;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getDebridLinkApiKeyTotalUsageBytes(settings: ProviderUsageSettings, keyId: string): number {
|
export function getDebridLinkApiKeyTotalUsageBytes(settings: ProviderUsageSettings, keyId: string): number {
|
||||||
return normalizePositiveBytes(settings.debridLinkApiKeyTotalUsageBytes?.[keyId]);
|
return normalizePositiveBytes(settings.debridLinkApiKeyTotalUsageBytes?.[keyId]);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function resetDebridLinkApiKeyDailyUsage(
|
export function resetDebridLinkApiKeyDailyUsage(
|
||||||
settings: ProviderDailySettings,
|
settings: ProviderDailySettings,
|
||||||
keyId?: string,
|
keyId?: string,
|
||||||
epochMs = Date.now()
|
epochMs = Date.now()
|
||||||
): Pick<AppSettings, "providerDailyUsageDay" | "debridLinkApiKeyDailyUsageBytes"> {
|
): Pick<AppSettings, "providerDailyUsageDay" | "debridLinkApiKeyDailyUsageBytes"> {
|
||||||
const dayKey = getProviderUsageDayKey(epochMs);
|
const dayKey = getProviderUsageDayKey(epochMs);
|
||||||
if (!keyId) {
|
if (!keyId) {
|
||||||
return {
|
return {
|
||||||
providerDailyUsageDay: dayKey,
|
providerDailyUsageDay: dayKey,
|
||||||
debridLinkApiKeyDailyUsageBytes: {}
|
debridLinkApiKeyDailyUsageBytes: {}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const nextUsageBytes = settings.providerDailyUsageDay === dayKey
|
const nextUsageBytes = settings.providerDailyUsageDay === dayKey
|
||||||
? { ...(settings.debridLinkApiKeyDailyUsageBytes || {}) }
|
? { ...(settings.debridLinkApiKeyDailyUsageBytes || {}) }
|
||||||
: {};
|
: {};
|
||||||
delete nextUsageBytes[keyId];
|
delete nextUsageBytes[keyId];
|
||||||
|
|
||||||
return {
|
return {
|
||||||
providerDailyUsageDay: dayKey,
|
providerDailyUsageDay: dayKey,
|
||||||
debridLinkApiKeyDailyUsageBytes: nextUsageBytes
|
debridLinkApiKeyDailyUsageBytes: nextUsageBytes
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function addDebridLinkApiKeyDailyUsageBytes(
|
export function addDebridLinkApiKeyDailyUsageBytes(
|
||||||
settings: ProviderDailySettings,
|
settings: ProviderDailySettings,
|
||||||
keyId: string,
|
keyId: string,
|
||||||
byteDelta: number,
|
byteDelta: number,
|
||||||
epochMs = Date.now()
|
epochMs = Date.now()
|
||||||
): Pick<AppSettings, "providerDailyUsageDay" | "debridLinkApiKeyDailyUsageBytes"> {
|
): Pick<AppSettings, "providerDailyUsageDay" | "debridLinkApiKeyDailyUsageBytes"> {
|
||||||
const increment = normalizePositiveBytes(byteDelta);
|
const increment = normalizePositiveBytes(byteDelta);
|
||||||
const dayKey = getProviderUsageDayKey(epochMs);
|
const dayKey = getProviderUsageDayKey(epochMs);
|
||||||
const currentUsageBytes = settings.providerDailyUsageDay === dayKey
|
const currentUsageBytes = settings.providerDailyUsageDay === dayKey
|
||||||
? { ...(settings.debridLinkApiKeyDailyUsageBytes || {}) }
|
? { ...(settings.debridLinkApiKeyDailyUsageBytes || {}) }
|
||||||
: {};
|
: {};
|
||||||
if (increment <= 0) {
|
if (increment <= 0) {
|
||||||
return {
|
return {
|
||||||
providerDailyUsageDay: dayKey,
|
providerDailyUsageDay: dayKey,
|
||||||
debridLinkApiKeyDailyUsageBytes: currentUsageBytes
|
debridLinkApiKeyDailyUsageBytes: currentUsageBytes
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
currentUsageBytes[keyId] = normalizePositiveBytes(currentUsageBytes[keyId]) + increment;
|
currentUsageBytes[keyId] = normalizePositiveBytes(currentUsageBytes[keyId]) + increment;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
providerDailyUsageDay: dayKey,
|
providerDailyUsageDay: dayKey,
|
||||||
debridLinkApiKeyDailyUsageBytes: currentUsageBytes
|
debridLinkApiKeyDailyUsageBytes: currentUsageBytes
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function addDebridLinkApiKeyTotalUsageBytes(
|
export function addDebridLinkApiKeyTotalUsageBytes(
|
||||||
settings: ProviderUsageSettings,
|
settings: ProviderUsageSettings,
|
||||||
keyId: string,
|
keyId: string,
|
||||||
byteDelta: number
|
byteDelta: number
|
||||||
): Pick<AppSettings, "debridLinkApiKeyTotalUsageBytes"> {
|
): Pick<AppSettings, "debridLinkApiKeyTotalUsageBytes"> {
|
||||||
const increment = normalizePositiveBytes(byteDelta);
|
const increment = normalizePositiveBytes(byteDelta);
|
||||||
const currentUsageBytes = { ...(settings.debridLinkApiKeyTotalUsageBytes || {}) };
|
const currentUsageBytes = { ...(settings.debridLinkApiKeyTotalUsageBytes || {}) };
|
||||||
if (increment <= 0) {
|
if (increment <= 0) {
|
||||||
return {
|
return {
|
||||||
debridLinkApiKeyTotalUsageBytes: currentUsageBytes
|
debridLinkApiKeyTotalUsageBytes: currentUsageBytes
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
currentUsageBytes[keyId] = normalizePositiveBytes(currentUsageBytes[keyId]) + increment;
|
currentUsageBytes[keyId] = normalizePositiveBytes(currentUsageBytes[keyId]) + increment;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
debridLinkApiKeyTotalUsageBytes: currentUsageBytes
|
debridLinkApiKeyTotalUsageBytes: currentUsageBytes
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isMegaDebridAccountDisabled(settings: ProviderDailySettings, accountId: string): boolean {
|
export function isMegaDebridAccountDisabled(settings: ProviderDailySettings, accountId: string): boolean {
|
||||||
return Array.isArray(settings.megaDebridDisabledAccountIds) && settings.megaDebridDisabledAccountIds.includes(accountId);
|
return Array.isArray(settings.megaDebridDisabledAccountIds) && settings.megaDebridDisabledAccountIds.includes(accountId);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getMegaDebridAccountDailyLimitBytes(settings: ProviderDailySettings, accountId: string): number {
|
export function getMegaDebridAccountDailyLimitBytes(settings: ProviderDailySettings, accountId: string): number {
|
||||||
return normalizePositiveBytes(settings.megaDebridAccountDailyLimitBytes?.[accountId]);
|
return normalizePositiveBytes(settings.megaDebridAccountDailyLimitBytes?.[accountId]);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getMegaDebridAccountDailyUsageBytes(
|
export function getMegaDebridAccountDailyUsageBytes(
|
||||||
settings: ProviderDailySettings,
|
settings: ProviderDailySettings,
|
||||||
accountId: string,
|
accountId: string,
|
||||||
epochMs = Date.now()
|
epochMs = Date.now()
|
||||||
): number {
|
): number {
|
||||||
if (settings.providerDailyUsageDay !== getProviderUsageDayKey(epochMs)) {
|
if (settings.providerDailyUsageDay !== getProviderUsageDayKey(epochMs)) {
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
return normalizePositiveBytes(settings.megaDebridAccountDailyUsageBytes?.[accountId]);
|
return normalizePositiveBytes(settings.megaDebridAccountDailyUsageBytes?.[accountId]);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isMegaDebridAccountDailyLimitReached(
|
export function isMegaDebridAccountDailyLimitReached(
|
||||||
settings: ProviderDailySettings,
|
settings: ProviderDailySettings,
|
||||||
accountId: string,
|
accountId: string,
|
||||||
epochMs = Date.now()
|
epochMs = Date.now()
|
||||||
): boolean {
|
): boolean {
|
||||||
const limit = getMegaDebridAccountDailyLimitBytes(settings, accountId);
|
const limit = getMegaDebridAccountDailyLimitBytes(settings, accountId);
|
||||||
return limit > 0 && getMegaDebridAccountDailyUsageBytes(settings, accountId, epochMs) >= limit;
|
return limit > 0 && getMegaDebridAccountDailyUsageBytes(settings, accountId, epochMs) >= limit;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getMegaDebridAccountTotalUsageBytes(settings: ProviderUsageSettings, accountId: string): number {
|
export function getMegaDebridAccountTotalUsageBytes(settings: ProviderUsageSettings, accountId: string): number {
|
||||||
return normalizePositiveBytes(settings.megaDebridAccountTotalUsageBytes?.[accountId]);
|
return normalizePositiveBytes(settings.megaDebridAccountTotalUsageBytes?.[accountId]);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function addMegaDebridAccountDailyUsageBytes(
|
export function addMegaDebridAccountDailyUsageBytes(
|
||||||
settings: ProviderDailySettings,
|
settings: ProviderDailySettings,
|
||||||
accountId: string,
|
accountId: string,
|
||||||
byteDelta: number,
|
byteDelta: number,
|
||||||
epochMs = Date.now()
|
epochMs = Date.now()
|
||||||
): Pick<AppSettings, "providerDailyUsageDay" | "megaDebridAccountDailyUsageBytes"> {
|
): Pick<AppSettings, "providerDailyUsageDay" | "megaDebridAccountDailyUsageBytes"> {
|
||||||
const increment = normalizePositiveBytes(byteDelta);
|
const increment = normalizePositiveBytes(byteDelta);
|
||||||
const dayKey = getProviderUsageDayKey(epochMs);
|
const dayKey = getProviderUsageDayKey(epochMs);
|
||||||
const currentUsageBytes = settings.providerDailyUsageDay === dayKey
|
const currentUsageBytes = settings.providerDailyUsageDay === dayKey
|
||||||
? { ...(settings.megaDebridAccountDailyUsageBytes || {}) }
|
? { ...(settings.megaDebridAccountDailyUsageBytes || {}) }
|
||||||
: {};
|
: {};
|
||||||
if (increment <= 0) {
|
if (increment <= 0) {
|
||||||
return {
|
return {
|
||||||
providerDailyUsageDay: dayKey,
|
providerDailyUsageDay: dayKey,
|
||||||
megaDebridAccountDailyUsageBytes: currentUsageBytes
|
megaDebridAccountDailyUsageBytes: currentUsageBytes
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
currentUsageBytes[accountId] = normalizePositiveBytes(currentUsageBytes[accountId]) + increment;
|
currentUsageBytes[accountId] = normalizePositiveBytes(currentUsageBytes[accountId]) + increment;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
providerDailyUsageDay: dayKey,
|
providerDailyUsageDay: dayKey,
|
||||||
megaDebridAccountDailyUsageBytes: currentUsageBytes
|
megaDebridAccountDailyUsageBytes: currentUsageBytes
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function addMegaDebridAccountTotalUsageBytes(
|
export function addMegaDebridAccountTotalUsageBytes(
|
||||||
settings: ProviderUsageSettings,
|
settings: ProviderUsageSettings,
|
||||||
accountId: string,
|
accountId: string,
|
||||||
byteDelta: number
|
byteDelta: number
|
||||||
): Pick<AppSettings, "megaDebridAccountTotalUsageBytes"> {
|
): Pick<AppSettings, "megaDebridAccountTotalUsageBytes"> {
|
||||||
const increment = normalizePositiveBytes(byteDelta);
|
const increment = normalizePositiveBytes(byteDelta);
|
||||||
const currentUsageBytes = { ...(settings.megaDebridAccountTotalUsageBytes || {}) };
|
const currentUsageBytes = { ...(settings.megaDebridAccountTotalUsageBytes || {}) };
|
||||||
if (increment <= 0) {
|
if (increment <= 0) {
|
||||||
return {
|
return {
|
||||||
megaDebridAccountTotalUsageBytes: currentUsageBytes
|
megaDebridAccountTotalUsageBytes: currentUsageBytes
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
currentUsageBytes[accountId] = normalizePositiveBytes(currentUsageBytes[accountId]) + increment;
|
currentUsageBytes[accountId] = normalizePositiveBytes(currentUsageBytes[accountId]) + increment;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
megaDebridAccountTotalUsageBytes: currentUsageBytes
|
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 { describe, it, expect, vi, afterEach } from "vitest";
|
||||||
import { checkMegaDebridAccount, checkDebridLinkKey, checkAllDebridAccounts } from "../src/main/account-check";
|
import { checkMegaDebridAccount, checkDebridLinkKey, checkAllDebridAccounts } from "../src/main/account-check";
|
||||||
import type { MegaDebridAccountEntry } from "../src/shared/mega-debrid-accounts";
|
import type { MegaDebridAccountEntry } from "../src/shared/mega-debrid-accounts";
|
||||||
import type { DebridLinkApiKeyEntry } from "../src/shared/debrid-link-keys";
|
import type { DebridLinkApiKeyEntry } from "../src/shared/debrid-link-keys";
|
||||||
import type { AppSettings } from "../src/shared/types";
|
import type { AppSettings } from "../src/shared/types";
|
||||||
|
|
||||||
function megaAccount(login = "user@example.com"): MegaDebridAccountEntry {
|
function megaAccount(login = "user@example.com"): MegaDebridAccountEntry {
|
||||||
return { id: "mda_test", login, password: "pw", index: 0, label: "Account 1", maskedLogin: "us**le" };
|
return { id: "mda_test", login, password: "pw", index: 0, label: "Account 1", maskedLogin: "us**le" };
|
||||||
}
|
}
|
||||||
|
|
||||||
function debridLinkKey(token = "tok_abcdef"): DebridLinkApiKeyEntry {
|
function debridLinkKey(token = "tok_abcdef"): DebridLinkApiKeyEntry {
|
||||||
return { id: "dlk_test", token, index: 0, label: "Key 1", masked: "tok***def" };
|
return { id: "dlk_test", token, index: 0, label: "Key 1", masked: "tok***def" };
|
||||||
}
|
}
|
||||||
|
|
||||||
function mockFetchOnce(status: number, body: unknown): void {
|
function mockFetchOnce(status: number, body: unknown): void {
|
||||||
const text = typeof body === "string" ? body : JSON.stringify(body);
|
const text = typeof body === "string" ? body : JSON.stringify(body);
|
||||||
vi.stubGlobal("fetch", vi.fn(async () => ({
|
vi.stubGlobal("fetch", vi.fn(async () => ({
|
||||||
ok: status >= 200 && status < 300,
|
ok: status >= 200 && status < 300,
|
||||||
status,
|
status,
|
||||||
text: async () => text
|
text: async () => text
|
||||||
})) as unknown as typeof fetch);
|
})) as unknown as typeof fetch);
|
||||||
}
|
}
|
||||||
|
|
||||||
const NOW = 1_700_000_000_000;
|
const NOW = 1_700_000_000_000;
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
vi.unstubAllGlobals();
|
vi.unstubAllGlobals();
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("checkMegaDebridAccount", () => {
|
describe("checkMegaDebridAccount", () => {
|
||||||
it("reports valid + premium from vip_end (future Unix ts)", async () => {
|
it("reports valid + premium from vip_end (future Unix ts)", async () => {
|
||||||
const futureSec = Math.floor(NOW / 1000) + 30 * 24 * 60 * 60;
|
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" });
|
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);
|
const st = await checkMegaDebridAccount(megaAccount(), undefined, NOW);
|
||||||
expect(st.valid).toBe(true);
|
expect(st.valid).toBe(true);
|
||||||
expect(st.isPremium).toBe(true);
|
expect(st.isPremium).toBe(true);
|
||||||
expect(st.premiumUntilMs).toBe(futureSec * 1000);
|
expect(st.premiumUntilMs).toBe(futureSec * 1000);
|
||||||
expect(st.email).toBe("a@b.de");
|
expect(st.email).toBe("a@b.de");
|
||||||
expect(st.message).toMatch(/Premium noch/);
|
expect(st.message).toMatch(/Premium noch/);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("reports valid but NOT premium when vip_end is in the past", async () => {
|
it("reports valid but NOT premium when vip_end is in the past", async () => {
|
||||||
const pastSec = Math.floor(NOW / 1000) - 1000;
|
const pastSec = Math.floor(NOW / 1000) - 1000;
|
||||||
mockFetchOnce(200, { response_code: "ok", token: "t", vip_end: String(pastSec) });
|
mockFetchOnce(200, { response_code: "ok", token: "t", vip_end: String(pastSec) });
|
||||||
const st = await checkMegaDebridAccount(megaAccount(), undefined, NOW);
|
const st = await checkMegaDebridAccount(megaAccount(), undefined, NOW);
|
||||||
expect(st.valid).toBe(true);
|
expect(st.valid).toBe(true);
|
||||||
expect(st.isPremium).toBe(false);
|
expect(st.isPremium).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("reports valid but no premium when vip_end is 0/missing", async () => {
|
it("reports valid but no premium when vip_end is 0/missing", async () => {
|
||||||
mockFetchOnce(200, { response_code: "ok", token: "t", vip_end: "0" });
|
mockFetchOnce(200, { response_code: "ok", token: "t", vip_end: "0" });
|
||||||
const st = await checkMegaDebridAccount(megaAccount(), undefined, NOW);
|
const st = await checkMegaDebridAccount(megaAccount(), undefined, NOW);
|
||||||
expect(st.valid).toBe(true);
|
expect(st.valid).toBe(true);
|
||||||
expect(st.isPremium).toBe(false);
|
expect(st.isPremium).toBe(false);
|
||||||
expect(st.premiumUntilMs).toBe(0);
|
expect(st.premiumUntilMs).toBe(0);
|
||||||
expect(st.message).toMatch(/Kein Premium/);
|
expect(st.message).toMatch(/Kein Premium/);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("reports invalid login when response_code != ok", async () => {
|
it("reports invalid login when response_code != ok", async () => {
|
||||||
mockFetchOnce(200, { response_code: "error", response_text: "bad login" });
|
mockFetchOnce(200, { response_code: "error", response_text: "bad login" });
|
||||||
const st = await checkMegaDebridAccount(megaAccount(), undefined, NOW);
|
const st = await checkMegaDebridAccount(megaAccount(), undefined, NOW);
|
||||||
expect(st.valid).toBe(false);
|
expect(st.valid).toBe(false);
|
||||||
expect(st.isPremium).toBe(false);
|
expect(st.isPremium).toBe(false);
|
||||||
expect(st.message).toMatch(/Ungueltiger Login/);
|
expect(st.message).toMatch(/Ungueltiger Login/);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("reports invalid on HTTP error", async () => {
|
it("reports invalid on HTTP error", async () => {
|
||||||
mockFetchOnce(500, "server error");
|
mockFetchOnce(500, "server error");
|
||||||
const st = await checkMegaDebridAccount(megaAccount(), undefined, NOW);
|
const st = await checkMegaDebridAccount(megaAccount(), undefined, NOW);
|
||||||
expect(st.valid).toBe(false);
|
expect(st.valid).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("never throws on network error — returns a failed status", async () => {
|
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);
|
vi.stubGlobal("fetch", vi.fn(async () => { throw new Error("ECONNRESET"); }) as unknown as typeof fetch);
|
||||||
const st = await checkMegaDebridAccount(megaAccount(), undefined, NOW);
|
const st = await checkMegaDebridAccount(megaAccount(), undefined, NOW);
|
||||||
expect(st.valid).toBe(false);
|
expect(st.valid).toBe(false);
|
||||||
expect(st.message).toMatch(/Pruefung fehlgeschlagen/);
|
expect(st.message).toMatch(/Pruefung fehlgeschlagen/);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("checkDebridLinkKey", () => {
|
describe("checkDebridLinkKey", () => {
|
||||||
it("reports valid + premium from premiumLeft seconds", async () => {
|
it("reports valid + premium from premiumLeft seconds", async () => {
|
||||||
const premiumLeft = 60 * 24 * 60 * 60;
|
const premiumLeft = 60 * 24 * 60 * 60;
|
||||||
mockFetchOnce(200, { success: true, value: { username: "u", accountType: 1, premiumLeft } });
|
mockFetchOnce(200, { success: true, value: { username: "u", accountType: 1, premiumLeft } });
|
||||||
const st = await checkDebridLinkKey(debridLinkKey(), undefined, NOW);
|
const st = await checkDebridLinkKey(debridLinkKey(), undefined, NOW);
|
||||||
expect(st.valid).toBe(true);
|
expect(st.valid).toBe(true);
|
||||||
expect(st.isPremium).toBe(true);
|
expect(st.isPremium).toBe(true);
|
||||||
expect(st.premiumUntilMs).toBe(NOW + premiumLeft * 1000);
|
expect(st.premiumUntilMs).toBe(NOW + premiumLeft * 1000);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("reports valid but free (premiumLeft 0, accountType 0)", async () => {
|
it("reports valid but free (premiumLeft 0, accountType 0)", async () => {
|
||||||
mockFetchOnce(200, { success: true, value: { username: "u", accountType: 0, premiumLeft: 0 } });
|
mockFetchOnce(200, { success: true, value: { username: "u", accountType: 0, premiumLeft: 0 } });
|
||||||
const st = await checkDebridLinkKey(debridLinkKey(), undefined, NOW);
|
const st = await checkDebridLinkKey(debridLinkKey(), undefined, NOW);
|
||||||
expect(st.valid).toBe(true);
|
expect(st.valid).toBe(true);
|
||||||
expect(st.isPremium).toBe(false);
|
expect(st.isPremium).toBe(false);
|
||||||
expect(st.message).toMatch(/Free/);
|
expect(st.message).toMatch(/Free/);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("reports invalid key on HTTP 401", async () => {
|
it("reports invalid key on HTTP 401", async () => {
|
||||||
mockFetchOnce(401, { success: false, error: "badToken" });
|
mockFetchOnce(401, { success: false, error: "badToken" });
|
||||||
const st = await checkDebridLinkKey(debridLinkKey(), undefined, NOW);
|
const st = await checkDebridLinkKey(debridLinkKey(), undefined, NOW);
|
||||||
expect(st.valid).toBe(false);
|
expect(st.valid).toBe(false);
|
||||||
expect(st.message).toMatch(/Ungueltiger API-Key/);
|
expect(st.message).toMatch(/Ungueltiger API-Key/);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("reports invalid key when success=false", async () => {
|
it("reports invalid key when success=false", async () => {
|
||||||
mockFetchOnce(200, { success: false, error: "badToken" });
|
mockFetchOnce(200, { success: false, error: "badToken" });
|
||||||
const st = await checkDebridLinkKey(debridLinkKey(), undefined, NOW);
|
const st = await checkDebridLinkKey(debridLinkKey(), undefined, NOW);
|
||||||
expect(st.valid).toBe(false);
|
expect(st.valid).toBe(false);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("checkAllDebridAccounts", () => {
|
describe("checkAllDebridAccounts", () => {
|
||||||
it("returns empty array when nothing configured", async () => {
|
it("returns empty array when nothing configured", async () => {
|
||||||
const settings = { megaCredentials: "", megaPassword: "", debridLinkApiKeys: "" } as unknown as AppSettings;
|
const settings = { megaCredentials: "", megaPassword: "", debridLinkApiKeys: "" } as unknown as AppSettings;
|
||||||
const result = await checkAllDebridAccounts(settings);
|
const result = await checkAllDebridAccounts(settings);
|
||||||
expect(result).toEqual([]);
|
expect(result).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("checks every configured mega account + debrid-link key", async () => {
|
it("checks every configured mega account + debrid-link key", async () => {
|
||||||
const futureSec = Math.floor(Date.now() / 1000) + 1000;
|
const futureSec = Math.floor(Date.now() / 1000) + 1000;
|
||||||
vi.stubGlobal("fetch", vi.fn(async (url: string) => {
|
vi.stubGlobal("fetch", vi.fn(async (url: string) => {
|
||||||
if (String(url).includes("mega-debrid")) {
|
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({ 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 } }) };
|
return { ok: true, status: 200, text: async () => JSON.stringify({ success: true, value: { accountType: 1, premiumLeft: 1000 } }) };
|
||||||
}) as unknown as typeof fetch);
|
}) as unknown as typeof fetch);
|
||||||
|
|
||||||
const settings = {
|
const settings = {
|
||||||
megaCredentials: "a@b.de:pw1\nc@d.de:pw2",
|
megaCredentials: "a@b.de:pw1\nc@d.de:pw2",
|
||||||
megaPassword: "",
|
megaPassword: "",
|
||||||
debridLinkApiKeys: "key1\nkey2\nkey3"
|
debridLinkApiKeys: "key1\nkey2\nkey3"
|
||||||
} as unknown as AppSettings;
|
} as unknown as AppSettings;
|
||||||
|
|
||||||
const result = await checkAllDebridAccounts(settings);
|
const result = await checkAllDebridAccounts(settings);
|
||||||
expect(result).toHaveLength(5);
|
expect(result).toHaveLength(5);
|
||||||
expect(result.filter((r) => r.provider === "megadebrid")).toHaveLength(2);
|
expect(result.filter((r) => r.provider === "megadebrid")).toHaveLength(2);
|
||||||
expect(result.filter((r) => r.provider === "debridlink")).toHaveLength(3);
|
expect(result.filter((r) => r.provider === "debridlink")).toHaveLength(3);
|
||||||
expect(result.every((r) => r.valid)).toBe(true);
|
expect(result.every((r) => r.valid)).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("caps concurrency (never more than 4 in flight) and preserves result order", async () => {
|
it("caps concurrency (never more than 4 in flight) and preserves result order", async () => {
|
||||||
let inFlight = 0;
|
let inFlight = 0;
|
||||||
let maxInFlight = 0;
|
let maxInFlight = 0;
|
||||||
vi.stubGlobal("fetch", vi.fn(async () => {
|
vi.stubGlobal("fetch", vi.fn(async () => {
|
||||||
inFlight += 1;
|
inFlight += 1;
|
||||||
maxInFlight = Math.max(maxInFlight, inFlight);
|
maxInFlight = Math.max(maxInFlight, inFlight);
|
||||||
await new Promise((resolve) => setTimeout(resolve, 5));
|
await new Promise((resolve) => setTimeout(resolve, 5));
|
||||||
inFlight -= 1;
|
inFlight -= 1;
|
||||||
return { ok: true, status: 200, text: async () => JSON.stringify({ success: true, value: { accountType: 1, premiumLeft: 1000 } }) };
|
return { ok: true, status: 200, text: async () => JSON.stringify({ success: true, value: { accountType: 1, premiumLeft: 1000 } }) };
|
||||||
}) as unknown as typeof fetch);
|
}) as unknown as typeof fetch);
|
||||||
|
|
||||||
const keys = Array.from({ length: 9 }, (_, i) => `key_${i}`).join("\n");
|
const keys = Array.from({ length: 9 }, (_, i) => `key_${i}`).join("\n");
|
||||||
const settings = { megaCredentials: "", megaPassword: "", debridLinkApiKeys: keys } as unknown as AppSettings;
|
const settings = { megaCredentials: "", megaPassword: "", debridLinkApiKeys: keys } as unknown as AppSettings;
|
||||||
|
|
||||||
const result = await checkAllDebridAccounts(settings);
|
const result = await checkAllDebridAccounts(settings);
|
||||||
expect(result).toHaveLength(9);
|
expect(result).toHaveLength(9);
|
||||||
expect(maxInFlight).toBeLessThanOrEqual(4);
|
expect(maxInFlight).toBeLessThanOrEqual(4);
|
||||||
result.forEach((r, i) => expect(r.label).toBe(`Key ${i + 1}`));
|
result.forEach((r, i) => expect(r.label).toBe(`Key ${i + 1}`));
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -1,35 +1,35 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import { applyAccountDialogToSettings, AccountDialogState } from "../src/renderer/App";
|
import { applyAccountDialogToSettings, AccountDialogState } from "../src/renderer/App";
|
||||||
import { defaultSettings } from "../src/main/constants";
|
import { defaultSettings } from "../src/main/constants";
|
||||||
|
|
||||||
function megaDialog(kind: "megadebrid-api" | "megadebrid-web"): AccountDialogState {
|
function megaDialog(kind: "megadebrid-api" | "megadebrid-web"): AccountDialogState {
|
||||||
return {
|
return {
|
||||||
mode: "edit",
|
mode: "edit",
|
||||||
kind,
|
kind,
|
||||||
token: "",
|
token: "",
|
||||||
login: "",
|
login: "",
|
||||||
password: "",
|
password: "",
|
||||||
dailyLimitGb: "",
|
dailyLimitGb: "",
|
||||||
keyDailyLimitGbById: {},
|
keyDailyLimitGbById: {},
|
||||||
megaAccounts: [{ login: "user@x", password: "pw" }],
|
megaAccounts: [{ login: "user@x", password: "pw" }],
|
||||||
megaNewLogin: "",
|
megaNewLogin: "",
|
||||||
megaNewPassword: "",
|
megaNewPassword: "",
|
||||||
megaDisabledIds: []
|
megaDisabledIds: []
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
describe("applyAccountDialogToSettings — keeps the user's Mega preferApi choice", () => {
|
describe("applyAccountDialogToSettings — keeps the user's Mega preferApi choice", () => {
|
||||||
it("does not flip megaDebridPreferApi to true when editing the API account", () => {
|
it("does not flip megaDebridPreferApi to true when editing the API account", () => {
|
||||||
const settings = { ...defaultSettings(), megaDebridApiEnabled: true, megaDebridWebEnabled: true, megaDebridPreferApi: false };
|
const settings = { ...defaultSettings(), megaDebridApiEnabled: true, megaDebridWebEnabled: true, megaDebridPreferApi: false };
|
||||||
const next = applyAccountDialogToSettings(settings, megaDialog("megadebrid-api"));
|
const next = applyAccountDialogToSettings(settings, megaDialog("megadebrid-api"));
|
||||||
expect(next.megaDebridApiEnabled).toBe(true);
|
expect(next.megaDebridApiEnabled).toBe(true);
|
||||||
expect(next.megaDebridPreferApi).toBe(false);
|
expect(next.megaDebridPreferApi).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("does not flip megaDebridPreferApi to false when editing the Web account", () => {
|
it("does not flip megaDebridPreferApi to false when editing the Web account", () => {
|
||||||
const settings = { ...defaultSettings(), megaDebridApiEnabled: true, megaDebridWebEnabled: true, megaDebridPreferApi: true };
|
const settings = { ...defaultSettings(), megaDebridApiEnabled: true, megaDebridWebEnabled: true, megaDebridPreferApi: true };
|
||||||
const next = applyAccountDialogToSettings(settings, megaDialog("megadebrid-web"));
|
const next = applyAccountDialogToSettings(settings, megaDialog("megadebrid-web"));
|
||||||
expect(next.megaDebridWebEnabled).toBe(true);
|
expect(next.megaDebridWebEnabled).toBe(true);
|
||||||
expect(next.megaDebridPreferApi).toBe(true);
|
expect(next.megaDebridPreferApi).toBe(true);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -1,57 +1,57 @@
|
|||||||
import { describe, it, expect } from "vitest";
|
import { describe, it, expect } from "vitest";
|
||||||
import { logAccountRotation, runWithRotationItemSink, getRecentRotationEvents } from "../src/main/account-rotation-log";
|
import { logAccountRotation, runWithRotationItemSink, getRecentRotationEvents } from "../src/main/account-rotation-log";
|
||||||
import type { RotationEvent } from "../src/shared/types";
|
import type { RotationEvent } from "../src/shared/types";
|
||||||
|
|
||||||
describe("rotation item-sink (AsyncLocalStorage)", () => {
|
describe("rotation item-sink (AsyncLocalStorage)", () => {
|
||||||
it("routes the FULL rotation trail (incl. TEST) to the active item sink", async () => {
|
it("routes the FULL rotation trail (incl. TEST) to the active item sink", async () => {
|
||||||
const captured: RotationEvent[] = [];
|
const captured: RotationEvent[] = [];
|
||||||
await runWithRotationItemSink((ev) => captured.push(ev), async () => {
|
await runWithRotationItemSink((ev) => captured.push(ev), async () => {
|
||||||
logAccountRotation("INFO", "Mega-Debrid Web", "Account 1/3 (ab**xy)", "TEST", { link: "x" });
|
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("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)", "TEST", { link: "x" });
|
||||||
logAccountRotation("INFO", "Mega-Debrid Web", "Account 2/3 (cd**zw)", "OK", { fileName: "f.mkv" });
|
logAccountRotation("INFO", "Mega-Debrid Web", "Account 2/3 (cd**zw)", "OK", { fileName: "f.mkv" });
|
||||||
await Promise.resolve();
|
await Promise.resolve();
|
||||||
});
|
});
|
||||||
|
|
||||||
const events = captured.map((e) => e.event);
|
const events = captured.map((e) => e.event);
|
||||||
expect(events).toEqual(["TEST", "FAILED", "TEST", "OK"]);
|
expect(events).toEqual(["TEST", "FAILED", "TEST", "OK"]);
|
||||||
const failed = captured.find((e) => e.event === "FAILED");
|
const failed = captured.find((e) => e.event === "FAILED");
|
||||||
expect(failed?.reason).toBe("Timeout");
|
expect(failed?.reason).toBe("Timeout");
|
||||||
expect(failed?.next).toBe("Account 2/3 (cd**zw)");
|
expect(failed?.next).toBe("Account 2/3 (cd**zw)");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("does not leak events to the sink outside the run() scope", () => {
|
it("does not leak events to the sink outside the run() scope", () => {
|
||||||
const captured: RotationEvent[] = [];
|
const captured: RotationEvent[] = [];
|
||||||
logAccountRotation("INFO", "Debrid-Link", "Key 1/2 (k1)", "OK");
|
logAccountRotation("INFO", "Debrid-Link", "Key 1/2 (k1)", "OK");
|
||||||
expect(captured).toHaveLength(0);
|
expect(captured).toHaveLength(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("isolates two parallel item sinks (no cross-attribution)", async () => {
|
it("isolates two parallel item sinks (no cross-attribution)", async () => {
|
||||||
const a: RotationEvent[] = [];
|
const a: RotationEvent[] = [];
|
||||||
const b: RotationEvent[] = [];
|
const b: RotationEvent[] = [];
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
runWithRotationItemSink((ev) => a.push(ev), async () => {
|
runWithRotationItemSink((ev) => a.push(ev), async () => {
|
||||||
logAccountRotation("INFO", "Mega-Debrid Web", "Account 1 (a)", "TEST");
|
logAccountRotation("INFO", "Mega-Debrid Web", "Account 1 (a)", "TEST");
|
||||||
await new Promise((r) => setTimeout(r, 10));
|
await new Promise((r) => setTimeout(r, 10));
|
||||||
logAccountRotation("INFO", "Mega-Debrid Web", "Account 1 (a)", "OK");
|
logAccountRotation("INFO", "Mega-Debrid Web", "Account 1 (a)", "OK");
|
||||||
}),
|
}),
|
||||||
runWithRotationItemSink((ev) => b.push(ev), async () => {
|
runWithRotationItemSink((ev) => b.push(ev), async () => {
|
||||||
logAccountRotation("INFO", "Debrid-Link", "Key 1 (b)", "TEST");
|
logAccountRotation("INFO", "Debrid-Link", "Key 1 (b)", "TEST");
|
||||||
await new Promise((r) => setTimeout(r, 5));
|
await new Promise((r) => setTimeout(r, 5));
|
||||||
logAccountRotation("WARN", "Debrid-Link", "Key 1 (b)", "FAILED", { reason: "badToken" });
|
logAccountRotation("WARN", "Debrid-Link", "Key 1 (b)", "FAILED", { reason: "badToken" });
|
||||||
})
|
})
|
||||||
]);
|
]);
|
||||||
expect(a.every((e) => e.provider === "Mega-Debrid Web")).toBe(true);
|
expect(a.every((e) => e.provider === "Mega-Debrid Web")).toBe(true);
|
||||||
expect(b.every((e) => e.provider === "Debrid-Link")).toBe(true);
|
expect(b.every((e) => e.provider === "Debrid-Link")).toBe(true);
|
||||||
expect(a.map((e) => e.event)).toEqual(["TEST", "OK"]);
|
expect(a.map((e) => e.event)).toEqual(["TEST", "OK"]);
|
||||||
expect(b.map((e) => e.event)).toEqual(["TEST", "FAILED"]);
|
expect(b.map((e) => e.event)).toEqual(["TEST", "FAILED"]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("still feeds the global UI ring (outcomes only, TEST filtered)", () => {
|
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)", "TEST");
|
||||||
logAccountRotation("INFO", "Mega-Debrid API", "Account 9 (zz)", "OK", { fileName: "ring.mkv" });
|
logAccountRotation("INFO", "Mega-Debrid API", "Account 9 (zz)", "OK", { fileName: "ring.mkv" });
|
||||||
const ring = getRecentRotationEvents(10);
|
const ring = getRecentRotationEvents(10);
|
||||||
expect(ring.some((e) => e.event === "OK" && e.accountLabel === "Account 9 (zz)")).toBe(true);
|
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);
|
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 { describe, expect, it } from "vitest";
|
||||||
import { reorderPackageOrderByDrop, sortPackageOrderByName } from "../src/renderer/package-order";
|
import { reorderPackageOrderByDrop, sortPackageOrderByName } from "../src/renderer/package-order";
|
||||||
|
|
||||||
describe("reorderPackageOrderByDrop", () => {
|
describe("reorderPackageOrderByDrop", () => {
|
||||||
it("moves adjacent package down by one on drop", () => {
|
it("moves adjacent package down by one on drop", () => {
|
||||||
const next = reorderPackageOrderByDrop(["a", "b", "c"], "b", "c");
|
const next = reorderPackageOrderByDrop(["a", "b", "c"], "b", "c");
|
||||||
expect(next).toEqual(["a", "c", "b"]);
|
expect(next).toEqual(["a", "c", "b"]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("moves package after lower drop target", () => {
|
it("moves package after lower drop target", () => {
|
||||||
const next = reorderPackageOrderByDrop(["a", "b", "c", "d"], "a", "c");
|
const next = reorderPackageOrderByDrop(["a", "b", "c", "d"], "a", "c");
|
||||||
expect(next).toEqual(["b", "c", "a", "d"]);
|
expect(next).toEqual(["b", "c", "a", "d"]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns original order when ids are invalid", () => {
|
it("returns original order when ids are invalid", () => {
|
||||||
const order = ["a", "b", "c"];
|
const order = ["a", "b", "c"];
|
||||||
expect(reorderPackageOrderByDrop(order, "x", "b")).toEqual(order);
|
expect(reorderPackageOrderByDrop(order, "x", "b")).toEqual(order);
|
||||||
expect(reorderPackageOrderByDrop(order, "a", "x")).toEqual(order);
|
expect(reorderPackageOrderByDrop(order, "a", "x")).toEqual(order);
|
||||||
expect(reorderPackageOrderByDrop(order, "a", "a")).toEqual(order);
|
expect(reorderPackageOrderByDrop(order, "a", "a")).toEqual(order);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("sortPackageOrderByName", () => {
|
describe("sortPackageOrderByName", () => {
|
||||||
it("sorts package IDs alphabetically ascending", () => {
|
it("sorts package IDs alphabetically ascending", () => {
|
||||||
const sorted = sortPackageOrderByName(
|
const sorted = sortPackageOrderByName(
|
||||||
["pkg3", "pkg1", "pkg2"],
|
["pkg3", "pkg1", "pkg2"],
|
||||||
{
|
{
|
||||||
pkg1: { id: "pkg1", name: "Alpha", outputDir: "", extractDir: "", status: "queued", itemIds: [], cancelled: false, enabled: true, priority: "normal", createdAt: 0, updatedAt: 0 },
|
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 },
|
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 }
|
pkg3: { id: "pkg3", name: "Gamma", outputDir: "", extractDir: "", status: "queued", itemIds: [], cancelled: false, enabled: true, priority: "normal", createdAt: 0, updatedAt: 0 }
|
||||||
},
|
},
|
||||||
false
|
false
|
||||||
);
|
);
|
||||||
expect(sorted).toEqual(["pkg1", "pkg2", "pkg3"]);
|
expect(sorted).toEqual(["pkg1", "pkg2", "pkg3"]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("sorts package IDs alphabetically descending", () => {
|
it("sorts package IDs alphabetically descending", () => {
|
||||||
const sorted = sortPackageOrderByName(
|
const sorted = sortPackageOrderByName(
|
||||||
["pkg1", "pkg2", "pkg3"],
|
["pkg1", "pkg2", "pkg3"],
|
||||||
{
|
{
|
||||||
pkg1: { id: "pkg1", name: "Alpha", outputDir: "", extractDir: "", status: "queued", itemIds: [], cancelled: false, enabled: true, priority: "normal", createdAt: 0, updatedAt: 0 },
|
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 },
|
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 }
|
pkg3: { id: "pkg3", name: "Gamma", outputDir: "", extractDir: "", status: "queued", itemIds: [], cancelled: false, enabled: true, priority: "normal", createdAt: 0, updatedAt: 0 }
|
||||||
},
|
},
|
||||||
true
|
true
|
||||||
);
|
);
|
||||||
expect(sorted).toEqual(["pkg3", "pkg2", "pkg1"]);
|
expect(sorted).toEqual(["pkg3", "pkg2", "pkg1"]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -1,48 +1,48 @@
|
|||||||
import fs from "node:fs";
|
import fs from "node:fs";
|
||||||
import os from "node:os";
|
import os from "node:os";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { afterEach, describe, expect, it } from "vitest";
|
import { afterEach, describe, expect, it } from "vitest";
|
||||||
import { getAuditLogPath, initAuditLog, logAuditEvent, shutdownAuditLog } from "../src/main/audit-log";
|
import { getAuditLogPath, initAuditLog, logAuditEvent, shutdownAuditLog } from "../src/main/audit-log";
|
||||||
|
|
||||||
const tempDirs: string[] = [];
|
const tempDirs: string[] = [];
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
shutdownAuditLog();
|
shutdownAuditLog();
|
||||||
for (const dir of tempDirs.splice(0)) {
|
for (const dir of tempDirs.splice(0)) {
|
||||||
fs.rmSync(dir, { recursive: true, force: true });
|
fs.rmSync(dir, { recursive: true, force: true });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("audit-log", () => {
|
describe("audit-log", () => {
|
||||||
it("writes audit events to the audit log", () => {
|
it("writes audit events to the audit log", () => {
|
||||||
const baseDir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-alog-"));
|
const baseDir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-alog-"));
|
||||||
tempDirs.push(baseDir);
|
tempDirs.push(baseDir);
|
||||||
|
|
||||||
initAuditLog(baseDir);
|
initAuditLog(baseDir);
|
||||||
logAuditEvent("INFO", "Settings changed", { changedKeys: ["token", "autoExtract"] });
|
logAuditEvent("INFO", "Settings changed", { changedKeys: ["token", "autoExtract"] });
|
||||||
|
|
||||||
const logPath = getAuditLogPath();
|
const logPath = getAuditLogPath();
|
||||||
expect(logPath).not.toBeNull();
|
expect(logPath).not.toBeNull();
|
||||||
expect(fs.existsSync(logPath!)).toBe(true);
|
expect(fs.existsSync(logPath!)).toBe(true);
|
||||||
const content = fs.readFileSync(logPath!, "utf8");
|
const content = fs.readFileSync(logPath!, "utf8");
|
||||||
expect(content).toContain("Audit-Log Start");
|
expect(content).toContain("Audit-Log Start");
|
||||||
expect(content).toContain("Settings changed");
|
expect(content).toContain("Settings changed");
|
||||||
expect(content).toContain("changedKeys");
|
expect(content).toContain("changedKeys");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("rotates oversized audit logs on startup", () => {
|
it("rotates oversized audit logs on startup", () => {
|
||||||
const baseDir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-alog-rotate-"));
|
const baseDir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-alog-rotate-"));
|
||||||
tempDirs.push(baseDir);
|
tempDirs.push(baseDir);
|
||||||
|
|
||||||
const oversizedPath = path.join(baseDir, "audit.log");
|
const oversizedPath = path.join(baseDir, "audit.log");
|
||||||
fs.mkdirSync(baseDir, { recursive: true });
|
fs.mkdirSync(baseDir, { recursive: true });
|
||||||
fs.writeFileSync(oversizedPath, "x".repeat(10 * 1024 * 1024 + 256), "utf8");
|
fs.writeFileSync(oversizedPath, "x".repeat(10 * 1024 * 1024 + 256), "utf8");
|
||||||
|
|
||||||
initAuditLog(baseDir);
|
initAuditLog(baseDir);
|
||||||
|
|
||||||
expect(fs.existsSync(oversizedPath)).toBe(true);
|
expect(fs.existsSync(oversizedPath)).toBe(true);
|
||||||
expect(fs.existsSync(`${oversizedPath}.old`)).toBe(true);
|
expect(fs.existsSync(`${oversizedPath}.old`)).toBe(true);
|
||||||
const content = fs.readFileSync(oversizedPath, "utf8");
|
const content = fs.readFileSync(oversizedPath, "utf8");
|
||||||
expect(content).toContain("Audit-Log Start");
|
expect(content).toContain("Audit-Log Start");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@ -1,82 +1,82 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import { encryptBackup, decryptBackup } from "../src/main/backup-crypto";
|
import { encryptBackup, decryptBackup } from "../src/main/backup-crypto";
|
||||||
|
|
||||||
describe("backup-crypto", () => {
|
describe("backup-crypto", () => {
|
||||||
it("encrypts and decrypts a round-trip correctly", () => {
|
it("encrypts and decrypts a round-trip correctly", () => {
|
||||||
const original = JSON.stringify({
|
const original = JSON.stringify({
|
||||||
version: 2,
|
version: 2,
|
||||||
settings: { token: "my-secret-api-key", outputDir: "C:\\Downloads" },
|
settings: { token: "my-secret-api-key", outputDir: "C:\\Downloads" },
|
||||||
session: { packages: {}, items: {} },
|
session: { packages: {}, items: {} },
|
||||||
history: [{ id: "h1", name: "Test" }]
|
history: [{ id: "h1", name: "Test" }]
|
||||||
});
|
});
|
||||||
|
|
||||||
const encrypted = encryptBackup(original);
|
const encrypted = encryptBackup(original);
|
||||||
const decrypted = decryptBackup(encrypted);
|
const decrypted = decryptBackup(encrypted);
|
||||||
expect(decrypted).toBe(original);
|
expect(decrypted).toBe(original);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("produces binary output that is not plaintext readable", () => {
|
it("produces binary output that is not plaintext readable", () => {
|
||||||
const secret = "super-secret-token-12345";
|
const secret = "super-secret-token-12345";
|
||||||
const plaintext = JSON.stringify({ settings: { token: secret } });
|
const plaintext = JSON.stringify({ settings: { token: secret } });
|
||||||
const encrypted = encryptBackup(plaintext);
|
const encrypted = encryptBackup(plaintext);
|
||||||
|
|
||||||
expect(encrypted.toString("utf8")).not.toContain(secret);
|
expect(encrypted.toString("utf8")).not.toContain(secret);
|
||||||
expect(encrypted.toString("latin1")).not.toContain(secret);
|
expect(encrypted.toString("latin1")).not.toContain(secret);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("starts with the MDD1 magic bytes", () => {
|
it("starts with the MDD1 magic bytes", () => {
|
||||||
const encrypted = encryptBackup("test");
|
const encrypted = encryptBackup("test");
|
||||||
expect(encrypted.subarray(0, 4).toString("utf8")).toBe("MDD1");
|
expect(encrypted.subarray(0, 4).toString("utf8")).toBe("MDD1");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("produces different ciphertext for the same input (random IV)", () => {
|
it("produces different ciphertext for the same input (random IV)", () => {
|
||||||
const plaintext = "same input data";
|
const plaintext = "same input data";
|
||||||
const a = encryptBackup(plaintext);
|
const a = encryptBackup(plaintext);
|
||||||
const b = encryptBackup(plaintext);
|
const b = encryptBackup(plaintext);
|
||||||
expect(a.equals(b)).toBe(false);
|
expect(a.equals(b)).toBe(false);
|
||||||
expect(decryptBackup(a)).toBe(plaintext);
|
expect(decryptBackup(a)).toBe(plaintext);
|
||||||
expect(decryptBackup(b)).toBe(plaintext);
|
expect(decryptBackup(b)).toBe(plaintext);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("throws on truncated data", () => {
|
it("throws on truncated data", () => {
|
||||||
const encrypted = encryptBackup("test data");
|
const encrypted = encryptBackup("test data");
|
||||||
const truncated = encrypted.subarray(0, 10);
|
const truncated = encrypted.subarray(0, 10);
|
||||||
expect(() => decryptBackup(truncated)).toThrow();
|
expect(() => decryptBackup(truncated)).toThrow();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("throws on corrupted ciphertext", () => {
|
it("throws on corrupted ciphertext", () => {
|
||||||
const encrypted = encryptBackup("test data");
|
const encrypted = encryptBackup("test data");
|
||||||
const corrupted = Buffer.from(encrypted);
|
const corrupted = Buffer.from(encrypted);
|
||||||
corrupted[corrupted.length - 1] ^= 0xff;
|
corrupted[corrupted.length - 1] ^= 0xff;
|
||||||
expect(() => decryptBackup(corrupted)).toThrow();
|
expect(() => decryptBackup(corrupted)).toThrow();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("throws on wrong magic bytes", () => {
|
it("throws on wrong magic bytes", () => {
|
||||||
const encrypted = encryptBackup("test data");
|
const encrypted = encryptBackup("test data");
|
||||||
const wrongMagic = Buffer.from(encrypted);
|
const wrongMagic = Buffer.from(encrypted);
|
||||||
wrongMagic[0] = 0x00;
|
wrongMagic[0] = 0x00;
|
||||||
expect(() => decryptBackup(wrongMagic)).toThrow(/Signatur/);
|
expect(() => decryptBackup(wrongMagic)).toThrow(/Signatur/);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("throws on empty buffer", () => {
|
it("throws on empty buffer", () => {
|
||||||
expect(() => decryptBackup(Buffer.alloc(0))).toThrow();
|
expect(() => decryptBackup(Buffer.alloc(0))).toThrow();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("handles large payloads", () => {
|
it("handles large payloads", () => {
|
||||||
const large = JSON.stringify({ data: "x".repeat(1_000_000) });
|
const large = JSON.stringify({ data: "x".repeat(1_000_000) });
|
||||||
const encrypted = encryptBackup(large);
|
const encrypted = encryptBackup(large);
|
||||||
const decrypted = decryptBackup(encrypted);
|
const decrypted = decryptBackup(encrypted);
|
||||||
expect(decrypted).toBe(large);
|
expect(decrypted).toBe(large);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("handles unicode content", () => {
|
it("handles unicode content", () => {
|
||||||
const unicode = JSON.stringify({ name: "Ünïcödé 日本語 🎉", path: "C:\\Benutzer\\Ö" });
|
const unicode = JSON.stringify({ name: "Ünïcödé 日本語 🎉", path: "C:\\Benutzer\\Ö" });
|
||||||
const encrypted = encryptBackup(unicode);
|
const encrypted = encryptBackup(unicode);
|
||||||
expect(decryptBackup(encrypted)).toBe(unicode);
|
expect(decryptBackup(encrypted)).toBe(unicode);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("handles empty string round-trip", () => {
|
it("handles empty string round-trip", () => {
|
||||||
const encrypted = encryptBackup("");
|
const encrypted = encryptBackup("");
|
||||||
expect(decryptBackup(encrypted)).toBe("");
|
expect(decryptBackup(encrypted)).toBe("");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -1,251 +1,232 @@
|
|||||||
import fs from "node:fs";
|
import fs from "node:fs";
|
||||||
import http from "node:http";
|
import http from "node:http";
|
||||||
import os from "node:os";
|
import os from "node:os";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { once } from "node:events";
|
import { once } from "node:events";
|
||||||
import { afterEach, describe, expect, it } from "vitest";
|
import { afterEach, describe, expect, it } from "vitest";
|
||||||
|
|
||||||
import { buildBackupPayload, resolveRemoteDiagnosticsRestore, BackupRemoteDiagnostics } from "../src/main/backup-payload";
|
import { buildBackupPayload, resolveMcpRemoteRestore, BackupMcpRemote } from "../src/main/backup-payload";
|
||||||
import { defaultSettings } from "../src/main/constants";
|
import { defaultSettings } from "../src/main/constants";
|
||||||
import { normalizeSettings } from "../src/main/storage";
|
import { normalizeSettings } from "../src/main/storage";
|
||||||
import {
|
import {
|
||||||
startDebugServer,
|
startDebugServer,
|
||||||
stopDebugServer,
|
stopDebugServer,
|
||||||
restartDebugServer,
|
restartDebugServer,
|
||||||
writeDebugServerConfig,
|
writeDebugServerConfig,
|
||||||
getDebugAllowlist,
|
getDebugAllowlist,
|
||||||
getDebugServerRuntimeStatus
|
getDebugServerRuntimeStatus
|
||||||
} from "../src/main/debug-server";
|
} from "../src/main/debug-server";
|
||||||
import type { DownloadManager } from "../src/main/download-manager";
|
import type { DownloadManager } from "../src/main/download-manager";
|
||||||
import type { AppSettings, SessionState } from "../src/shared/types";
|
import type { AppSettings, SessionState } from "../src/shared/types";
|
||||||
|
|
||||||
const tempDirs: string[] = [];
|
const tempDirs: string[] = [];
|
||||||
|
|
||||||
function input(settingsOverride: Partial<AppSettings>, remoteDiagnostics?: BackupRemoteDiagnostics) {
|
function input(settingsOverride: Partial<AppSettings>, mcpRemote?: BackupMcpRemote) {
|
||||||
return {
|
return {
|
||||||
settings: { ...defaultSettings(), ...settingsOverride } as AppSettings,
|
settings: { ...defaultSettings(), ...settingsOverride } as AppSettings,
|
||||||
appVersion: "1.7.233",
|
appVersion: "1.7.224",
|
||||||
exportedAt: "2026-08-01T00:00:00.000Z",
|
exportedAt: "2026-06-19T00:00:00.000Z",
|
||||||
session: {} as unknown as SessionState,
|
session: {} as unknown as SessionState,
|
||||||
history: [],
|
history: [],
|
||||||
remoteDiagnostics
|
mcpRemote
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getFreePort(): Promise<number> {
|
async function getFreePort(): Promise<number> {
|
||||||
const probe = http.createServer();
|
const probe = http.createServer();
|
||||||
probe.listen(0, "127.0.0.1");
|
probe.listen(0, "127.0.0.1");
|
||||||
await once(probe, "listening");
|
await once(probe, "listening");
|
||||||
const address = probe.address();
|
const address = probe.address();
|
||||||
if (!address || typeof address === "string") {
|
if (!address || typeof address === "string") {
|
||||||
throw new Error("port probe failed");
|
throw new Error("port probe failed");
|
||||||
}
|
}
|
||||||
probe.close();
|
probe.close();
|
||||||
await once(probe, "close");
|
await once(probe, "close");
|
||||||
return address.port;
|
return address.port;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function waitForReady(url: string): Promise<void> {
|
async function waitForReady(url: string): Promise<void> {
|
||||||
const deadline = Date.now() + 5000;
|
const deadline = Date.now() + 5000;
|
||||||
while (Date.now() < deadline) {
|
while (Date.now() < deadline) {
|
||||||
try {
|
try {
|
||||||
const res = await fetch(url);
|
const res = await fetch(url);
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
return;
|
return;
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
}
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 40));
|
|
||||||
}
|
|
||||||
throw new Error(`debug server not ready: ${url}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
stopDebugServer();
|
|
||||||
while (tempDirs.length > 0) {
|
|
||||||
const dir = tempDirs.pop();
|
|
||||||
if (!dir) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
fs.rmSync(dir, { recursive: true, force: true });
|
|
||||||
} catch {
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("backup remoteDiagnostics export gating", () => {
|
|
||||||
it("includes remoteDiagnostics when backupIncludeRemoteDiagnostics is on", () => {
|
|
||||||
const settings = {
|
|
||||||
...defaultSettings(),
|
|
||||||
backupIncludeRemoteDiagnostics: true
|
|
||||||
};
|
|
||||||
const payload = buildBackupPayload({
|
|
||||||
settings,
|
|
||||||
appVersion: "1.7.233",
|
|
||||||
exportedAt: "2026-08-01T00:00:00.000Z",
|
|
||||||
session: {} as unknown as SessionState,
|
|
||||||
history: [],
|
|
||||||
remoteDiagnostics: {
|
|
||||||
allowlist: ["192.0.2.0/24"],
|
|
||||||
port: 8976,
|
|
||||||
hostMode: "network"
|
|
||||||
}
|
}
|
||||||
});
|
} catch {
|
||||||
|
}
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 40));
|
||||||
|
}
|
||||||
|
throw new Error(`debug server not ready: ${url}`);
|
||||||
|
}
|
||||||
|
|
||||||
expect(payload.remoteDiagnostics).toEqual({
|
afterEach(() => {
|
||||||
allowlist: ["192.0.2.0/24"],
|
stopDebugServer();
|
||||||
port: 8976,
|
while (tempDirs.length > 0) {
|
||||||
hostMode: "network"
|
const dir = tempDirs.pop();
|
||||||
});
|
if (!dir) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
fs.rmSync(dir, { recursive: true, force: true });
|
||||||
|
} catch {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("backup mcpRemote export gating", () => {
|
||||||
|
it("includes mcpRemote when backupIncludeMcp is on", () => {
|
||||||
|
const section: BackupMcpRemote = { allowlist: ["10.0.0.5", "192.168.1.0/24"], port: 9999, hostMode: "network" };
|
||||||
|
const payload = buildBackupPayload(input({ backupIncludeMcp: true }, section));
|
||||||
|
expect(payload.mcpRemote).toEqual(section);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("omits remoteDiagnostics when the toggle is off even if a section is provided", () => {
|
it("omits mcpRemote when the toggle is off even if a section is provided", () => {
|
||||||
const payload = buildBackupPayload(input({ backupIncludeRemoteDiagnostics: false }, { allowlist: ["10.0.0.5"], port: 9868, hostMode: "network" }));
|
const payload = buildBackupPayload(input({ backupIncludeMcp: false }, { allowlist: ["10.0.0.5"], port: 9868, hostMode: "network" }));
|
||||||
expect(payload.remoteDiagnostics).toBeUndefined();
|
expect(payload.mcpRemote).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("omits remoteDiagnostics when toggle on but no section gathered", () => {
|
it("omits mcpRemote when toggle on but no section gathered", () => {
|
||||||
const payload = buildBackupPayload(input({ backupIncludeRemoteDiagnostics: true }, undefined));
|
const payload = buildBackupPayload(input({ backupIncludeMcp: true }, undefined));
|
||||||
expect(payload.remoteDiagnostics).toBeUndefined();
|
expect(payload.mcpRemote).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("the remoteDiagnostics section carries ONLY allowlist/port/hostMode (no token, publicHost, name)", () => {
|
it("the mcpRemote section carries ONLY allowlist/port/hostMode (no token, publicHost, name)", () => {
|
||||||
const payload = buildBackupPayload(input({ backupIncludeRemoteDiagnostics: true }, { allowlist: ["10.0.0.5"], port: 9868, hostMode: "network" }));
|
const payload = buildBackupPayload(input({ backupIncludeMcp: true }, { allowlist: ["10.0.0.5"], port: 9868, hostMode: "network" }));
|
||||||
expect(payload.remoteDiagnostics && Object.keys(payload.remoteDiagnostics).sort()).toEqual(["allowlist", "hostMode", "port"]);
|
expect(payload.mcpRemote && Object.keys(payload.mcpRemote).sort()).toEqual(["allowlist", "hostMode", "port"]);
|
||||||
const sectionJson = JSON.stringify(payload.remoteDiagnostics);
|
const sectionJson = JSON.stringify(payload.mcpRemote);
|
||||||
expect(sectionJson.toLowerCase()).not.toContain("token");
|
expect(sectionJson.toLowerCase()).not.toContain("token");
|
||||||
expect(sectionJson).not.toContain("publicHost");
|
expect(sectionJson).not.toContain("publicHost");
|
||||||
expect(sectionJson.toLowerCase()).not.toContain("\"name\"");
|
expect(sectionJson.toLowerCase()).not.toContain("\"name\"");
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("backupIncludeRemoteDiagnostics settings persistence", () => {
|
|
||||||
it("normalizeSettings preserves backupIncludeRemoteDiagnostics (the toggle survives save/load)", () => {
|
|
||||||
expect(normalizeSettings({ backupIncludeRemoteDiagnostics: true } as unknown as AppSettings).backupIncludeRemoteDiagnostics).toBe(true);
|
|
||||||
expect(normalizeSettings({ backupIncludeRemoteDiagnostics: false } as unknown as AppSettings).backupIncludeRemoteDiagnostics).toBe(false);
|
|
||||||
expect(normalizeSettings({} as unknown as AppSettings).backupIncludeRemoteDiagnostics).toBe(false);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("resolveRemoteDiagnosticsRestore", () => {
|
describe("backupIncludeMcp settings persistence", () => {
|
||||||
|
it("normalizeSettings preserves backupIncludeMcp (the toggle survives save/load)", () => {
|
||||||
|
expect(normalizeSettings({ backupIncludeMcp: true } as unknown as AppSettings).backupIncludeMcp).toBe(true);
|
||||||
|
expect(normalizeSettings({ backupIncludeMcp: false } as unknown as AppSettings).backupIncludeMcp).toBe(false);
|
||||||
|
expect(normalizeSettings({} as unknown as AppSettings).backupIncludeMcp).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("resolveMcpRemoteRestore", () => {
|
||||||
it("maps network + non-empty allowlist to 0.0.0.0", () => {
|
it("maps network + non-empty allowlist to 0.0.0.0", () => {
|
||||||
expect(resolveRemoteDiagnosticsRestore({ allowlist: ["10.0.0.5"], port: 9868, hostMode: "network" }))
|
expect(resolveMcpRemoteRestore({ allowlist: ["10.0.0.5"], port: 9868, hostMode: "network" }))
|
||||||
.toEqual({ host: "0.0.0.0", port: 9868, allowlist: ["10.0.0.5"] });
|
.toEqual({ host: "0.0.0.0", port: 9868, allowlist: ["10.0.0.5"] });
|
||||||
});
|
});
|
||||||
|
|
||||||
it("SAFETY: network with EMPTY allowlist binds local, never 0.0.0.0", () => {
|
it("SAFETY: network with EMPTY allowlist binds local, never 0.0.0.0", () => {
|
||||||
expect(resolveRemoteDiagnosticsRestore({ allowlist: [], port: 9868, hostMode: "network" })?.host).toBe("127.0.0.1");
|
expect(resolveMcpRemoteRestore({ allowlist: [], port: 9868, hostMode: "network" })?.host).toBe("127.0.0.1");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("maps local to 127.0.0.1", () => {
|
it("maps local to 127.0.0.1", () => {
|
||||||
expect(resolveRemoteDiagnosticsRestore({ allowlist: ["10.0.0.5"], port: 9868, hostMode: "local" })?.host).toBe("127.0.0.1");
|
expect(resolveMcpRemoteRestore({ allowlist: ["10.0.0.5"], port: 9868, hostMode: "local" })?.host).toBe("127.0.0.1");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("rejects an out-of-range or non-integer port", () => {
|
it("rejects an out-of-range or non-integer port", () => {
|
||||||
expect(resolveRemoteDiagnosticsRestore({ allowlist: ["10.0.0.5"], port: 80, hostMode: "network" })?.port).toBeUndefined();
|
expect(resolveMcpRemoteRestore({ allowlist: ["10.0.0.5"], port: 80, hostMode: "network" })?.port).toBeUndefined();
|
||||||
expect(resolveRemoteDiagnosticsRestore({ allowlist: ["10.0.0.5"], port: 70000, hostMode: "network" })?.port).toBeUndefined();
|
expect(resolveMcpRemoteRestore({ allowlist: ["10.0.0.5"], port: 70000, hostMode: "network" })?.port).toBeUndefined();
|
||||||
expect(resolveRemoteDiagnosticsRestore({ allowlist: ["10.0.0.5"], port: 9868.5, hostMode: "network" })?.port).toBeUndefined();
|
expect(resolveMcpRemoteRestore({ allowlist: ["10.0.0.5"], port: 9868.5, hostMode: "network" })?.port).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("filters non-string and blank allowlist entries and trims", () => {
|
it("filters non-string and blank allowlist entries and trims", () => {
|
||||||
const r = resolveRemoteDiagnosticsRestore({ allowlist: ["10.0.0.5", "", " ", 5, null, " 8.8.8.8 "], port: 9868, hostMode: "network" });
|
const r = resolveMcpRemoteRestore({ allowlist: ["10.0.0.5", "", " ", 5, null, " 8.8.8.8 "], port: 9868, hostMode: "network" });
|
||||||
expect(r?.allowlist).toEqual(["10.0.0.5", "8.8.8.8"]);
|
expect(r?.allowlist).toEqual(["10.0.0.5", "8.8.8.8"]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns null for missing or empty/invalid sections", () => {
|
it("returns null for missing or empty/invalid sections", () => {
|
||||||
expect(resolveRemoteDiagnosticsRestore(undefined)).toBeNull();
|
expect(resolveMcpRemoteRestore(undefined)).toBeNull();
|
||||||
expect(resolveRemoteDiagnosticsRestore(null)).toBeNull();
|
expect(resolveMcpRemoteRestore(null)).toBeNull();
|
||||||
expect(resolveRemoteDiagnosticsRestore("x")).toBeNull();
|
expect(resolveMcpRemoteRestore("x")).toBeNull();
|
||||||
expect(resolveRemoteDiagnosticsRestore({})).toBeNull();
|
expect(resolveMcpRemoteRestore({})).toBeNull();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("backup remoteDiagnostics live restore round-trip", () => {
|
describe("backup mcpRemote live restore round-trip", () => {
|
||||||
it("export -> resolve -> apply is reflected in the running debug-server (proves restart fired)", async () => {
|
it("export -> resolve -> apply is reflected in the running debug-server (proves restart fired)", async () => {
|
||||||
const baseDir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-backup-remote-"));
|
const baseDir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-bkmcp-"));
|
||||||
tempDirs.push(baseDir);
|
tempDirs.push(baseDir);
|
||||||
const startPort = await getFreePort();
|
const startPort = await getFreePort();
|
||||||
const restorePort = await getFreePort();
|
const restorePort = await getFreePort();
|
||||||
fs.writeFileSync(path.join(baseDir, "debug_token.txt"), "rt-secret", "utf8");
|
fs.writeFileSync(path.join(baseDir, "debug_token.txt"), "rt-secret", "utf8");
|
||||||
fs.writeFileSync(path.join(baseDir, "debug_port.txt"), String(startPort), "utf8");
|
fs.writeFileSync(path.join(baseDir, "debug_port.txt"), String(startPort), "utf8");
|
||||||
fs.writeFileSync(path.join(baseDir, "debug_host.txt"), "127.0.0.1", "utf8");
|
fs.writeFileSync(path.join(baseDir, "debug_host.txt"), "127.0.0.1", "utf8");
|
||||||
fs.writeFileSync(path.join(baseDir, "debug_allowlist.txt"), "", "utf8");
|
fs.writeFileSync(path.join(baseDir, "debug_allowlist.txt"), "", "utf8");
|
||||||
startDebugServer({} as unknown as DownloadManager, baseDir);
|
startDebugServer({} as unknown as DownloadManager, baseDir);
|
||||||
await waitForReady(`http://127.0.0.1:${startPort}/health?token=rt-secret`);
|
await waitForReady(`http://127.0.0.1:${startPort}/health?token=rt-secret`);
|
||||||
expect(getDebugAllowlist()).toEqual([]);
|
expect(getDebugAllowlist()).toEqual([]);
|
||||||
|
|
||||||
const payload = buildBackupPayload(input(
|
const payload = buildBackupPayload(input(
|
||||||
{ backupIncludeRemoteDiagnostics: true },
|
{ backupIncludeMcp: true },
|
||||||
{ allowlist: ["203.0.113.4", "10.0.0.0/24"], port: restorePort, hostMode: "network" }
|
{ allowlist: ["203.0.113.4", "10.0.0.0/24"], port: restorePort, hostMode: "network" }
|
||||||
));
|
));
|
||||||
|
|
||||||
const restore = resolveRemoteDiagnosticsRestore(payload.remoteDiagnostics);
|
const restore = resolveMcpRemoteRestore(payload.mcpRemote);
|
||||||
expect(restore).not.toBeNull();
|
expect(restore).not.toBeNull();
|
||||||
writeDebugServerConfig({ host: restore!.host, port: restore!.port, allowlist: restore!.allowlist });
|
writeDebugServerConfig({ host: restore!.host, port: restore!.port, allowlist: restore!.allowlist });
|
||||||
const status = await restartDebugServer();
|
const status = await restartDebugServer();
|
||||||
|
|
||||||
expect(getDebugAllowlist()).toEqual(["203.0.113.4", "10.0.0.0/24"]);
|
expect(getDebugAllowlist()).toEqual(["203.0.113.4", "10.0.0.0/24"]);
|
||||||
expect(status.port).toBe(restorePort);
|
expect(status.port).toBe(restorePort);
|
||||||
expect(status.host).toBe("0.0.0.0");
|
expect(status.host).toBe("0.0.0.0");
|
||||||
expect(status.allowlistCount).toBe(2);
|
expect(status.allowlistCount).toBe(2);
|
||||||
|
|
||||||
expect(fs.readFileSync(path.join(baseDir, "debug_token.txt"), "utf8").trim()).toBe("rt-secret");
|
expect(fs.readFileSync(path.join(baseDir, "debug_token.txt"), "utf8").trim()).toBe("rt-secret");
|
||||||
expect(fs.existsSync(path.join(baseDir, "debug_remote.json"))).toBe(false);
|
expect(fs.existsSync(path.join(baseDir, "debug_remote.json"))).toBe(false);
|
||||||
|
|
||||||
await waitForReady(`http://127.0.0.1:${restorePort}/health?token=rt-secret`);
|
await waitForReady(`http://127.0.0.1:${restorePort}/health?token=rt-secret`);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("full-backup path writes the debug_* files to disk without a restart (boot picks them up)", async () => {
|
it("full-backup path writes the debug_* files to disk without a restart (boot picks them up)", async () => {
|
||||||
const baseDir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-backup-remote2-"));
|
const baseDir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-bkmcp2-"));
|
||||||
tempDirs.push(baseDir);
|
tempDirs.push(baseDir);
|
||||||
const startPort = await getFreePort();
|
const startPort = await getFreePort();
|
||||||
fs.writeFileSync(path.join(baseDir, "debug_token.txt"), "rt2", "utf8");
|
fs.writeFileSync(path.join(baseDir, "debug_token.txt"), "rt2", "utf8");
|
||||||
fs.writeFileSync(path.join(baseDir, "debug_port.txt"), String(startPort), "utf8");
|
fs.writeFileSync(path.join(baseDir, "debug_port.txt"), String(startPort), "utf8");
|
||||||
fs.writeFileSync(path.join(baseDir, "debug_host.txt"), "127.0.0.1", "utf8");
|
fs.writeFileSync(path.join(baseDir, "debug_host.txt"), "127.0.0.1", "utf8");
|
||||||
fs.writeFileSync(path.join(baseDir, "debug_allowlist.txt"), "", "utf8");
|
fs.writeFileSync(path.join(baseDir, "debug_allowlist.txt"), "", "utf8");
|
||||||
startDebugServer({} as unknown as DownloadManager, baseDir);
|
startDebugServer({} as unknown as DownloadManager, baseDir);
|
||||||
await waitForReady(`http://127.0.0.1:${startPort}/health?token=rt2`);
|
await waitForReady(`http://127.0.0.1:${startPort}/health?token=rt2`);
|
||||||
|
|
||||||
const restore = resolveRemoteDiagnosticsRestore({ allowlist: ["198.51.100.9"], port: 9100, hostMode: "network" });
|
const restore = resolveMcpRemoteRestore({ allowlist: ["198.51.100.9"], port: 9100, hostMode: "network" });
|
||||||
writeDebugServerConfig({ host: restore!.host, port: restore!.port, allowlist: restore!.allowlist });
|
writeDebugServerConfig({ host: restore!.host, port: restore!.port, allowlist: restore!.allowlist });
|
||||||
|
|
||||||
expect(fs.readFileSync(path.join(baseDir, "debug_host.txt"), "utf8").trim()).toBe("0.0.0.0");
|
expect(fs.readFileSync(path.join(baseDir, "debug_host.txt"), "utf8").trim()).toBe("0.0.0.0");
|
||||||
expect(fs.readFileSync(path.join(baseDir, "debug_port.txt"), "utf8").trim()).toBe("9100");
|
expect(fs.readFileSync(path.join(baseDir, "debug_port.txt"), "utf8").trim()).toBe("9100");
|
||||||
expect(fs.readFileSync(path.join(baseDir, "debug_allowlist.txt"), "utf8")).toContain("198.51.100.9");
|
expect(fs.readFileSync(path.join(baseDir, "debug_allowlist.txt"), "utf8")).toContain("198.51.100.9");
|
||||||
expect(getDebugServerRuntimeStatus().port).toBe(startPort);
|
expect(getDebugServerRuntimeStatus().port).toBe(startPort);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("debug-server live diagnostics endpoints", () => {
|
describe("debug-server live diagnostics endpoints", () => {
|
||||||
it("serves /providers (live cooldown/runtime snapshot) and /logs/conversion over authenticated HTTP", async () => {
|
it("serves /providers (live cooldown/runtime snapshot) and /logs/conversion over authenticated HTTP", async () => {
|
||||||
const baseDir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-prov-"));
|
const baseDir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-prov-"));
|
||||||
tempDirs.push(baseDir);
|
tempDirs.push(baseDir);
|
||||||
const port = await getFreePort();
|
const port = await getFreePort();
|
||||||
fs.writeFileSync(path.join(baseDir, "debug_token.txt"), "prov-secret", "utf8");
|
fs.writeFileSync(path.join(baseDir, "debug_token.txt"), "prov-secret", "utf8");
|
||||||
fs.writeFileSync(path.join(baseDir, "debug_port.txt"), String(port), "utf8");
|
fs.writeFileSync(path.join(baseDir, "debug_port.txt"), String(port), "utf8");
|
||||||
fs.writeFileSync(path.join(baseDir, "debug_host.txt"), "127.0.0.1", "utf8");
|
fs.writeFileSync(path.join(baseDir, "debug_host.txt"), "127.0.0.1", "utf8");
|
||||||
fs.writeFileSync(path.join(baseDir, "debug_allowlist.txt"), "", "utf8");
|
fs.writeFileSync(path.join(baseDir, "debug_allowlist.txt"), "", "utf8");
|
||||||
startDebugServer({} as unknown as DownloadManager, baseDir);
|
startDebugServer({} as unknown as DownloadManager, baseDir);
|
||||||
await waitForReady(`http://127.0.0.1:${port}/health?token=prov-secret`);
|
await waitForReady(`http://127.0.0.1:${port}/health?token=prov-secret`);
|
||||||
|
|
||||||
const provRes = await fetch(`http://127.0.0.1:${port}/providers?token=prov-secret`);
|
const provRes = await fetch(`http://127.0.0.1:${port}/providers?token=prov-secret`);
|
||||||
expect(provRes.status).toBe(200);
|
expect(provRes.status).toBe(200);
|
||||||
const prov = await provRes.json();
|
const prov = await provRes.json();
|
||||||
expect(typeof prov.capturedAtMs).toBe("number");
|
expect(typeof prov.capturedAtMs).toBe("number");
|
||||||
expect(prov.megaDebrid).toBeTruthy();
|
expect(prov.megaDebrid).toBeTruthy();
|
||||||
expect(Array.isArray(prov.megaDebrid.accounts)).toBe(true);
|
expect(Array.isArray(prov.megaDebrid.accounts)).toBe(true);
|
||||||
expect(typeof prov.megaDebrid.rotationCursor).toBe("number");
|
expect(typeof prov.megaDebrid.rotationCursor).toBe("number");
|
||||||
expect(prov.debridLink).toBeTruthy();
|
expect(prov.debridLink).toBeTruthy();
|
||||||
expect(Array.isArray(prov.debridLink.keys)).toBe(true);
|
expect(Array.isArray(prov.debridLink.keys)).toBe(true);
|
||||||
|
|
||||||
const unauth = await fetch(`http://127.0.0.1:${port}/providers`);
|
const unauth = await fetch(`http://127.0.0.1:${port}/providers`);
|
||||||
expect(unauth.status).toBe(401);
|
expect(unauth.status).toBe(401);
|
||||||
|
|
||||||
const convRes = await fetch(`http://127.0.0.1:${port}/logs/conversion?token=prov-secret`);
|
const convRes = await fetch(`http://127.0.0.1:${port}/logs/conversion?token=prov-secret`);
|
||||||
expect(convRes.status).toBe(200);
|
expect(convRes.status).toBe(200);
|
||||||
const conv = await convRes.json();
|
const conv = await convRes.json();
|
||||||
expect(Array.isArray(conv.lines)).toBe(true);
|
expect(Array.isArray(conv.lines)).toBe(true);
|
||||||
expect(conv).toHaveProperty("available");
|
expect(conv).toHaveProperty("available");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@ -1,81 +1,81 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import { buildBackupPayload, planBackupImport } from "../src/main/backup-payload";
|
import { buildBackupPayload, planBackupImport } from "../src/main/backup-payload";
|
||||||
import type { AppSettings, SessionState, HistoryEntry } from "../src/shared/types";
|
import type { AppSettings, SessionState, HistoryEntry } from "../src/shared/types";
|
||||||
|
|
||||||
function settings(overrides: Partial<AppSettings> = {}): AppSettings {
|
function settings(overrides: Partial<AppSettings> = {}): AppSettings {
|
||||||
return { backupIncludeDownloads: false, token: "secret", outputDir: "C:\\dl" } as unknown as AppSettings;
|
return { backupIncludeDownloads: false, token: "secret", outputDir: "C:\\dl" } as unknown as AppSettings;
|
||||||
}
|
}
|
||||||
|
|
||||||
const session: SessionState = {
|
const session: SessionState = {
|
||||||
version: 2, packageOrder: ["p1"], packages: { p1: {} as never }, items: { i1: {} as never },
|
version: 2, packageOrder: ["p1"], packages: { p1: {} as never }, items: { i1: {} as never },
|
||||||
runStartedAt: 0, totalDownloadedBytes: 0, summaryText: "", reconnectUntil: 0,
|
runStartedAt: 0, totalDownloadedBytes: 0, summaryText: "", reconnectUntil: 0,
|
||||||
reconnectReason: "", paused: false, running: true, updatedAt: 0
|
reconnectReason: "", paused: false, running: true, updatedAt: 0
|
||||||
};
|
};
|
||||||
const history: HistoryEntry[] = [{ id: "h1" } as unknown as HistoryEntry];
|
const history: HistoryEntry[] = [{ id: "h1" } as unknown as HistoryEntry];
|
||||||
|
|
||||||
const baseInput = { appVersion: "1.7.183", exportedAt: "2026-06-07T00:00:00Z", session, history };
|
const baseInput = { appVersion: "1.7.183", exportedAt: "2026-06-07T00:00:00Z", session, history };
|
||||||
|
|
||||||
describe("buildBackupPayload — default is settings-only", () => {
|
describe("buildBackupPayload — default is settings-only", () => {
|
||||||
it("omits session AND history when backupIncludeDownloads is false (default)", () => {
|
it("omits session AND history when backupIncludeDownloads is false (default)", () => {
|
||||||
const p = buildBackupPayload({ ...baseInput, settings: { backupIncludeDownloads: false } as AppSettings });
|
const p = buildBackupPayload({ ...baseInput, settings: { backupIncludeDownloads: false } as AppSettings });
|
||||||
expect(p.kind).toBe("settings-only");
|
expect(p.kind).toBe("settings-only");
|
||||||
expect(p.session).toBeUndefined();
|
expect(p.session).toBeUndefined();
|
||||||
expect(p.history).toBeUndefined();
|
expect(p.history).toBeUndefined();
|
||||||
expect(p.settings).toBeDefined();
|
expect(p.settings).toBeDefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("includes session + history when backupIncludeDownloads is true", () => {
|
it("includes session + history when backupIncludeDownloads is true", () => {
|
||||||
const p = buildBackupPayload({ ...baseInput, settings: { backupIncludeDownloads: true } as AppSettings });
|
const p = buildBackupPayload({ ...baseInput, settings: { backupIncludeDownloads: true } as AppSettings });
|
||||||
expect(p.kind).toBe("full");
|
expect(p.kind).toBe("full");
|
||||||
expect(p.session).toBe(session);
|
expect(p.session).toBe(session);
|
||||||
expect(p.history).toBe(history);
|
expect(p.history).toBe(history);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("treats a missing flag as settings-only (safe default)", () => {
|
it("treats a missing flag as settings-only (safe default)", () => {
|
||||||
const p = buildBackupPayload({ ...baseInput, settings: {} as AppSettings });
|
const p = buildBackupPayload({ ...baseInput, settings: {} as AppSettings });
|
||||||
expect(p.kind).toBe("settings-only");
|
expect(p.kind).toBe("settings-only");
|
||||||
expect(p.session).toBeUndefined();
|
expect(p.session).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("ROUND-TRIP: toggle off -> exported payload carries the flag still false", () => {
|
it("ROUND-TRIP: toggle off -> exported payload carries the flag still false", () => {
|
||||||
// "Haken aus bleibt aus": the exported settings object preserves the flag,
|
// "Haken aus bleibt aus": the exported settings object preserves the flag,
|
||||||
// so importing it keeps the toggle off.
|
// so importing it keeps the toggle off.
|
||||||
const p = buildBackupPayload({ ...baseInput, settings: { backupIncludeDownloads: false } as AppSettings });
|
const p = buildBackupPayload({ ...baseInput, settings: { backupIncludeDownloads: false } as AppSettings });
|
||||||
expect((p.settings as AppSettings).backupIncludeDownloads).toBe(false);
|
expect((p.settings as AppSettings).backupIncludeDownloads).toBe(false);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("planBackupImport — decision follows the file, not the local toggle", () => {
|
describe("planBackupImport — decision follows the file, not the local toggle", () => {
|
||||||
it("settings-only backup (no session) -> restore settings only, no relaunch", () => {
|
it("settings-only backup (no session) -> restore settings only, no relaunch", () => {
|
||||||
const plan = planBackupImport({ version: 2, kind: "settings-only", settings: { theme: "dark" } });
|
const plan = planBackupImport({ version: 2, kind: "settings-only", settings: { theme: "dark" } });
|
||||||
expect(plan.valid).toBe(true);
|
expect(plan.valid).toBe(true);
|
||||||
expect(plan.restoreDownloads).toBe(false);
|
expect(plan.restoreDownloads).toBe(false);
|
||||||
expect(plan.message).toMatch(/Einstellungen/);
|
expect(plan.message).toMatch(/Einstellungen/);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("full backup (with session) -> restore downloads + relaunch", () => {
|
it("full backup (with session) -> restore downloads + relaunch", () => {
|
||||||
const plan = planBackupImport({ version: 2, kind: "full", settings: { theme: "dark" }, session });
|
const plan = planBackupImport({ version: 2, kind: "full", settings: { theme: "dark" }, session });
|
||||||
expect(plan.valid).toBe(true);
|
expect(plan.valid).toBe(true);
|
||||||
expect(plan.restoreDownloads).toBe(true);
|
expect(plan.restoreDownloads).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("rejects payloads without settings", () => {
|
it("rejects payloads without settings", () => {
|
||||||
expect(planBackupImport({ session }).valid).toBe(false);
|
expect(planBackupImport({ session }).valid).toBe(false);
|
||||||
expect(planBackupImport(null).valid).toBe(false);
|
expect(planBackupImport(null).valid).toBe(false);
|
||||||
expect(planBackupImport("nope").valid).toBe(false);
|
expect(planBackupImport("nope").valid).toBe(false);
|
||||||
expect(planBackupImport({}).valid).toBe(false);
|
expect(planBackupImport({}).valid).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("a settings-only export then import does NOT pull in the download list", () => {
|
it("a settings-only export then import does NOT pull in the download list", () => {
|
||||||
// Build with toggle off, then plan the import of exactly that payload.
|
// Build with toggle off, then plan the import of exactly that payload.
|
||||||
const exported = buildBackupPayload({ ...baseInput, settings: { backupIncludeDownloads: false } as AppSettings });
|
const exported = buildBackupPayload({ ...baseInput, settings: { backupIncludeDownloads: false } as AppSettings });
|
||||||
const plan = planBackupImport(JSON.parse(JSON.stringify(exported)));
|
const plan = planBackupImport(JSON.parse(JSON.stringify(exported)));
|
||||||
expect(plan.restoreDownloads).toBe(false); // queue stays untouched
|
expect(plan.restoreDownloads).toBe(false); // queue stays untouched
|
||||||
});
|
});
|
||||||
|
|
||||||
it("a full export then import DOES restore the download list", () => {
|
it("a full export then import DOES restore the download list", () => {
|
||||||
const exported = buildBackupPayload({ ...baseInput, settings: { backupIncludeDownloads: true } as AppSettings });
|
const exported = buildBackupPayload({ ...baseInput, settings: { backupIncludeDownloads: true } as AppSettings });
|
||||||
const plan = planBackupImport(JSON.parse(JSON.stringify(exported)));
|
const plan = planBackupImport(JSON.parse(JSON.stringify(exported)));
|
||||||
expect(plan.restoreDownloads).toBe(true);
|
expect(plan.restoreDownloads).toBe(true);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -1,167 +1,167 @@
|
|||||||
import fs from "node:fs";
|
import fs from "node:fs";
|
||||||
import os from "node:os";
|
import os from "node:os";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
const {
|
const {
|
||||||
mockCookiesSet,
|
mockCookiesSet,
|
||||||
mockFetch,
|
mockFetch,
|
||||||
mockClearStorageData,
|
mockClearStorageData,
|
||||||
mockClearCache,
|
mockClearCache,
|
||||||
mockFromPartition,
|
mockFromPartition,
|
||||||
mockSession
|
mockSession
|
||||||
} = vi.hoisted(() => {
|
} = vi.hoisted(() => {
|
||||||
const cookiesSet = vi.fn();
|
const cookiesSet = vi.fn();
|
||||||
const fetch = vi.fn();
|
const fetch = vi.fn();
|
||||||
const clearStorageData = vi.fn();
|
const clearStorageData = vi.fn();
|
||||||
const clearCache = vi.fn();
|
const clearCache = vi.fn();
|
||||||
const fromPartition = vi.fn();
|
const fromPartition = vi.fn();
|
||||||
return {
|
return {
|
||||||
mockCookiesSet: cookiesSet,
|
mockCookiesSet: cookiesSet,
|
||||||
mockFetch: fetch,
|
mockFetch: fetch,
|
||||||
mockClearStorageData: clearStorageData,
|
mockClearStorageData: clearStorageData,
|
||||||
mockClearCache: clearCache,
|
mockClearCache: clearCache,
|
||||||
mockFromPartition: fromPartition,
|
mockFromPartition: fromPartition,
|
||||||
mockSession: {
|
mockSession: {
|
||||||
cookies: {
|
cookies: {
|
||||||
set: cookiesSet
|
set: cookiesSet
|
||||||
},
|
},
|
||||||
fetch,
|
fetch,
|
||||||
clearStorageData,
|
clearStorageData,
|
||||||
clearCache
|
clearCache
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
vi.mock("electron", () => ({
|
vi.mock("electron", () => ({
|
||||||
session: {
|
session: {
|
||||||
fromPartition: mockFromPartition
|
fromPartition: mockFromPartition
|
||||||
}
|
}
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("../src/main/logger", () => ({
|
vi.mock("../src/main/logger", () => ({
|
||||||
logger: {
|
logger: {
|
||||||
info: vi.fn(),
|
info: vi.fn(),
|
||||||
warn: vi.fn(),
|
warn: vi.fn(),
|
||||||
error: vi.fn()
|
error: vi.fn()
|
||||||
}
|
}
|
||||||
}));
|
}));
|
||||||
|
|
||||||
import { BestDebridWebFallback } from "../src/main/bestdebrid-web";
|
import { BestDebridWebFallback } from "../src/main/bestdebrid-web";
|
||||||
|
|
||||||
function createCookieFile(contents: string): string {
|
function createCookieFile(contents: string): string {
|
||||||
const filePath = path.join(os.tmpdir(), `bestdebrid-cookies-${Date.now()}-${Math.random().toString(16).slice(2)}.txt`);
|
const filePath = path.join(os.tmpdir(), `bestdebrid-cookies-${Date.now()}-${Math.random().toString(16).slice(2)}.txt`);
|
||||||
fs.writeFileSync(filePath, contents, "utf8");
|
fs.writeFileSync(filePath, contents, "utf8");
|
||||||
return filePath;
|
return filePath;
|
||||||
}
|
}
|
||||||
|
|
||||||
describe("bestdebrid-web", () => {
|
describe("bestdebrid-web", () => {
|
||||||
const tempFiles: string[] = [];
|
const tempFiles: string[] = [];
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
mockFromPartition.mockReturnValue(mockSession);
|
mockFromPartition.mockReturnValue(mockSession);
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
mockFromPartition.mockReturnValue(mockSession);
|
mockFromPartition.mockReturnValue(mockSession);
|
||||||
while (tempFiles.length > 0) {
|
while (tempFiles.length > 0) {
|
||||||
const filePath = tempFiles.pop();
|
const filePath = tempFiles.pop();
|
||||||
if (!filePath) {
|
if (!filePath) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
fs.rmSync(filePath, { force: true });
|
fs.rmSync(filePath, { force: true });
|
||||||
} catch {
|
} catch {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
it("imports HttpOnly Netscape cookies instead of skipping them as comments", async () => {
|
it("imports HttpOnly Netscape cookies instead of skipping them as comments", async () => {
|
||||||
const filePath = createCookieFile([
|
const filePath = createCookieFile([
|
||||||
"# Netscape HTTP Cookie File",
|
"# Netscape HTTP Cookie File",
|
||||||
"#HttpOnly_.bestdebrid.com\tTRUE\t/\tTRUE\t1803585385\tPHPSESSID\tsecret-session",
|
"#HttpOnly_.bestdebrid.com\tTRUE\t/\tTRUE\t1803585385\tPHPSESSID\tsecret-session",
|
||||||
".bestdebrid.com\tTRUE\t/\tFALSE\t1806720721\t_ga\ttracking"
|
".bestdebrid.com\tTRUE\t/\tFALSE\t1806720721\t_ga\ttracking"
|
||||||
].join("\n"));
|
].join("\n"));
|
||||||
tempFiles.push(filePath);
|
tempFiles.push(filePath);
|
||||||
|
|
||||||
const fallback = new BestDebridWebFallback(() => true);
|
const fallback = new BestDebridWebFallback(() => true);
|
||||||
const count = await fallback.importCookiesFromFile(filePath);
|
const count = await fallback.importCookiesFromFile(filePath);
|
||||||
|
|
||||||
expect(count).toBe(2);
|
expect(count).toBe(2);
|
||||||
expect(mockClearStorageData).toHaveBeenCalledTimes(1);
|
expect(mockClearStorageData).toHaveBeenCalledTimes(1);
|
||||||
expect(mockClearStorageData).toHaveBeenCalledWith({
|
expect(mockClearStorageData).toHaveBeenCalledWith({
|
||||||
storages: ["cookies", "indexdb", "localstorage", "serviceworkers", "cachestorage"]
|
storages: ["cookies", "indexdb", "localstorage", "serviceworkers", "cachestorage"]
|
||||||
});
|
});
|
||||||
expect(mockCookiesSet).toHaveBeenCalledTimes(2);
|
expect(mockCookiesSet).toHaveBeenCalledTimes(2);
|
||||||
expect(mockCookiesSet).toHaveBeenCalledWith(expect.objectContaining({
|
expect(mockCookiesSet).toHaveBeenCalledWith(expect.objectContaining({
|
||||||
name: "PHPSESSID",
|
name: "PHPSESSID",
|
||||||
domain: ".bestdebrid.com",
|
domain: ".bestdebrid.com",
|
||||||
httpOnly: true,
|
httpOnly: true,
|
||||||
secure: true
|
secure: true
|
||||||
}));
|
}));
|
||||||
});
|
});
|
||||||
|
|
||||||
it("deduplicates conflicting session cookies and prefers the HttpOnly variant", async () => {
|
it("deduplicates conflicting session cookies and prefers the HttpOnly variant", async () => {
|
||||||
const filePath = createCookieFile([
|
const filePath = createCookieFile([
|
||||||
"# Netscape HTTP Cookie File",
|
"# Netscape HTTP Cookie File",
|
||||||
"bestdebrid.com\tFALSE\t/\tTRUE\t1803585384\tPHPSESSID\tnon-http-only",
|
"bestdebrid.com\tFALSE\t/\tTRUE\t1803585384\tPHPSESSID\tnon-http-only",
|
||||||
"#HttpOnly_.bestdebrid.com\tTRUE\t/\tTRUE\t1803585385\tPHPSESSID\thttp-only"
|
"#HttpOnly_.bestdebrid.com\tTRUE\t/\tTRUE\t1803585385\tPHPSESSID\thttp-only"
|
||||||
].join("\n"));
|
].join("\n"));
|
||||||
tempFiles.push(filePath);
|
tempFiles.push(filePath);
|
||||||
|
|
||||||
const fallback = new BestDebridWebFallback(() => true);
|
const fallback = new BestDebridWebFallback(() => true);
|
||||||
const count = await fallback.importCookiesFromFile(filePath);
|
const count = await fallback.importCookiesFromFile(filePath);
|
||||||
|
|
||||||
expect(count).toBe(1);
|
expect(count).toBe(1);
|
||||||
expect(mockCookiesSet).toHaveBeenCalledTimes(1);
|
expect(mockCookiesSet).toHaveBeenCalledTimes(1);
|
||||||
expect(mockCookiesSet).toHaveBeenCalledWith(expect.objectContaining({
|
expect(mockCookiesSet).toHaveBeenCalledWith(expect.objectContaining({
|
||||||
name: "PHPSESSID",
|
name: "PHPSESSID",
|
||||||
value: "http-only",
|
value: "http-only",
|
||||||
httpOnly: true,
|
httpOnly: true,
|
||||||
domain: ".bestdebrid.com"
|
domain: ".bestdebrid.com"
|
||||||
}));
|
}));
|
||||||
});
|
});
|
||||||
|
|
||||||
it("rejects cookie files that only contain tracking cookies", async () => {
|
it("rejects cookie files that only contain tracking cookies", async () => {
|
||||||
const filePath = createCookieFile([
|
const filePath = createCookieFile([
|
||||||
"# Netscape HTTP Cookie File",
|
"# Netscape HTTP Cookie File",
|
||||||
".bestdebrid.com\tTRUE\t/\tTRUE\t1803585385\t__stripe_mid\tstripe",
|
".bestdebrid.com\tTRUE\t/\tTRUE\t1803585385\t__stripe_mid\tstripe",
|
||||||
".bestdebrid.com\tTRUE\t/\tFALSE\t1806720721\t_ga\ttracking"
|
".bestdebrid.com\tTRUE\t/\tFALSE\t1806720721\t_ga\ttracking"
|
||||||
].join("\n"));
|
].join("\n"));
|
||||||
tempFiles.push(filePath);
|
tempFiles.push(filePath);
|
||||||
|
|
||||||
const fallback = new BestDebridWebFallback(() => true);
|
const fallback = new BestDebridWebFallback(() => true);
|
||||||
|
|
||||||
await expect(fallback.importCookiesFromFile(filePath))
|
await expect(fallback.importCookiesFromFile(filePath))
|
||||||
.rejects.toThrow("Login-Cookie");
|
.rejects.toThrow("Login-Cookie");
|
||||||
expect(mockCookiesSet).not.toHaveBeenCalled();
|
expect(mockCookiesSet).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("treats BestDebrid free-user errors as logged-out sessions when the account page is guest-only", async () => {
|
it("treats BestDebrid free-user errors as logged-out sessions when the account page is guest-only", async () => {
|
||||||
const filePath = createCookieFile([
|
const filePath = createCookieFile([
|
||||||
"# Netscape HTTP Cookie File",
|
"# Netscape HTTP Cookie File",
|
||||||
"bestdebrid.com\tFALSE\t/\tTRUE\t1803585385\tPHPSESSID\tsecret-session"
|
"bestdebrid.com\tFALSE\t/\tTRUE\t1803585385\tPHPSESSID\tsecret-session"
|
||||||
].join("\n"));
|
].join("\n"));
|
||||||
tempFiles.push(filePath);
|
tempFiles.push(filePath);
|
||||||
|
|
||||||
mockFetch
|
mockFetch
|
||||||
.mockResolvedValueOnce(new Response(JSON.stringify({
|
.mockResolvedValueOnce(new Response(JSON.stringify({
|
||||||
error: 1,
|
error: 1,
|
||||||
message: "Free users are not allowed to download using a VPN or proxy. Please purchase a premium plan."
|
message: "Free users are not allowed to download using a VPN or proxy. Please purchase a premium plan."
|
||||||
}), { status: 200 }))
|
}), { status: 200 }))
|
||||||
.mockResolvedValueOnce(new Response("<div class=\"font-medium\">Guest</div>", { status: 200 }));
|
.mockResolvedValueOnce(new Response("<div class=\"font-medium\">Guest</div>", { status: 200 }));
|
||||||
|
|
||||||
const fallback = new BestDebridWebFallback(() => true);
|
const fallback = new BestDebridWebFallback(() => true);
|
||||||
await fallback.importCookiesFromFile(filePath);
|
await fallback.importCookiesFromFile(filePath);
|
||||||
|
|
||||||
await expect(fallback.unrestrict("https://1fichier.com/?abc"))
|
await expect(fallback.unrestrict("https://1fichier.com/?abc"))
|
||||||
.rejects.toThrow("Nicht eingeloggt");
|
.rejects.toThrow("Nicht eingeloggt");
|
||||||
await expect(fallback.unrestrict("https://1fichier.com/?abc"))
|
await expect(fallback.unrestrict("https://1fichier.com/?abc"))
|
||||||
.rejects.toThrow("Keine Cookies importiert");
|
.rejects.toThrow("Keine Cookies importiert");
|
||||||
|
|
||||||
expect(mockFetch).toHaveBeenCalledTimes(2);
|
expect(mockFetch).toHaveBeenCalledTimes(2);
|
||||||
expect(mockFetch.mock.calls[0]?.[0]).toBe("https://bestdebrid.com/api/v1/generateLink");
|
expect(mockFetch.mock.calls[0]?.[0]).toBe("https://bestdebrid.com/api/v1/generateLink");
|
||||||
expect(mockFetch.mock.calls[1]?.[0]).toBe("https://bestdebrid.com/en/downloader/");
|
expect(mockFetch.mock.calls[1]?.[0]).toBe("https://bestdebrid.com/en/downloader/");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -1,100 +1,100 @@
|
|||||||
import fs from "node:fs";
|
import fs from "node:fs";
|
||||||
import os from "node:os";
|
import os from "node:os";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { afterEach, describe, expect, it } from "vitest";
|
import { afterEach, describe, expect, it } from "vitest";
|
||||||
import { cleanupCancelledPackageArtifacts, removeDownloadLinkArtifacts, removeSampleArtifacts } from "../src/main/cleanup";
|
import { cleanupCancelledPackageArtifacts, removeDownloadLinkArtifacts, removeSampleArtifacts } from "../src/main/cleanup";
|
||||||
|
|
||||||
const tempDirs: string[] = [];
|
const tempDirs: string[] = [];
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
for (const dir of tempDirs.splice(0)) {
|
for (const dir of tempDirs.splice(0)) {
|
||||||
fs.rmSync(dir, { recursive: true, force: true });
|
fs.rmSync(dir, { recursive: true, force: true });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("cleanup", () => {
|
describe("cleanup", () => {
|
||||||
it("removes archive artifacts but keeps media", () => {
|
it("removes archive artifacts but keeps media", () => {
|
||||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-clean-"));
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-clean-"));
|
||||||
tempDirs.push(dir);
|
tempDirs.push(dir);
|
||||||
fs.writeFileSync(path.join(dir, "release.part1.rar"), "x");
|
fs.writeFileSync(path.join(dir, "release.part1.rar"), "x");
|
||||||
fs.writeFileSync(path.join(dir, "movie.mkv"), "x");
|
fs.writeFileSync(path.join(dir, "movie.mkv"), "x");
|
||||||
|
|
||||||
const removed = cleanupCancelledPackageArtifacts(dir);
|
const removed = cleanupCancelledPackageArtifacts(dir);
|
||||||
expect(removed).toBeGreaterThan(0);
|
expect(removed).toBeGreaterThan(0);
|
||||||
expect(fs.existsSync(path.join(dir, "release.part1.rar"))).toBe(false);
|
expect(fs.existsSync(path.join(dir, "release.part1.rar"))).toBe(false);
|
||||||
expect(fs.existsSync(path.join(dir, "movie.mkv"))).toBe(true);
|
expect(fs.existsSync(path.join(dir, "movie.mkv"))).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("removes sample artifacts and link files", async () => {
|
it("removes sample artifacts and link files", async () => {
|
||||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-clean-"));
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-clean-"));
|
||||||
tempDirs.push(dir);
|
tempDirs.push(dir);
|
||||||
fs.mkdirSync(path.join(dir, "Samples"), { recursive: true });
|
fs.mkdirSync(path.join(dir, "Samples"), { recursive: true });
|
||||||
fs.writeFileSync(path.join(dir, "Samples", "demo-sample.mkv"), "x");
|
fs.writeFileSync(path.join(dir, "Samples", "demo-sample.mkv"), "x");
|
||||||
fs.writeFileSync(path.join(dir, "download_links.txt"), "https://example.com/a\n");
|
fs.writeFileSync(path.join(dir, "download_links.txt"), "https://example.com/a\n");
|
||||||
|
|
||||||
const links = await removeDownloadLinkArtifacts(dir);
|
const links = await removeDownloadLinkArtifacts(dir);
|
||||||
const samples = await removeSampleArtifacts(dir);
|
const samples = await removeSampleArtifacts(dir);
|
||||||
expect(links).toBeGreaterThan(0);
|
expect(links).toBeGreaterThan(0);
|
||||||
expect(samples.files + samples.dirs).toBeGreaterThan(0);
|
expect(samples.files + samples.dirs).toBeGreaterThan(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("cleans up archive files in nested directories", () => {
|
it("cleans up archive files in nested directories", () => {
|
||||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-clean-"));
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-clean-"));
|
||||||
tempDirs.push(dir);
|
tempDirs.push(dir);
|
||||||
|
|
||||||
const sub1 = path.join(dir, "season1");
|
const sub1 = path.join(dir, "season1");
|
||||||
const sub2 = path.join(dir, "season1", "extras");
|
const sub2 = path.join(dir, "season1", "extras");
|
||||||
fs.mkdirSync(sub2, { recursive: true });
|
fs.mkdirSync(sub2, { recursive: true });
|
||||||
|
|
||||||
fs.writeFileSync(path.join(sub1, "episode.part1.rar"), "x");
|
fs.writeFileSync(path.join(sub1, "episode.part1.rar"), "x");
|
||||||
fs.writeFileSync(path.join(sub1, "episode.part2.rar"), "x");
|
fs.writeFileSync(path.join(sub1, "episode.part2.rar"), "x");
|
||||||
fs.writeFileSync(path.join(sub2, "bonus.zip"), "x");
|
fs.writeFileSync(path.join(sub2, "bonus.zip"), "x");
|
||||||
fs.writeFileSync(path.join(sub2, "bonus.7z"), "x");
|
fs.writeFileSync(path.join(sub2, "bonus.7z"), "x");
|
||||||
fs.writeFileSync(path.join(sub1, "video.mkv"), "real content");
|
fs.writeFileSync(path.join(sub1, "video.mkv"), "real content");
|
||||||
fs.writeFileSync(path.join(sub2, "subtitle.srt"), "subtitle content");
|
fs.writeFileSync(path.join(sub2, "subtitle.srt"), "subtitle content");
|
||||||
|
|
||||||
const removed = cleanupCancelledPackageArtifacts(dir);
|
const removed = cleanupCancelledPackageArtifacts(dir);
|
||||||
expect(removed).toBe(4);
|
expect(removed).toBe(4);
|
||||||
expect(fs.existsSync(path.join(sub1, "episode.part1.rar"))).toBe(false);
|
expect(fs.existsSync(path.join(sub1, "episode.part1.rar"))).toBe(false);
|
||||||
expect(fs.existsSync(path.join(sub1, "episode.part2.rar"))).toBe(false);
|
expect(fs.existsSync(path.join(sub1, "episode.part2.rar"))).toBe(false);
|
||||||
expect(fs.existsSync(path.join(sub2, "bonus.zip"))).toBe(false);
|
expect(fs.existsSync(path.join(sub2, "bonus.zip"))).toBe(false);
|
||||||
expect(fs.existsSync(path.join(sub2, "bonus.7z"))).toBe(false);
|
expect(fs.existsSync(path.join(sub2, "bonus.7z"))).toBe(false);
|
||||||
expect(fs.existsSync(path.join(sub1, "video.mkv"))).toBe(true);
|
expect(fs.existsSync(path.join(sub1, "video.mkv"))).toBe(true);
|
||||||
expect(fs.existsSync(path.join(sub2, "subtitle.srt"))).toBe(true);
|
expect(fs.existsSync(path.join(sub2, "subtitle.srt"))).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("detects link artifacts by URL content in text files", async () => {
|
it("detects link artifacts by URL content in text files", async () => {
|
||||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-clean-"));
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-clean-"));
|
||||||
tempDirs.push(dir);
|
tempDirs.push(dir);
|
||||||
|
|
||||||
fs.writeFileSync(path.join(dir, "download_links.txt"), "https://rapidgator.net/file/abc123\nhttps://uploaded.net/file/def456\n");
|
fs.writeFileSync(path.join(dir, "download_links.txt"), "https://rapidgator.net/file/abc123\nhttps://uploaded.net/file/def456\n");
|
||||||
fs.writeFileSync(path.join(dir, "my_downloads.txt"), "Just some random text without URLs");
|
fs.writeFileSync(path.join(dir, "my_downloads.txt"), "Just some random text without URLs");
|
||||||
fs.writeFileSync(path.join(dir, "readme.txt"), "https://example.com");
|
fs.writeFileSync(path.join(dir, "readme.txt"), "https://example.com");
|
||||||
fs.writeFileSync(path.join(dir, "bookmark.url"), "[InternetShortcut]\nURL=https://example.com");
|
fs.writeFileSync(path.join(dir, "bookmark.url"), "[InternetShortcut]\nURL=https://example.com");
|
||||||
fs.writeFileSync(path.join(dir, "container.dlc"), "encrypted-data");
|
fs.writeFileSync(path.join(dir, "container.dlc"), "encrypted-data");
|
||||||
|
|
||||||
const removed = await removeDownloadLinkArtifacts(dir);
|
const removed = await removeDownloadLinkArtifacts(dir);
|
||||||
expect(removed).toBeGreaterThanOrEqual(3);
|
expect(removed).toBeGreaterThanOrEqual(3);
|
||||||
expect(fs.existsSync(path.join(dir, "download_links.txt"))).toBe(false);
|
expect(fs.existsSync(path.join(dir, "download_links.txt"))).toBe(false);
|
||||||
expect(fs.existsSync(path.join(dir, "bookmark.url"))).toBe(false);
|
expect(fs.existsSync(path.join(dir, "bookmark.url"))).toBe(false);
|
||||||
expect(fs.existsSync(path.join(dir, "container.dlc"))).toBe(false);
|
expect(fs.existsSync(path.join(dir, "container.dlc"))).toBe(false);
|
||||||
expect(fs.existsSync(path.join(dir, "readme.txt"))).toBe(true);
|
expect(fs.existsSync(path.join(dir, "readme.txt"))).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("does not recurse into sample symlink or junction targets", async () => {
|
it("does not recurse into sample symlink or junction targets", async () => {
|
||||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-clean-"));
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-clean-"));
|
||||||
const external = fs.mkdtempSync(path.join(os.tmpdir(), "rd-clean-ext-"));
|
const external = fs.mkdtempSync(path.join(os.tmpdir(), "rd-clean-ext-"));
|
||||||
tempDirs.push(dir, external);
|
tempDirs.push(dir, external);
|
||||||
|
|
||||||
const outsideFile = path.join(external, "outside-sample.mkv");
|
const outsideFile = path.join(external, "outside-sample.mkv");
|
||||||
fs.writeFileSync(outsideFile, "keep", "utf8");
|
fs.writeFileSync(outsideFile, "keep", "utf8");
|
||||||
|
|
||||||
const linkedSampleDir = path.join(dir, "sample");
|
const linkedSampleDir = path.join(dir, "sample");
|
||||||
const linkType: fs.symlink.Type = process.platform === "win32" ? "junction" : "dir";
|
const linkType: fs.symlink.Type = process.platform === "win32" ? "junction" : "dir";
|
||||||
fs.symlinkSync(external, linkedSampleDir, linkType);
|
fs.symlinkSync(external, linkedSampleDir, linkType);
|
||||||
|
|
||||||
const result = await removeSampleArtifacts(dir);
|
const result = await removeSampleArtifacts(dir);
|
||||||
expect(result.files).toBe(0);
|
expect(result.files).toBe(0);
|
||||||
expect(fs.existsSync(outsideFile)).toBe(true);
|
expect(fs.existsSync(outsideFile)).toBe(true);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -1,54 +1,42 @@
|
|||||||
import { describe, it, expect } from "vitest";
|
import { describe, it, expect } from "vitest";
|
||||||
import { encodeConnectionCode } from "../src/main/connection-code";
|
import { encodeConnectionCode } from "../src/main/connection-code";
|
||||||
|
import { decodeConnectionCode } from "../tools/rd-diagnostics-mcp/src/code.mjs";
|
||||||
|
|
||||||
function decodeConnectionCode(code: string) {
|
describe("connection-code", () => {
|
||||||
const encoded = code.slice("rddiag:v1:".length).replace(/-/g, "+").replace(/_/g, "/");
|
it("round-trips through the bridge decoder", () => {
|
||||||
const payload = JSON.parse(Buffer.from(encoded, "base64").toString("utf8")) as Record<string, unknown>;
|
const code = encodeConnectionCode({ host: "203.0.113.5", port: 9868, token: "deadbeef", name: "server-1" });
|
||||||
return {
|
expect(code.startsWith("rddiag:v1:")).toBe(true);
|
||||||
host: payload.h,
|
const decoded = decodeConnectionCode(code);
|
||||||
port: payload.p,
|
expect(decoded.host).toBe("203.0.113.5");
|
||||||
token: payload.t,
|
expect(decoded.port).toBe(9868);
|
||||||
name: payload.n,
|
expect(decoded.token).toBe("deadbeef");
|
||||||
scheme: payload.s ?? "http",
|
expect(decoded.name).toBe("server-1");
|
||||||
fingerprint: payload.fp
|
expect(decoded.scheme).toBe("http");
|
||||||
};
|
});
|
||||||
}
|
|
||||||
|
it("carries https scheme and fingerprint when set", () => {
|
||||||
describe("connection-code", () => {
|
const code = encodeConnectionCode({
|
||||||
it("encodes a directly decodable payload", () => {
|
host: "diag.example.com",
|
||||||
const code = encodeConnectionCode({ host: "203.0.113.5", port: 9868, token: "deadbeef", name: "server-1" });
|
port: 8443,
|
||||||
expect(code.startsWith("rddiag:v1:")).toBe(true);
|
token: "abc",
|
||||||
const decoded = decodeConnectionCode(code);
|
scheme: "https",
|
||||||
expect(decoded.host).toBe("203.0.113.5");
|
fingerprint: "AA:BB:CC"
|
||||||
expect(decoded.port).toBe(9868);
|
});
|
||||||
expect(decoded.token).toBe("deadbeef");
|
const decoded = decodeConnectionCode(code);
|
||||||
expect(decoded.name).toBe("server-1");
|
expect(decoded.scheme).toBe("https");
|
||||||
expect(decoded.scheme).toBe("http");
|
expect(decoded.fingerprint).toBe("AA:BB:CC");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("carries https scheme and fingerprint when set", () => {
|
it("omits scheme key for plain http (default)", () => {
|
||||||
const code = encodeConnectionCode({
|
const code = encodeConnectionCode({ host: "10.0.0.2", port: 9868, token: "t" });
|
||||||
host: "diag.example.com",
|
const json = JSON.parse(Buffer.from(code.slice("rddiag:v1:".length).replace(/-/g, "+").replace(/_/g, "/"), "base64").toString("utf8"));
|
||||||
port: 8443,
|
expect(json.s).toBeUndefined();
|
||||||
token: "abc",
|
expect(json).toMatchObject({ v: 1, h: "10.0.0.2", p: 9868, t: "t" });
|
||||||
scheme: "https",
|
});
|
||||||
fingerprint: "AA:BB:CC"
|
|
||||||
});
|
it("rejects invalid input", () => {
|
||||||
const decoded = decodeConnectionCode(code);
|
expect(() => encodeConnectionCode({ host: "", port: 9868, token: "t" })).toThrow();
|
||||||
expect(decoded.scheme).toBe("https");
|
expect(() => encodeConnectionCode({ host: "h", port: 0, token: "t" })).toThrow();
|
||||||
expect(decoded.fingerprint).toBe("AA:BB:CC");
|
expect(() => encodeConnectionCode({ host: "h", port: 9868, token: "" })).toThrow();
|
||||||
});
|
});
|
||||||
|
});
|
||||||
it("omits scheme key for plain http (default)", () => {
|
|
||||||
const code = encodeConnectionCode({ host: "10.0.0.2", port: 9868, token: "t" });
|
|
||||||
const json = JSON.parse(Buffer.from(code.slice("rddiag:v1:".length).replace(/-/g, "+").replace(/_/g, "/"), "base64").toString("utf8"));
|
|
||||||
expect(json.s).toBeUndefined();
|
|
||||||
expect(json).toMatchObject({ v: 1, h: "10.0.0.2", p: 9868, t: "t" });
|
|
||||||
});
|
|
||||||
|
|
||||||
it("rejects invalid input", () => {
|
|
||||||
expect(() => encodeConnectionCode({ host: "", port: 9868, token: "t" })).toThrow();
|
|
||||||
expect(() => encodeConnectionCode({ host: "h", port: 0, token: "t" })).toThrow();
|
|
||||||
expect(() => encodeConnectionCode({ host: "h", port: 9868, token: "" })).toThrow();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|||||||
@ -1,202 +1,202 @@
|
|||||||
import fs from "node:fs";
|
import fs from "node:fs";
|
||||||
import os from "node:os";
|
import os from "node:os";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
import { importDlcContainers } from "../src/main/container";
|
import { importDlcContainers } from "../src/main/container";
|
||||||
|
|
||||||
const tempDirs: string[] = [];
|
const tempDirs: string[] = [];
|
||||||
const originalFetch = globalThis.fetch;
|
const originalFetch = globalThis.fetch;
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
globalThis.fetch = originalFetch;
|
globalThis.fetch = originalFetch;
|
||||||
vi.restoreAllMocks();
|
vi.restoreAllMocks();
|
||||||
for (const dir of tempDirs.splice(0)) {
|
for (const dir of tempDirs.splice(0)) {
|
||||||
fs.rmSync(dir, { recursive: true, force: true });
|
fs.rmSync(dir, { recursive: true, force: true });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("container", () => {
|
describe("container", () => {
|
||||||
it("skips oversized DLC files without throwing and blocking other files", async () => {
|
it("skips oversized DLC files without throwing and blocking other files", async () => {
|
||||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dlc-"));
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dlc-"));
|
||||||
tempDirs.push(dir);
|
tempDirs.push(dir);
|
||||||
const oversizedFilePath = path.join(dir, "oversized.dlc");
|
const oversizedFilePath = path.join(dir, "oversized.dlc");
|
||||||
fs.writeFileSync(oversizedFilePath, Buffer.alloc((8 * 1024 * 1024) + 1, 1));
|
fs.writeFileSync(oversizedFilePath, Buffer.alloc((8 * 1024 * 1024) + 1, 1));
|
||||||
|
|
||||||
const validFilePath = path.join(dir, "valid.dlc");
|
const validFilePath = path.join(dir, "valid.dlc");
|
||||||
fs.writeFileSync(validFilePath, Buffer.from("Valid but not real DLC content..."));
|
fs.writeFileSync(validFilePath, Buffer.from("Valid but not real DLC content..."));
|
||||||
|
|
||||||
const fetchSpy = vi.fn(async (url: string | URL | Request) => {
|
const fetchSpy = vi.fn(async (url: string | URL | Request) => {
|
||||||
const urlStr = String(url);
|
const urlStr = String(url);
|
||||||
if (urlStr.includes("dcrypt.it/decrypt/upload")) {
|
if (urlStr.includes("dcrypt.it/decrypt/upload")) {
|
||||||
return new Response("http://example.com/file1.rar\nhttp://example.com/file2.rar", { status: 200 });
|
return new Response("http://example.com/file1.rar\nhttp://example.com/file2.rar", { status: 200 });
|
||||||
}
|
}
|
||||||
return new Response("", { status: 404 });
|
return new Response("", { status: 404 });
|
||||||
});
|
});
|
||||||
globalThis.fetch = fetchSpy as unknown as typeof fetch;
|
globalThis.fetch = fetchSpy as unknown as typeof fetch;
|
||||||
|
|
||||||
const result = await importDlcContainers([oversizedFilePath, validFilePath]);
|
const result = await importDlcContainers([oversizedFilePath, validFilePath]);
|
||||||
|
|
||||||
expect(result).toHaveLength(1);
|
expect(result).toHaveLength(1);
|
||||||
expect(result[0].name).toBe("valid");
|
expect(result[0].name).toBe("valid");
|
||||||
expect(result[0].links).toEqual(["http://example.com/file1.rar", "http://example.com/file2.rar"]);
|
expect(result[0].links).toEqual(["http://example.com/file1.rar", "http://example.com/file2.rar"]);
|
||||||
expect(fetchSpy).toHaveBeenCalledTimes(1);
|
expect(fetchSpy).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("skips non-dlc files completely", async () => {
|
it("skips non-dlc files completely", async () => {
|
||||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dlc-non-"));
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dlc-non-"));
|
||||||
tempDirs.push(dir);
|
tempDirs.push(dir);
|
||||||
const txtPath = path.join(dir, "links.txt");
|
const txtPath = path.join(dir, "links.txt");
|
||||||
fs.writeFileSync(txtPath, "http://link.com/1");
|
fs.writeFileSync(txtPath, "http://link.com/1");
|
||||||
|
|
||||||
const result = await importDlcContainers([txtPath]);
|
const result = await importDlcContainers([txtPath]);
|
||||||
expect(result).toEqual([]);
|
expect(result).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("falls back to dcrypt if local decryption returns empty", async () => {
|
it("falls back to dcrypt if local decryption returns empty", async () => {
|
||||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dlc-"));
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dlc-"));
|
||||||
tempDirs.push(dir);
|
tempDirs.push(dir);
|
||||||
const filePath = path.join(dir, "fallback.dlc");
|
const filePath = path.join(dir, "fallback.dlc");
|
||||||
|
|
||||||
fs.writeFileSync(filePath, Buffer.alloc(100, 1).toString("base64"));
|
fs.writeFileSync(filePath, Buffer.alloc(100, 1).toString("base64"));
|
||||||
|
|
||||||
const fetchSpy = vi.fn(async (url: string | URL | Request) => {
|
const fetchSpy = vi.fn(async (url: string | URL | Request) => {
|
||||||
const urlStr = String(url);
|
const urlStr = String(url);
|
||||||
if (urlStr.includes("service.jdownloader.org")) {
|
if (urlStr.includes("service.jdownloader.org")) {
|
||||||
return new Response("", { status: 404 });
|
return new Response("", { status: 404 });
|
||||||
}
|
}
|
||||||
if (urlStr.includes("dcrypt.it/decrypt/upload")) {
|
if (urlStr.includes("dcrypt.it/decrypt/upload")) {
|
||||||
return new Response("http://fallback.com/1", { status: 200 });
|
return new Response("http://fallback.com/1", { status: 200 });
|
||||||
}
|
}
|
||||||
return new Response("", { status: 404 });
|
return new Response("", { status: 404 });
|
||||||
});
|
});
|
||||||
globalThis.fetch = fetchSpy as unknown as typeof fetch;
|
globalThis.fetch = fetchSpy as unknown as typeof fetch;
|
||||||
|
|
||||||
const result = await importDlcContainers([filePath]);
|
const result = await importDlcContainers([filePath]);
|
||||||
expect(result).toHaveLength(1);
|
expect(result).toHaveLength(1);
|
||||||
expect(result[0].name).toBe("fallback");
|
expect(result[0].name).toBe("fallback");
|
||||||
expect(result[0].links).toEqual(["http://fallback.com/1"]);
|
expect(result[0].links).toEqual(["http://fallback.com/1"]);
|
||||||
expect(fetchSpy).toHaveBeenCalledTimes(2);
|
expect(fetchSpy).toHaveBeenCalledTimes(2);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("falls back to dcrypt when local decryption throws invalid padding", async () => {
|
it("falls back to dcrypt when local decryption throws invalid padding", async () => {
|
||||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dlc-"));
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dlc-"));
|
||||||
tempDirs.push(dir);
|
tempDirs.push(dir);
|
||||||
const filePath = path.join(dir, "invalid-local.dlc");
|
const filePath = path.join(dir, "invalid-local.dlc");
|
||||||
fs.writeFileSync(filePath, "X".repeat(120));
|
fs.writeFileSync(filePath, "X".repeat(120));
|
||||||
|
|
||||||
const fetchSpy = vi.fn(async (url: string | URL | Request) => {
|
const fetchSpy = vi.fn(async (url: string | URL | Request) => {
|
||||||
const urlStr = String(url);
|
const urlStr = String(url);
|
||||||
if (urlStr.includes("service.jdownloader.org")) {
|
if (urlStr.includes("service.jdownloader.org")) {
|
||||||
return new Response(`<rc>${Buffer.alloc(16).toString("base64")}</rc>`, { status: 200 });
|
return new Response(`<rc>${Buffer.alloc(16).toString("base64")}</rc>`, { status: 200 });
|
||||||
}
|
}
|
||||||
if (urlStr.includes("dcrypt.it/decrypt/upload")) {
|
if (urlStr.includes("dcrypt.it/decrypt/upload")) {
|
||||||
return new Response("http://example.com/fallback1", { status: 200 });
|
return new Response("http://example.com/fallback1", { status: 200 });
|
||||||
}
|
}
|
||||||
return new Response("", { status: 404 });
|
return new Response("", { status: 404 });
|
||||||
});
|
});
|
||||||
globalThis.fetch = fetchSpy as unknown as typeof fetch;
|
globalThis.fetch = fetchSpy as unknown as typeof fetch;
|
||||||
|
|
||||||
const result = await importDlcContainers([filePath]);
|
const result = await importDlcContainers([filePath]);
|
||||||
expect(result).toHaveLength(1);
|
expect(result).toHaveLength(1);
|
||||||
expect(result[0].name).toBe("invalid-local");
|
expect(result[0].name).toBe("invalid-local");
|
||||||
expect(result[0].links).toEqual(["http://example.com/fallback1"]);
|
expect(result[0].links).toEqual(["http://example.com/fallback1"]);
|
||||||
expect(fetchSpy).toHaveBeenCalledTimes(2);
|
expect(fetchSpy).toHaveBeenCalledTimes(2);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("falls back to paste endpoint when upload returns 413", async () => {
|
it("falls back to paste endpoint when upload returns 413", async () => {
|
||||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dlc-"));
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dlc-"));
|
||||||
tempDirs.push(dir);
|
tempDirs.push(dir);
|
||||||
const filePath = path.join(dir, "big-dlc.dlc");
|
const filePath = path.join(dir, "big-dlc.dlc");
|
||||||
fs.writeFileSync(filePath, Buffer.alloc(100, 1).toString("base64"));
|
fs.writeFileSync(filePath, Buffer.alloc(100, 1).toString("base64"));
|
||||||
|
|
||||||
const fetchSpy = vi.fn(async (url: string | URL | Request) => {
|
const fetchSpy = vi.fn(async (url: string | URL | Request) => {
|
||||||
const urlStr = String(url);
|
const urlStr = String(url);
|
||||||
if (urlStr.includes("service.jdownloader.org")) {
|
if (urlStr.includes("service.jdownloader.org")) {
|
||||||
return new Response("", { status: 404 });
|
return new Response("", { status: 404 });
|
||||||
}
|
}
|
||||||
if (urlStr.includes("dcrypt.it/decrypt/upload")) {
|
if (urlStr.includes("dcrypt.it/decrypt/upload")) {
|
||||||
return new Response("Request Entity Too Large", { status: 413 });
|
return new Response("Request Entity Too Large", { status: 413 });
|
||||||
}
|
}
|
||||||
if (urlStr.includes("dcrypt.it/decrypt/paste")) {
|
if (urlStr.includes("dcrypt.it/decrypt/paste")) {
|
||||||
return new Response("http://paste-fallback.com/file1.rar\nhttp://paste-fallback.com/file2.rar", { status: 200 });
|
return new Response("http://paste-fallback.com/file1.rar\nhttp://paste-fallback.com/file2.rar", { status: 200 });
|
||||||
}
|
}
|
||||||
return new Response("", { status: 404 });
|
return new Response("", { status: 404 });
|
||||||
});
|
});
|
||||||
globalThis.fetch = fetchSpy as unknown as typeof fetch;
|
globalThis.fetch = fetchSpy as unknown as typeof fetch;
|
||||||
|
|
||||||
const result = await importDlcContainers([filePath]);
|
const result = await importDlcContainers([filePath]);
|
||||||
expect(result).toHaveLength(1);
|
expect(result).toHaveLength(1);
|
||||||
expect(result[0].name).toBe("big-dlc");
|
expect(result[0].name).toBe("big-dlc");
|
||||||
expect(result[0].links).toEqual(["http://paste-fallback.com/file1.rar", "http://paste-fallback.com/file2.rar"]);
|
expect(result[0].links).toEqual(["http://paste-fallback.com/file1.rar", "http://paste-fallback.com/file2.rar"]);
|
||||||
expect(fetchSpy).toHaveBeenCalledTimes(3);
|
expect(fetchSpy).toHaveBeenCalledTimes(3);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("throws when both dcrypt endpoints return 413", async () => {
|
it("throws when both dcrypt endpoints return 413", async () => {
|
||||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dlc-"));
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dlc-"));
|
||||||
tempDirs.push(dir);
|
tempDirs.push(dir);
|
||||||
const filePath = path.join(dir, "huge.dlc");
|
const filePath = path.join(dir, "huge.dlc");
|
||||||
fs.writeFileSync(filePath, Buffer.alloc(100, 1).toString("base64"));
|
fs.writeFileSync(filePath, Buffer.alloc(100, 1).toString("base64"));
|
||||||
|
|
||||||
const fetchSpy = vi.fn(async (url: string | URL | Request) => {
|
const fetchSpy = vi.fn(async (url: string | URL | Request) => {
|
||||||
const urlStr = String(url);
|
const urlStr = String(url);
|
||||||
if (urlStr.includes("service.jdownloader.org")) {
|
if (urlStr.includes("service.jdownloader.org")) {
|
||||||
return new Response("", { status: 404 });
|
return new Response("", { status: 404 });
|
||||||
}
|
}
|
||||||
if (urlStr.includes("dcrypt.it/decrypt/upload")) {
|
if (urlStr.includes("dcrypt.it/decrypt/upload")) {
|
||||||
return new Response("Request Entity Too Large", { status: 413 });
|
return new Response("Request Entity Too Large", { status: 413 });
|
||||||
}
|
}
|
||||||
if (urlStr.includes("dcrypt.it/decrypt/paste")) {
|
if (urlStr.includes("dcrypt.it/decrypt/paste")) {
|
||||||
return new Response("Request Entity Too Large", { status: 413 });
|
return new Response("Request Entity Too Large", { status: 413 });
|
||||||
}
|
}
|
||||||
return new Response("", { status: 500 });
|
return new Response("", { status: 500 });
|
||||||
});
|
});
|
||||||
globalThis.fetch = fetchSpy as unknown as typeof fetch;
|
globalThis.fetch = fetchSpy as unknown as typeof fetch;
|
||||||
|
|
||||||
await expect(importDlcContainers([filePath])).rejects.toThrow(/zu groß für dcrypt/i);
|
await expect(importDlcContainers([filePath])).rejects.toThrow(/zu groß für dcrypt/i);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("throws when upload returns 413 and paste returns 500", async () => {
|
it("throws when upload returns 413 and paste returns 500", async () => {
|
||||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dlc-"));
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dlc-"));
|
||||||
tempDirs.push(dir);
|
tempDirs.push(dir);
|
||||||
const filePath = path.join(dir, "doomed.dlc");
|
const filePath = path.join(dir, "doomed.dlc");
|
||||||
fs.writeFileSync(filePath, Buffer.from("not a valid dlc payload at all"));
|
fs.writeFileSync(filePath, Buffer.from("not a valid dlc payload at all"));
|
||||||
|
|
||||||
const fetchSpy = vi.fn(async (url: string | URL | Request) => {
|
const fetchSpy = vi.fn(async (url: string | URL | Request) => {
|
||||||
const urlStr = String(url);
|
const urlStr = String(url);
|
||||||
if (urlStr.includes("service.jdownloader.org")) {
|
if (urlStr.includes("service.jdownloader.org")) {
|
||||||
return new Response("", { status: 404 });
|
return new Response("", { status: 404 });
|
||||||
}
|
}
|
||||||
if (urlStr.includes("dcrypt.it/decrypt/upload")) {
|
if (urlStr.includes("dcrypt.it/decrypt/upload")) {
|
||||||
return new Response("Request Entity Too Large", { status: 413 });
|
return new Response("Request Entity Too Large", { status: 413 });
|
||||||
}
|
}
|
||||||
if (urlStr.includes("dcrypt.it/decrypt/paste")) {
|
if (urlStr.includes("dcrypt.it/decrypt/paste")) {
|
||||||
return new Response("paste failure", { status: 500 });
|
return new Response("paste failure", { status: 500 });
|
||||||
}
|
}
|
||||||
return new Response("", { status: 500 });
|
return new Response("", { status: 500 });
|
||||||
});
|
});
|
||||||
globalThis.fetch = fetchSpy as unknown as typeof fetch;
|
globalThis.fetch = fetchSpy as unknown as typeof fetch;
|
||||||
|
|
||||||
await expect(importDlcContainers([filePath])).rejects.toThrow(/DLC konnte nicht importiert werden/i);
|
await expect(importDlcContainers([filePath])).rejects.toThrow(/DLC konnte nicht importiert werden/i);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("throws clear error when all dlc imports fail", async () => {
|
it("throws clear error when all dlc imports fail", async () => {
|
||||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dlc-"));
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dlc-"));
|
||||||
tempDirs.push(dir);
|
tempDirs.push(dir);
|
||||||
const filePath = path.join(dir, "broken.dlc");
|
const filePath = path.join(dir, "broken.dlc");
|
||||||
fs.writeFileSync(filePath, Buffer.from("not a valid dlc payload at all"));
|
fs.writeFileSync(filePath, Buffer.from("not a valid dlc payload at all"));
|
||||||
|
|
||||||
const fetchSpy = vi.fn(async (url: string | URL | Request) => {
|
const fetchSpy = vi.fn(async (url: string | URL | Request) => {
|
||||||
const urlStr = String(url);
|
const urlStr = String(url);
|
||||||
if (urlStr.includes("service.jdownloader.org")) {
|
if (urlStr.includes("service.jdownloader.org")) {
|
||||||
return new Response("", { status: 404 });
|
return new Response("", { status: 404 });
|
||||||
}
|
}
|
||||||
if (urlStr.includes("dcrypt.it/decrypt/upload")) {
|
if (urlStr.includes("dcrypt.it/decrypt/upload")) {
|
||||||
return new Response("upstream failure", { status: 500 });
|
return new Response("upstream failure", { status: 500 });
|
||||||
}
|
}
|
||||||
return new Response("", { status: 500 });
|
return new Response("", { status: 500 });
|
||||||
});
|
});
|
||||||
globalThis.fetch = fetchSpy as unknown as typeof fetch;
|
globalThis.fetch = fetchSpy as unknown as typeof fetch;
|
||||||
|
|
||||||
await expect(importDlcContainers([filePath])).rejects.toThrow(/DLC konnte nicht importiert werden/i);
|
await expect(importDlcContainers([filePath])).rejects.toThrow(/DLC konnte nicht importiert werden/i);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user